Il Drag’n’Drop, in termini di interfaccia utente, è una soluzione grandiosa. Il fatto di poter prendere qualcosa, trascinarla e rilasciarla è un modo semplice ed intuitivo per fare tantissime operazioni, dal copiare e spostare documenti (come nei gestori di files), al fare un ordine online (rilasciando un prodotto in un carrello).
Nello standard HTML attuale c’è una sezione sul Drag and Drop con eventi speciali come dragstart
, dragend
, e via dicendo.
Questi eventi ci permettono di supportare tipi particolari di drag’n’drop, come gestire il trascinamento di un file dal sistema operativo ed il rilascio dentro la finestra del browser, in modo che JavaScript possa accedere al contenuto di tali files.
Ma i Drag Events hanno anche delle limitazioni. Ad esempio, non possiamo prevenire il trascinamento da una certa sezione. Inoltre non possiamo rendere il trascinamento solo “orizzontale” o “verticale”. E ci sono tante altre azioni drag’n’drop che non è possibile sfruttare. Inoltre il supporto dei dispositivi mobile per questo tipo di eventi è abbastanza scarso.
Di conseguenza, vedremo come implementare il Drag’n’Drop solo tramite l’utilizzo degli eventi del mouse.
Algoritmo del Drag’n’Drop
L’algoritmo di base del Drag’n’Drop è qualcosa del genere:
- Al
mousedown
– prepara l’elemento per lo spostamento, se necessario (magari crea un suo clone, o aggiungi una classe o altro). - Quindi al
mousemove
spostalo variandoleft/top
con ilposition:absolute
. - Al
mouseup
– esegui tutte le azioni coinvolte per completare il drag’n’drop.
Queste sono le basi. Dopo vedremo come gestire altre caratteristiche, come evidenziare gli altri elementi sottostanti mentre effettuiamo il trascinamento su di essi.
Ecco un’implementazione del trascinamento di un pallone:
ball.onmousedown = function(event) {
// (1) preparazione dello spostamento: imposta il posizionamento assoluto e lo z-index al massimo valore utile
ball.style.position = 'absolute';
ball.style.zIndex = 1000;
// spostamento all'esterno da ogni elemento genitore e direttamente verso il body della pagina
// per posizionarlo relativamente ad esso
document.body.append(ball);
// centratura del pallone alle coordinate (pageX, pageY)
function moveAt(pageX, pageY) {
ball.style.left = pageX - ball.offsetWidth / 2 + 'px';
ball.style.top = pageY - ball.offsetHeight / 2 + 'px';
}
// spostamento del nostro pallone con posizionamento assoluto sotto il puntatore
moveAt(event.pageX, event.pageY);
function onMouseMove(event) {
moveAt(event.pageX, event.pageY);
}
// (2) spostamento del pallone al mousemove
document.addEventListener('mousemove', onMouseMove);
// (3) rilascio del pallone, rimozione dei gestori non necessari
ball.onmouseup = function() {
document.removeEventListener('mousemove', onMouseMove);
ball.onmouseup = null;
};
};
Eseguendo il codice, noteremo qualcosa di anomalo. All’inizio del drag’n’drop, il pallone si “duplica”: abbiamo cominciato trascinando il suo “clone”.
Ecco un esempio in azione:
Prova a fare il drag’n’drop con il mouse per vedere questo comportamento anomalo.
Questo accade perché il browser ha una sua implementazione del drag’n’drop per immagini e qualche altro elemento. La esegue in automatico e va quindi in conflitto con la nostra.
Per disabilitarlo:
ball.ondragstart = function() {
return false;
};
Ora è tutto a posto.
In azione:
Un altro aspetto importante – noi teniamo traccia di mousemove
nel document
, non su ball
. A prima vista potrebbe sembrare che il mouse sia sempre sopra il pallone, e possiamo attivare mousemove
su di essa.
Ma come noto, mousemove
viene generato spesso, ma non per ogni pixel. Quindi a seguito di qualche movimento rapido potrebbe saltare dal pallone a qualche punto nel bel mezzo del documento (o anche fuori dalla finestra).
Di conseguenza dovremmo metterci in ascolto sul document
per la cattura.
Posizionamento corretto
Negli esempi precedenti il pallone viene sempre spostato in modo che il suo centro sia sotto il puntatore:
ball.style.left = pageX - ball.offsetWidth / 2 + 'px';
ball.style.top = pageY - ball.offsetHeight / 2 + 'px';
Non male, ma c’è un effetto collaterale. Per innescare il drag’n’drop, potremmo cominciare mousedown
da un punto qualunque del pallone. Ma se lo “prendessimo” dai bordi, si centrerebbe repentinamente sotto il puntatore facendo una specie di “salto”.
Sarebbe meglio se mantenessimo lo spostamento iniziale dell’elemento rispetto al puntatore.
Ad esempio, se cominciassimo dal bordo del pallone, il puntatore dovrebbe rimanere sul bordo mentre lo trasciniamo.
Aggiorniamo il nostro algoritmo:
-
Quando un utente preme il pulsante (
mousedown
) – memorizza la distanza del puntatore dall’angolo in alto a sinistra del pallone nelle variabilishiftX/shiftY
. Manterremo questa distanza durante il trascinamento.Per ottenere questi scostamenti possiamo sottrarre le coordinate:
// onmousedown let shiftX = event.clientX - ball.getBoundingClientRect().left; let shiftY = event.clientY - ball.getBoundingClientRect().top;
-
Quindi, durante il trascinamento posizioneremo il pallone con lo stesso scostamento relativo al puntatore, in questo modo:
// onmousemove // il pallone ha position:absolute ball.style.left = event.pageX - shiftX + 'px'; ball.style.top = event.pageY - shiftY + 'px';
Il codice definitivo con un posizionamento ottimale:
ball.onmousedown = function(event) {
let shiftX = event.clientX - ball.getBoundingClientRect().left;
let shiftY = event.clientY - ball.getBoundingClientRect().top;
ball.style.position = 'absolute';
ball.style.zIndex = 1000;
document.body.append(ball);
moveAt(event.pageX, event.pageY);
// sposta il pallone alle coordinate (pageX, pageY)
// tenendo conto dello scostamento iniziale
function moveAt(pageX, pageY) {
ball.style.left = pageX - shiftX + 'px';
ball.style.top = pageY - shiftY + 'px';
}
function onMouseMove(event) {
moveAt(event.pageX, event.pageY);
}
// muovi il pallone al mousemove
document.addEventListener('mousemove', onMouseMove);
// rilascia il pallone, rimuovi i gestori non necessari
ball.onmouseup = function() {
document.removeEventListener('mousemove', onMouseMove);
ball.onmouseup = null;
};
};
ball.ondragstart = function() {
return false;
};
In azione (inside <iframe>
):
La differenza è particolarmente visibile se trasciniamo il pallone dall’angolo in basso a destra. Nell’esempio precedente, il pallone “salterebbe” sotto il puntatore. Adesso segue fluidamente il puntatore dalla posizione corrente.
Potenziali obiettivi per il drop (droppables)
Negli esempi precedenti il pallone può essere rilasciato “ovunque”. In applicazioni concrete generalmente prendiamo un oggetto e lo lasciamo su un altro. Ad esempio, un “file” dentro una “cartella” o cose del genere.
Parlando in maniera astratta, prendiamo un elemento “draggable” e lo rilasciamo su uno “droppable”.
Dobbiamo quindi sapere:
- dove viene rilasciato l’elemento alla fine del Drag’n’Drop – per eseguire l’azione corrispondente,
- e, preferibilmente, conoscere il droppable sul quale lo stiamo rilasciando, per evidenziarlo.
La soluzione è piuttosto interessante e leggermente complicata, e la affronteremo qui.
Quale potrebbe essere l’idea iniziale? Probabilmente quella di impostare dei gestori mouseover/mouseup
sui potenziali droppables?
Non funzionerebbe.
Il problema principale, sarebbe che durante il trascinamento, l’elemento draggable è sempre sopra gli altri. E gli eventi del mouse vengono generati sull’elemento superiore, e non su quelli sotto.
Per esempio, sotto ci sono due elementi <div>
, uno rosso sopra uno blu (lo copre del tutto). Non c’è modo di catturare un evento su quello blu, perché il rosso sta sopra:
<style>
div {
width: 50px;
height: 50px;
position: absolute;
top: 0;
}
</style>
<div style="background:blue" onmouseover="alert('non funziona mai')"></div>
<div style="background:red" onmouseover="alert('sul rosso!')"></div>
Stessa cosa per un elemento draggable. Il pallone sta sempre sopra gli altri elementi, e quindi gli eventi vengono generati su di esso. Qualunque gestore assegnassimo agli elementi sotto, non funzionerebbero.
Questo è il motivo per cui l’idea iniziale di impostare i gestori su dei potenziali droppables non funziona. I gestori non verrebbero eseguiti.
Come fare, quindi?
C’è un metodo chiamato document.elementFromPoint(clientX, clientY)
che restituisce le coordinate relative alla window, dell’elemento più annidato (o null
se le coordinate restituite sono fuori dalla window).
Possiamo usarlo su qualunque nostro gestore di evento del mouse per rilevare dei potenziali droppable sotto il puntatore, in questo modo:
// dentro un gestore di evento del mouse
ball.hidden = true; // (*) nasconde l'elemento che trasciniamo
let elemBelow = document.elementFromPoint(event.clientX, event.clientY);
// elemBelow è l'elemento sotto il pallone, potrebbe essere droppable
ball.hidden = false;
Nota bene: abbiamo bisogno di nascondere il pallone prima della chiamata (*)
. Altrimenti otterremmo la palla a queste coordinate, dato che sarebbe questo l’elemento più in alto sotto il puntatore: elemBelow=ball
. Quindi lo nascondiamo per mostrarlo nuovamente immediatamente dopo.
Possiamo usare questo codice per sapere quale elemento stiamo “sorvolando” in ogni momento. E gestire il rilascio quando accade.
Un codice esteso di onMouseMove
per individuare gli elementi “droppable”:
// droppable potenziale che stiamo sorvolando in questo momento
let currentDroppable = null;
function onMouseMove(event) {
moveAt(event.pageX, event.pageY);
ball.hidden = true;
let elemBelow = document.elementFromPoint(event.clientX, event.clientY);
ball.hidden = false;
// gli eventi mousemove possono essere generati fuori dalla window (quando il pallone è trascinato fuori dallo schermo)
// se clientX/clientY sono fuori dalla window, elementFromPoint restituisce null
if (!elemBelow) return;
// i potenziali elementi droppables sono contrassegnati con la classe "droppable" (la logica potrebbe essere anche altra)
let droppableBelow = elemBelow.closest('.droppable');
if (currentDroppable != droppableBelow) {
// sorvolando in entrata o in uscita...
// nota bene: entrambi i valori potrebbero essere null
// currentDroppable=null se non siamo su un elemento prima di questo evento (es. su uno spazio vuoto)
// droppableBelow=null se non siamo attualmente droppable, durante questo evento
if (currentDroppable) {
// la logica per elaborare "il sorvolo in uscita" dal droppable (rimuove l'evidenziatura)
leaveDroppable(currentDroppable);
}
currentDroppable = droppableBelow;
if (currentDroppable) {
// la logica per elaborare "il sorvolo in entrata" sul droppable
enterDroppable(currentDroppable);
}
}
}
Nel seguente esempio, quando il pallone viene trascinato sopra la porta, la porta viene evidenziata.
#gate {
cursor: pointer;
margin-bottom: 100px;
width: 83px;
height: 46px;
}
#ball {
cursor: pointer;
width: 40px;
height: 40px;
}
<!doctype html>
<html>
<head>
<meta charset="UTF-8">
<link rel="stylesheet" href="style.css">
</head>
<body>
<p>Drag the ball.</p>
<img src="https://en.js.cx/clipart/soccer-gate.svg" id="gate" class="droppable">
<img src="https://en.js.cx/clipart/ball.svg" id="ball">
<script>
let currentDroppable = null;
ball.onmousedown = function(event) {
let shiftX = event.clientX - ball.getBoundingClientRect().left;
let shiftY = event.clientY - ball.getBoundingClientRect().top;
ball.style.position = 'absolute';
ball.style.zIndex = 1000;
document.body.append(ball);
moveAt(event.pageX, event.pageY);
function moveAt(pageX, pageY) {
ball.style.left = pageX - shiftX + 'px';
ball.style.top = pageY - shiftY + 'px';
}
function onMouseMove(event) {
moveAt(event.pageX, event.pageY);
ball.hidden = true;
let elemBelow = document.elementFromPoint(event.clientX, event.clientY);
ball.hidden = false;
if (!elemBelow) return;
let droppableBelow = elemBelow.closest('.droppable');
if (currentDroppable != droppableBelow) {
if (currentDroppable) { // null se prima di questo evento non ci trovavamo sopra un elemento droppable
leaveDroppable(currentDroppable);
}
currentDroppable = droppableBelow;
if (currentDroppable) { // null se adesso non ci stiamo posizionando su un elemento droppable
// (maybe just left the droppable)
enterDroppable(currentDroppable);
}
}
}
document.addEventListener('mousemove', onMouseMove);
ball.onmouseup = function() {
document.removeEventListener('mousemove', onMouseMove);
ball.onmouseup = null;
};
};
function enterDroppable(elem) {
elem.style.background = 'pink';
}
function leaveDroppable(elem) {
elem.style.background = '';
}
ball.ondragstart = function() {
return false;
};
</script>
</body>
</html>
Adesso abbiamo l’attuale “obiettivo drop”, sul quale stiamo sorvolando, dentro la variabile currentDroppable
durante tutta l’operazione, e possiamo usarlo per evidenziarlo o per altre cose.
Riepilogo
Abbiamo preso in considerazione un algoritmo base del Drag’n’Drop.
I componenti chiave:
- Flusso degli eventi:
ball.mousedown
→document.mousemove
→ball.mouseup
(non dimenticare di eliminare ilondragstart
nativo). - All’inizio del trascinamento – ricorda lo scostamento iniziale del puntatore rispetto all’elemento:
shiftX/shiftY
e di mantenerlo durante tutta la fase di trascinamento. - Rileva gli elementi droppable sotto il puntatore tramite
document.elementFromPoint
.
Possiamo trarre molto da queste basi.
- Al
mouseup
possiamo completare intelligentemente il rilascio: modificare dati, spostare degli elementi vicini. - Possiamo evidenziare gli elementi che stiamo sorvolando.
- Possiamo limitare il trascinamento in una certa area o direzione.
- Possiamo usare la event delegation per
mousedown/up
. Un gestore evento per aree vaste che controllaevent.target
può gestire centinaia di elementi. - E così via.
Esistono frameworks che basano intere architetture su di esso: DragZone
, Droppable
, Draggable
ed altre classi. La maggior parte di essi fanno cose simili a quelle appena descritte, quindi potrebbe essere semplice comprenderle adesso. Oppure potresti preparartelo da te, dal momento che abbiamo visto quanto sia facile da fare, talvolta più semplice di adattare una soluzione di terze parti.
Commenti
<code>
, per molte righe – includile nel tag<pre>
, per più di 10 righe – utilizza una sandbox (plnkr, jsbin, codepen…)