Cómo implementar objetos arrastrables
(redblobgames.com)- Los diagramas interactivos de Red Blob Games necesitan que, cuando el lector mueva directamente un objeto, la pantalla reaccione de inmediato, por lo que organizó el manejo de entrada en torno a Pointer Events para cubrir tanto mouse como touch
- El método anterior con
mousedown/mouseup/mousemovepodía hacer que el estado de arrastre se desincronizara si el puntero salía del objeto o si se soltaba el botón fuera de él, así que hacía falta complementarlo con manejadores globales y similares - La entrada táctil transmite eventos de forma distinta al mouse, y arrastrar con un dedo también provoca el desplazamiento de la página, por lo que se llama a
preventDefault()entouchstartpara reducir conflictos - La receta final combina
pointerdown,pointermove,pointerup,pointercancelysetPointerCapture(), y además comprueba el botón izquierdo y el desplazamiento inicial para evitar que el objeto salte de golpe - Los casos especiales según la plataforma —como objetos con texto o imágenes, multitouch, arrastres anidados,
<canvas>, la combinación de hover y drag, olostpointercapture— requieren manejo adicional en una UI real
Objetivo del manejador de entrada para arrastre
- Muchas páginas interactivas tienen objetos arrastrables que el lector puede mover directamente, y el diagrama debe reaccionar a los cambios en la posición del objeto
- El objetivo es crear un manejador de eventos de entrada que funcione tanto con mouse como con entrada táctil
- El flujo básico consiste en iniciar el arrastre en
pointerdown, actualizar la posición enpointermovey terminar el arrastre enpointerupo en un evento de cancelación - En una implementación real, el comportamiento se vuelve más complejo que este modelo simple debido a los botones del mouse, los toques con varios dedos, el destino de entrega de eventos y el menú contextual del clic derecho
- La receta básica combina los siguientes elementos
- En
pointerdown, sievent.button !== 0, retornar para procesar solo el botón principal - Convertir las coordenadas del evento a coordenadas del diagrama
- Guardar
dxydy, que son la diferencia entre la posición del objeto y la posición del puntero al inicio - Capturar el puntero con
el.setPointerCapture(event.pointerId) - En
pointermove, actualizarstate.possumando el desplazamiento guardado - En
pointerupypointercancel, establecerstate.dragging = null - Llamar a
preventDefault()entouchstart
- En
- Esta receta es un manejador de eventos de entrada; el procesamiento real de salida debe conectarse a un manejador de estado aparte
- El código completo está en la página de ejemplos, e incluye también ejemplos de arrastre horizontal de números, pintura en canvas y arrastre restringido
- El alcance de las pruebas incluye Mac, Windows, Linux, Android, iPhone y iPad en Gecko/Firefox, Blink/Chrome y WebKit/Safari
- No se probaron stylus con hover, dispositivos híbridos touch+mouse ni entrada por voz
- Este método no es la API de Drag and Drop de HTML para soltar elementos sobre otros elementos; en diagramas hay que leer directamente los eventos de mouse y touch
Limitaciones de usar solo eventos de mouse
- La implementación inicial consistía en adjuntar
mousedown,mouseupymousemovedirectamente al elemento arrastrable - A simple vista funcionaba, pero aparecían dos problemas
- Si el puntero se movía rápido, dejaba de estar sobre el círculo y ya no recibía eventos
- Si se soltaba el botón fuera del círculo, podía quedarse atrapado en el estado
dragging
- La mejora fue recibir
mousedownen el círculo, y durante el arrastre adjuntar temporalmentemousemoveymouseupadocument - Si en
mouseupse eliminan los manejadoresmousemoveymouseupañadidos adocument, el arrastre con mouse funciona de forma más estable - Este método solo maneja mouse y no trata eventos táctiles
Diferencias de los eventos táctiles
- La entrada táctil usa
touchstart,touchendytouchmoveen lugar demousedown,mouseupymousemove - Después de
touchstart, los eventos táctiles se capturan automáticamente, y todos lostouchmovese entregan al elemento original - Por esta característica, no hace falta adjuntar temporalmente manejadores globales a
documentcomo sí ocurría con los eventos de mouse - Cuando el navegador necesita cancelar una secuencia táctil, envía
touchcancel - La implementación de arrastre táctil llama a
preventDefault()entouchstarty actualiza la posición entouchmoveusando las coordenadas deevent.changedTouches[0] - Este método solo maneja touch y no trata eventos de mouse
Unificar mouse y touch con Pointer Events
- Para manejar tanto mouse como touch antes hacían falta muchos manejadores de eventos, y así se trabajó hasta antes de 2021
- Entre 2011 y 2014, en el proyecto d3 se usó d3-drag, y en proyectos que no usaban d3 se creaba código aparte para mouse+touch
- Pointer Events unifica y simplifica el manejo de mouse y touch
- MS IE agregó soporte en 2012
- Chrome agregó soporte en 2017, Firefox en 2018 y Safari en 2020
- El comportamiento de los navegadores fue cambiando con el tiempo, y en 2017 Chrome puso algunos eventos en modo passive por defecto, lo que hacía que la página se desplazara al intentar arrastrar un objeto
- Safari y Firefox también aplicaron el mismo cambio en 2018
- Usar pointer capture permite resolverlo con lógica más simple, sin tener que agregar y quitar manejadores globales en
documentcomo ocurría con los eventos de mouse
Mantener el seguimiento del puntero con Pointer Capture
- Pointer capture permite seguir rastreando el puntero aunque salga del círculo, del diagrama o de la ventana del navegador
- Con eventos de mouse, para lograr el mismo efecto había que adjuntar manejadores a
document, pero con Pointer EventssetPointerCapture(event.pointerId)es más simple - Sin captura del puntero, el arrastre puede detenerse si se arrastra rápido o si el puntero sale del diagrama y luego vuelve
- Si se suelta el botón fuera del diagrama o se cambia a otra ventana con Alt+Tab, el manejo del fin del arrastre también cambia según haya o no captura
- En dispositivos táctiles, la captura del puntero es el comportamiento predeterminado, pero con mouse hay que establecerla explícitamente
Desplazamiento táctil y preventDefault()
- En dispositivos táctiles, arrastrar con un dedo provoca por defecto el desplazamiento de la página
- Si ese mismo arrastre con un dedo también mueve el círculo, entonces el scroll y el arrastre del objeto ocurren al mismo tiempo
- La solución más simple es aplicar
touch-action: noneal diagrama con CSS, pero en ese caso ya no se puede hacer scroll en ninguna parte del diagrama - El comportamiento deseado es bloquear el scroll solo al arrastrar el círculo, y permitirlo al arrastrar el propio diagrama
- Los resultados comparativos fueron los siguientes
touch-action: noneen todo el diagrama: tanto el arrastre del círculo como el del diagrama detienen el scroll- Valor predeterminado: ninguno de los dos impide el scroll
touch-action: nonesolo en el círculo: no funciona como se esperabapreventDefault()entouchstart: bloquea solo el scroll durante el arrastre del círculo y permite el scroll del diagrama
preventDefault()se aplica entouchstart, no enpointerstart- Este método funciona en la mayoría de los casos, pero no funcionó en el “mobile emulation mode” de Firefox de escritorio, mientras que sí funcionó en Firefox móvil sobre Android
Guardar el desplazamiento inicial del arrastre
- Si al arrastrar sujetando el borde del objeto, el centro del objeto salta instantáneamente a la posición del puntero, la experiencia de uso empeora
- La solución es guardar al inicio del arrastre la posición relativa entre el centro del objeto y la posición del puntero
- Luego, al moverlo, se calcula la posición del objeto sumando
dxydyguardados a las coordenadas actuales del puntero - Este enfoque cambia
state.draggingde un simpletrue/falsea información de posición relativa como{dx, dy} - Con mouse, la diferencia del salto se nota bien al sujetar desde el borde; con touch, puede notarse menos porque el dedo lo tapa
- Este tipo de pequeños detalles también están resumidos en la página little details
Menú contextual y botones del mouse
- La forma de activar el menú contextual varía según la plataforma
- Windows: clic derecho down+up, Shift+F10
- Linux: botón derecho down, Shift+F10
- Mac: botón derecho down, Ctrl+clic izquierdo
- iOS: long press solo sobre texto
- Android: long press en cualquier lugar
- El problema es que
pointerdownsí se ve, peropointeruppuede no verse siempre - El botón en realidad puede haberse soltado, pero el código todavía puede considerarlo como presionado
- La forma de manejarlo es ignorar el botón derecho y establecer el estado
draggingsolo con el botón izquierdo del mouse - En la propiedad
buttonde la especificación Pointer Events,button = 0significa el botón primario, y con eso se excluyen los botones central y derecho - En Chrome y Safari de Mac, Ctrl+clic puede entrar como un
pointerdownde botón izquierdo, así que también hay que revisarevent.ctrlKey - Las opciones son las siguientes
- En
contextmenuse puede bloquear el menú conpreventDefault(), pero puede entrar en conflicto con el comportamiento predeterminado de clic central o arrastre - Si el arrastre se limita al botón izquierdo, se pueden evitar la mayoría de las diferencias del clic derecho
- Si también se quiere evitar el Ctrl+clic de Mac, hay que excluir
ctrlKey
- En
- Aunque los cambios de botón no se transmitan por
pointerdownopointerup, se puede verificar una parte con el estado de botones depointermove
Objetos que incluyen texto e imágenes
- Si dentro de un elemento arrastrable hay texto o imágenes, se necesita procesamiento adicional
- Mientras se arrastra el círculo, el texto interno puede quedar seleccionado
- La solución es aplicar CSS
user-select: none- Si se aplica siempre, el texto interno nunca podrá seleccionarse
- Si se aplica solo durante el arrastre, normalmente sí será posible seleccionar el texto
- A inicios de 2023, Safari todavía no soportaba
user-selectsin prefijo, por lo que también hay que configurarwebkitUserSelect - Windows, Linux y Mac soportan drag and drop entre aplicaciones para texto e imágenes, y eso entra en conflicto con el arrastre de objetos
- Si se llama a
preventDefault()endragstart, se puede dar prioridad al arrastre del objeto por encima del arrastre de texto o imágenes seleccionadas - El comportamiento varía un poco según el navegador y el sistema operativo
Múltiples punteros y arrastre simultáneo
- Poder arrastrar varios objetos al mismo tiempo con varios dedos o varios mouse es un caso límite
- En touch, el código presentado ya funciona para arrastrar varios objetos al mismo tiempo
- Si se arrastra el mismo objeto con dos dedos, hace falta un manejo adicional
- La solución es guardar
event.pointerIdenstate.draggingo en el estado durantepointerdown, e ignorarlo enpointermovesi no es el mismopointerId - Este ejemplo de implementación no está aplicado por completo en el texto principal, pero se puede revisar en canvas dragging test
- En el caso del mouse, la especificación Pointer Events indica que los sistemas operativos y agentes de usuario comunes normalmente no tienen el concepto de múltiples entradas de mouse
- Aunque haya un trackpad y un mouse externo al mismo tiempo, por lo general se tratan como un solo puntero de mouse, así que se considera que no hay forma de arrastrar distintos objetos con mouse diferentes
Al presionar varios botones del mouse a la vez
- Si se presionan varios botones del mouse al mismo tiempo, el comportamiento de Pointer Events y Mouse Events cambia
- Mouse Events envía
mousedownpor cada presión de botón ymouseuppor cada liberación de botón - Según la especificación Pointer Events,
pointerdownocurre con el primer botón presionado ypointerupcon el último botón liberado - Por eso,
pointerdownypointeruppueden ocurrir con estados de botones diferentes - Por ejemplo, si se presiona el botón izquierdo, luego el derecho, y después se suelta el izquierdo, el objeto puede quedar en estado de arrastre
- La solución es comprobar en
pointermoveel estado del botón izquierdo conevent.buttons & 1, y si ya no está presionado, llamar aend(event) - La captura del puntero puede continuar hasta que se suelten todos los botones, y puede seguir activa a menos que se libere explícitamente la captura
- Esta implementación no resuelve por completo este problema ni todos los casos límite
Elementos de arrastre anidados
- Si dentro de un elemento arrastrable hay otro elemento arrastrable, tanto el elemento interno como el externo procesarán el arrastre
- La solución es llamar a
event.stopPropagation()en el elemento arrastrable interno para evitar que el evento suba al elemento arrastrable externo - Se usa el enfoque de detener la propagación tanto en
pointerdowncomo enpointermove - La demo no está en el texto principal, pero en el ejemplo de Vue playground se puede ver el caso donde el elemento arrastrable rojo es hijo del elemento arrastrable amarillo
Arrastre en Canvas
- Normalmente se usa SVG, pero en
<canvas>no se pueden configurar manejadores de eventos ni la forma del puntero del mouse en los propios elementos arrastrables - En 2D Canvas o WebGL, hay que adjuntar los manejadores de eventos a
<canvas>y determinar manualmente si las coordenadas están sobre un objeto arrastrable - La forma de procesarlo es la siguiente
pointerdown,touchstart,dragstart: si no está sobre un objeto arrastrable, regresar de inmediatopointermove: configurar el cursor según si está sobre un objeto arrastrable
- Hay una demo en la examples page
Combinación de hover con mouse y drag con toque
- Algunos diagramas reaccionan con hover cuando se usa mouse sin presionar botones, y en dispositivos táctiles, como no existe hover, deben reaccionar con drag
- La receta básica asume drag tanto para mouse como para toque
- Para usar juntos el hover con mouse y el drag con toque, se elimina la condición
if (!state.dragging)enpointermove - En la Responsive Design page, al hacer hover sobre una fila con el mouse cambia el diseño, y en toque el diseño cambia con drag
- En la Hexagons Guide, muchos diagramas también usan en conjunto hover con mouse y drag con toque
- Si al arrastrar en toque desde el elemento A hacia B se mantiene la captura, el evento move se envía a A, así que para resaltar B puede ser necesario liberar la captura
Extensión de Toggle Paint y del estado de drag
- En Rounded Cell Painter,
pointerdowncaptura el color inicial de pintura - Después, todos los
pointermovehastapointerupusan ese mismo color de pintura - Para esto,
state.draggingdebe incluir no solo lasx,yiniciales, sino también el color inicial de pintura - Hay una demo en la examples page
Caso límite de lostpointercapture
lostpointercapturepuede usarse para detectar si se perdió la captura del puntero- Todavía no está del todo claro en qué situaciones ocurre y qué se debe hacer
- Los escenarios tratados en los comentarios son los siguientes
- Iniciar el arrastre de un círculo con el botón izquierdo
- Moverse fuera del área de arrastre mientras se mantiene presionado el botón izquierdo
- Presionar el botón derecho y luego soltar el izquierdo
- Hacer clic en cualquier lugar fuera del área de arrastre
- El standard dice que, después de que se libera la captura del puntero, el agente de usuario debe disparar obligatoriamente un evento
lostpointercapture - El comportamiento real de los navegadores varía bastante: en algunos sistemas sí ocurre
lostpointercapture, pero en otros no ocurre hasta que se hace clic en otro lugar
1 comentarios
Opiniones de Hacker News
Es un artículo sobre el drag, y me topé con casi todas las trampas que menciona Amit y llegué a las mismas soluciones. Excelente artículo.
Desde el punto de vista del usuario, reordenar arrastrando parece algo muy natural, pero cuando uno se mete con elementos de distintos tamaños, crear placeholders entre elementos, detectar bordes y la madriguera de conejo de los algoritmos de ajuste de cajas, implementarlo por cuenta propia entra en la categoría de bastante difícil.
Cada vez que se mueve el mouse, se elige de esas posiciones la más cercana a la posición actual del objeto arrastrado y se renderiza el layout seleccionado. Funciona con buena sensación incluso con layouts complejos o reflows.
Al final, cuando levantaba una tarjeta, calculaba el layout como si esa tarjeta se hubiera eliminado del tablero, y eso lo volvía fácil. Solo había que empujar lo que estuviera en la posición sobre la que estaba el mouse.
Aun así hubo muchísimos casos especiales, y fue particularmente complicado cuando se empezaba a editar una tarjeta nueva que todavía no había sido “confirmada” en el modelo de datos. También tuve que agregar una opción para empujar las tarjetas existentes y crear espacio para una tarjeta fantasma.
Ayuda aceptar que hay muchos casos especiales que hay que manejar manualmente. Si lo encaras como si fuera a existir una solución más general y homogénea, te la pasás dando vueltas en el diseño. Algún día debería escribir un post de blog recopilando los distintos casos especiales.
También encontré un mejor enfoque de layout, que reduce cálculos innecesarios y hace que el motor de layout sea más flexible y más amigable para el usuario. Es sorprendente la enorme diferencia que hace la elección de cómo representar el modelo de datos en la solución.
[1] https://www.ussherpress.com/cardbuddy/
El artículo no parece tratar el manejo de cancelación. Por ejemplo, en Windows existe la convención de cancelar el drag al presionar Escape.
A veces también quieres cancelar el drag si se suelta el mouse fuera de un área definida. La cancelación le da al usuario una forma más rápida de deshacer, o una salida del tipo “ah, en realidad no quería arrastrar eso”.
Por eso, aunque no haya una función separada de deshacer, hay que guardar el estado original al inicio del drag y restaurarlo si se cancela.
En el caso de cancelar al arrastrar fuera del área, también existe la descancelación. Cuando el puntero está fuera del área, visualmente se muestra como si hubiera vuelto al estado original, y cuando el puntero vuelve a entrar al área, se reanuda el drag. La cancelación real ocurre al soltar el mouse, pero durante el drag se le adelanta visualmente al usuario que se cancelará.
No puedo creer que Microsoft todavía piense que eso es buen diseño de UI. Confunde por completo al usuario; incluso se ve gente que, sin entender qué pasa, tiene miedo de soltar el botón izquierdo del mouse o repite la acción como un yoyó.
Estoy de acuerdo en que la cancelación es un gran problema que suele pasarse por alto y que no es simple. El usuario promedio ni siquiera sabe que ESC puede ayudar.
Tras 20 años arrastrando todo tipo de objetos en la GUI de Ardour [0], aprendí un detalle adicional: todos los eventos de presionar y soltar botones deben tratarse como eventos de drag sin movimiento.
Es decir, presionar siempre inicia un drag y soltarlo lo termina; el código que maneja el soltar trata como caso especial la condición de que no hubo movimiento.
[0] https://ardour.org/
Tengo manejadores normales de
mousedownymouseup, y uso un timeout configurable de unos 150 ms para decidir si se trata de un clic, del inicio de un drag u otra acción. Si ocurremouseup, o si el mouse sale de la pantalla y hay un drag en curso, hagostopdrag. Para mí, el drag es solo uno de varios casos especiales.Hace muchísimo tiempo había en Minneapolis un geometry center financiado por la NSF. Se celebró una conferencia de teoría computacional de grupos y habían invitado a algunas personas de áreas cercanas, una especie de mascotas.
Me invitaron porque había escrito un sistema de geometría algebraica, y me levanté muy temprano con un frío de -20 °F para conseguir una estación de trabajo Silicon Graphics para usar ese día y programé un juego para entender mejor los generadores y relaciones de grupos.
Ese juego incluía drag. El resultado fueron buenas conversaciones que siguieron hasta las 2 de la mañana. Mi idea era que el drag no tenía por qué seguir las leyes físicas del mundo real; pensaba que el drag debía sentirse como una dosis muy potente de LSD. Todos se engancharon con eso y aportaron sus propias ideas.
El artículo implementa un arrastre relativamente básico, dejando de lado varias excepciones que aparecen en los navegadores web. Me pregunto si hay material sobre arrastre con restricciones como hacer snap a guías, evitar colisiones con límites u otros objetos, o destinos de soltado animados que cambian de tamaño o posición en respuesta a la operación de arrastre.
Hace tiempo quería crear una UI de temporizador Pomodoro personalizado que dividiera un reloj circular en segmentos en forma de cuña de distintas longitudes para definir intervalos de concentración/descanso. No avancé mucho con la implementación del reordenamiento por arrastre de esos segmentos.
Quería separar el sistema de restricciones del sistema de manejo de eventos. Bibliotecas como jquery-ui terminan acoplando ambos, de modo que el sistema de manejo de eventos tiene que conocer todas las restricciones posibles. jquery-ui admite caja delimitadora, ejes, cuadrícula cuadrada y snap a elementos del DOM. Pero si quieres hacer snap a una cuadrícula hexagonal, una cuadrícula de escala logarítmica o un límite circular, no está soportado.
En el código se puede ver
state.pos = …; ahí es donde se establece el estado. Para las restricciones, ocultaríaposdetrás de un setter. Así, aunque el código de manejo de eventos de arrastre no sepa qué restricciones hacen falta, el setter puede aplicarlas.Debería actualizar la página para mostrar ejemplos de restricciones. Olvidé por completo mencionar este aspecto en la receta de código.
Un ejemplo antiguo de restricciones está en https://www.redblobgames.com/articles/curved-paths/making-of... y un ejemplo de prevención de colisiones está en https://redblobgames.github.io/circular-obstacle-pathfinding.... Pero todavía no he probado el reordenamiento por arrastre ni los destinos de soltado animados.
Yo también le agradezco a este sitio. Cuando diseñábamos en Uber un sistema hexagonal para análisis geográfico, consulté mucho https://www.redblobgames.com/grids/hexagons/.
Es un artículo que resume bien las trampas y precauciones que aparecen al tratar interacciones bien hechas.
Para algo más “listo para usar”, he usado interactjs durante bastante tiempo en varios proyectos.
Gracias a todos. Me sorprendió que llegara hoy a HN, y puedo responder preguntas.
draggable="true"y los eventos de arrastre.También me intriga qué le falta a la funcionalidad integrada en comparación con este código.
https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement...
https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement...
https://developer.mozilla.org/en-US/docs/Web/HTML/Global_att...
Tengo una pregunta rápida. Me pregunto cómo se puede restringir el movimiento de arrastre a un solo eje usando eventos DOM integrados como
dragstart. Implementé funcionalidad de arrastrar y soltar con los eventosdragstart/dragenter/dragover/drop, pero no encontré una forma rápida de restringir el movimiento al eje x. Recuerdo que la API de arrastrar y soltar de JQuery sí lo soportaba. Estoy intentando usar solo eventos/API nativos del DOM, así que agradecería cualquier información o referencia al respecto.Considerando que Safari agregó soporte en 2020, me pregunto si no te preocupa que algunos navegadores no los soporten. Los usuarios de macOS Mojave probablemente usen Safari 13 o 14, así que espero que Safari 12 ya no se use en la práctica, pero en una app de producción dirigida a usuarios web generales sería bastante malo que no funcionara algo tan simple como arrastrar.
Agregar manejadores de eventos a
documentdurante el arrastre es una práctica antigua, y los navegadores siguen agregando funciones nuevas para simplificar ciertos casos de uso, pero cada una trae sus propias excepciones y trampas. El artículo también dice que todavía no cubre todo por completo. En el resultado final queda una combinación de eventos de puntero y eventos táctiles.Me pregunto si la “simplicidad” en el sentido de reducir código vale el costo de asumir excepciones adicionales, menor soporte de navegadores y la necesidad de que los desarrolladores conozcan detalles y diferencias entre navegadores de los eventos de puntero, que probablemente se entienden y documentan menos que los eventos de mouse.
Falta algo: accesibilidad. ¿Cómo habría que hacer posible arrastrar con el teclado?
En mi ejemplo, la respuesta de accesibilidad es ofrecer además controles de teclado para completar esa tarea. Si es posible, mejor que no requieran mantener una tecla presionada. Por ejemplo, se puede entrar en modo de desplazamiento con un atajo y mover el viewport alrededor del lienzo con las flechas.
En el caso de objetos arrastrables, la tarea es reordenar. Para hacerlo accesible, basta con ofrecer un medio alternativo para cambiar el orden. Por ejemplo, recorrer el foco entre los objetos con TAB, seleccionar el objeto enfocado con una tecla y luego mover la vista previa de arrastre con las flechas a la posición válida más cercana en esa dirección.
Es decir, no hay que forzar el soporte de teclado dentro de la implementación de arrastre, sino agregar una funcionalidad de teclado significativa por separado de la función de arrastre.
Muy bueno. A principios de esta semana estaba implementando arrastre de elementos SVG en una app Vue, y tuve que descubrir por mi cuenta casi todo lo que describe el autor, prácticamente en el mismo orden; al final terminé con un componente casi igual.
Aunque yo lo escribí como una utilidad composable llamada
useDragging, en vez de como un componente funcional.