Draggable: tipos, límites y el disparador
Los siete tipos de arrastre, las cinco formas de declarar los límites, el elemento que inicia el gesto, y el manejo correcto de los elementos pulsables.
Hacer arrastrable un elemento es de esas tareas que parecen media hora y consumen dos días. La parte fácil es mover el elemento con el ratón; la difícil es todo lo demás: que funcione igual con el dedo, que no dispare la selección de texto, que no rompa el scroll de la página en móvil, que un botón dentro del elemento arrastrable siga siendo pulsable, que respete unos límites, y que el elemento vuelva dentro si lo sueltas fuera. Draggable resuelve esa lista completa, y su superficie de configuración es un mapa bastante exacto de todos los problemas que tiene el arrastre en la web.
- Elegir el tipo de arrastre correcto entre los siete disponibles.
- Declarar límites en cualquiera de sus cinco formas.
- Usar
triggerpara separar la zona de agarre del elemento que se mueve. - Manejar los elementos pulsables dentro de una zona arrastrable.
Los siete tipos
type determina qué propiedad se modifica al arrastrar. Los valores documentados son "x,y", "left,top", "rotation", "x", "y", "top" y "left". El valor por defecto es "x,y".
import { gsap } from "gsap";
import { Draggable } from "gsap/Draggable";
gsap.registerPlugin(Draggable);
Draggable.create(".ficha", { type: "x,y", bounds: ".tablero" });
La diferencia entre "x,y" y "left,top" importa. El primero usa transformaciones, que resuelve el compositor y no provocan recálculo de layout. El segundo modifica las propiedades de posición, lo que sí lo provoca en cada movimiento, y además exige que el elemento tenga position: relative o absolute. La regla es usar "x,y" salvo que tengas una razón concreta, como necesitar que el elemento afecte a un layout de posicionamiento absoluto.
"rotation" convierte el arrastre en un giro alrededor del origen de transformación del elemento, lo que da mandos giratorios y diales con una línea.
Draggable.create(".dial", {
type: "rotation",
bounds: { minRotation: 0, maxRotation: 270 },
onDrag: function () {
document.querySelector(".valor").textContent = Math.round(this.rotation);
},
});
Fíjate en que el callback es una función tradicional y no una flecha: los callbacks de Draggable están enlazados a la instancia, así que this te da acceso a x, y, rotation, maxX y el resto sin guardar referencias.
Los cinco formatos de bounds
Los límites aceptan cinco formas distintas, y cada una encaja con un caso.
// 1) Un elemento: el arrastrable no sale de el
bounds: document.querySelector(".tablero")
// 2) Texto de selector, lo mismo
bounds: "#tablero"
// 3) Un rectangulo en coordenadas del padre
bounds: { top: 100, left: 0, width: 1000, height: 800 }
// 4) Minimos y maximos por eje
bounds: { minX: 10, maxX: 300, minY: 50, maxY: 500 }
// 5) Para type rotation
bounds: { minRotation: 0, maxRotation: 270 }
Las tres primeras describen una zona; las dos últimas describen rangos por propiedad. La documentación advierte de que el elemento que usas como límite no debe estar rotado de forma distinta al arrastrable, porque los cálculos asumen ejes alineados.
Los límites no son una pared dura por defecto. edgeResistance es un número entre 0 y 1 que controla cuánto se puede pasar el elemento del límite mientras lo arrastras: con 0 no hay resistencia y el elemento pasa como si no hubiera límite, con 1 es una pared absoluta, y valores intermedios producen el efecto elástico familiar de las listas de móvil.
dragResistance, también entre 0 y 1, aplica una resistencia constante durante todo el arrastre, no solo en los bordes. Sirve para que un elemento se sienta pesado.
Draggable.create(".panel", {
type: "y",
bounds: ".contenedor",
edgeResistance: 0.65, // se pasa un poco y tira hacia dentro
dragResistance: 0.15, // se siente ligeramente pesado
});
Los límites se pueden cambiar en caliente con applyBounds(), que es lo que hace falta al redimensionar la ventana.
const [arrastrable] = Draggable.create(".ficha", { bounds: ".tablero" });
window.addEventListener("resize", () => arrastrable.applyBounds(".tablero"));
Fíjate en el destructurado: Draggable.create() devuelve siempre un array, aunque el selector encuentre un solo elemento.
trigger: separar el agarre del movimiento
Un caso muy común: una ventana flotante que solo se arrastra por su barra de título. trigger indica el elemento que inicia el gesto, mientras que el que se mueve sigue siendo el objetivo.
<div class="ventana">
<header class="barra">Ajustes</header>
<div class="contenido">
<input type="range">
<button>Aplicar</button>
</div>
</div>
Draggable.create(".ventana", {
trigger: ".ventana .barra",
bounds: window,
cursor: "grab",
activeCursor: "grabbing",
});
cursor y activeCursor son la pareja que hace que el gesto se lea correctamente: la mano abierta al pasar por encima y la cerrada mientras se arrastra. Es un detalle de un segundo que cambia mucho la percepción.
Los elementos pulsables
Aquí hay un comportamiento por defecto que conviene conocer porque salva o arruina el resultado.
Draggable ignora por defecto los elementos a, input, select, button y textarea, además de cualquier elemento con onclick o con el atributo data-clickable="true". Al pulsar sobre uno de ellos, el arrastre no comienza y el elemento funciona normalmente.
Eso es lo que quieres casi siempre, y lo controlan dos opciones. dragClickables invierte el comportamiento: con true, esos elementos también inician el arrastre. Y clickableTest te deja escribir tu propia función que recibe el elemento y devuelve si debe considerarse pulsable.
Draggable.create(".tarjeta", {
bounds: ".tablero",
// Todo lo que tenga la clase "control" se comporta como pulsable
clickableTest: (elemento) => elemento.closest(".control") !== null,
});
Y existe onClick, que se dispara cuando ha habido pulsación y liberación sin que el puntero se moviera lo suficiente como para contar como arrastre. minimumMovement fija ese umbral, que por defecto es de 2 píxeles.
allowNativeTouchScrolling, activado por defecto, permite que un Draggable de un solo eje deje pasar el scroll nativo en el eje contrario. Con un arrastre horizontal, el usuario puede seguir desplazando la página verticalmente con el dedo sobre el elemento. Si lo desactivas, el elemento se traga todos los gestos y el usuario queda atrapado: llega a la zona arrastrable y no puede seguir bajando. Es uno de los fallos de usabilidad más frecuentes de los carruseles táctiles y no se detecta nunca probando con el ratón.
Propiedades y métodos que vas a usar
La instancia expone su estado y su control. Lo esencial:
| Miembro | Qué es |
|---|---|
x, y, rotation |
Posición actual según el tipo |
pointerX, pointerY |
Posición del puntero, ya normalizada entre navegadores |
startX, startY |
Posición al empezar el gesto |
deltaX, deltaY |
Desplazamiento del último movimiento |
minX, maxX, minY, maxY |
Límites resueltos en números |
isPressed, isDragging, isThrowing |
Estado del gesto |
disable(), enable(), kill() |
Ciclo de vida |
applyBounds(), update() |
Recalcular tras cambios de layout |
getDirection() |
Devuelve la dirección como cadena |
Y dos estáticos que resuelven casos concretos: Draggable.get(elemento) recupera la instancia asociada a un elemento, y Draggable.hitTest(a, b, umbral) comprueba si dos elementos se solapan, con un umbral que puede ser píxeles o un porcentaje de solapamiento.
Draggable.create(".pieza", {
bounds: ".tablero",
onDrag: function () {
const encima = Draggable.hitTest(this.target, ".casilla-destino", "50%");
document.querySelector(".casilla-destino").classList.toggle("resaltada", encima);
},
onDragEnd: function () {
if (Draggable.hitTest(this.target, ".casilla-destino", "50%")) colocar(this.target);
},
});
Hay una forma productiva de leer la documentación de este plugin, y es tratarla como un catálogo de bugs de arrastre encontrados en veinte años de producción. Cada opción existe porque alguien se estrelló contra un caso concreto. dragClickables existe porque un botón dentro de una tarjeta arrastrable deja de funcionar. allowNativeTouchScrolling existe porque un carrusel horizontal atrapa el dedo del usuario y le impide bajar por la página. allowContextMenu existe porque el menú contextual del clic derecho y el arrastre compiten por el mismo gesto. minimumMovement existe porque un temblor de la mano convierte un clic en un arrastre de dos píxeles y el clic no se dispara. zIndexBoost existe porque el elemento que arrastras se cuela por debajo de sus hermanos. force3D existe porque promover un elemento a su propia capa acelera el arrastre y arruina la nitidez del texto de sus hijos si además los animas. onPressInit existe porque hay cosas que hay que hacer antes de que se tomen las medidas iniciales, y onPress ya es demasiado tarde. Ninguna de esas opciones se le habría ocurrido a nadie diseñando la API en una pizarra; todas son cicatrices. La conclusión práctica de leerlo así es doble. Primero, que si estás escribiendo tu propio arrastre desde cero porque “solo necesito mover un div”, vas a redescubrir esa lista entera, en orden, a lo largo de varias semanas, y cada descubrimiento llegará por un informe de error de un usuario con un dispositivo que tú no tienes. Y segundo, que cuando uses el plugin merece la pena leerse las opciones aunque no las necesites hoy, porque cada nombre es la etiqueta de un problema que tarde o temprano vas a tener y que así sabrás reconocer en dos minutos en lugar de en dos tardes.
- Monta un arrastrable con
"x,y"y otro con"left,top"y compara ambos en el panel de rendimiento moviéndolos rápido. - Añade límites con las cinco formas y comprueba cuál te resulta más cómoda para un tablero.
- Prueba
edgeResistancea 0, 0,65 y 1, y decide cuál se siente mejor. - Mete un botón dentro de una tarjeta arrastrable y comprueba que sigue funcionando. Después activa
dragClickablesy observa que deja de hacerlo. - Monta un carrusel horizontal arrastrable, pruébalo en un móvil real y verifica que puedes seguir haciendo scroll vertical con el dedo encima.