Inercia y snap en Draggable
Cómo funciona el lanzamiento por inercia, qué plugin lo requiere, las formas de snap y liveSnap, y el carrusel arrastrable con paradas.
Un arrastre que se detiene en seco en el instante en que sueltas el dedo se siente mal, y la razón es física: en el mundo real los objetos con masa siguen moviéndose. El lanzamiento por inercia es lo que convierte un arrastre correcto en uno que se siente bien, y su combinación con el ajuste a posiciones concretas resuelve de un golpe el problema del carrusel, del selector de valores y del panel deslizante. Son dos mecanismos independientes que se combinan, y hay que entender el orden en que actúan para configurarlos.
- Activar el lanzamiento por inercia y saber qué plugin adicional requiere.
- Ajustar la sensación con
throwResistance,minDuration,maxDurationyovershootTolerance. - Distinguir
snapdeliveSnapy elegir el formato adecuado. - Construir un carrusel arrastrable con paradas.
inertia y el plugin que hace falta
inertia: true hace que al soltar, el elemento siga moviéndose y se frene progresivamente, calculando el destino a partir de la velocidad del gesto.
La condición es que InertiaPlugin esté cargado y registrado. Draggable funciona sin él, pero cualquier configuración relacionada con inercia se ignora en silencio: no hay error, simplemente el elemento se para en seco. Es el fallo número uno con este plugin y la causa es siempre la misma.
import { gsap } from "gsap";
import { Draggable } from "gsap/Draggable";
import { InertiaPlugin } from "gsap/InertiaPlugin";
gsap.registerPlugin(Draggable, InertiaPlugin);
Draggable.create(".panel", {
type: "x",
bounds: ".marco",
inertia: true,
});
Con InertiaPlugin cargado se habilitan además estas opciones, que solo tienen efecto cuando inertia está activo:
| Opción | Qué controla |
|---|---|
throwResistance |
Cuánto frena. Por defecto 1000. Más alto, se para antes |
minDuration |
Duración mínima del lanzamiento en segundos |
maxDuration |
Duración máxima |
overshootTolerance |
Cuánto puede pasarse del límite antes de volver. Con 0 no se pasa |
onThrowUpdate |
Callback en cada frame del lanzamiento |
onThrowComplete |
Callback al terminar |
snap |
Posiciones de ajuste al terminar el lanzamiento |
throwResistance es el mando principal de sensación. Un valor bajo produce un objeto que patina mucho, como un disco sobre hielo; uno alto produce algo que se para casi de inmediato. Merece la pena probarlo con valores de 200, 1000 y 5000 sobre el mismo elemento para calibrar el oído.
overshootTolerance con valores mayores que cero permite que el elemento se pase del límite y vuelva, que es el efecto de rebote de las listas nativas. Con 0 se detiene exactamente en el borde.
Draggable.create(".lista", {
type: "y",
bounds: ".ventana",
inertia: true,
throwResistance: 2500,
overshootTolerance: 0.8,
minDuration: 0.2,
maxDuration: 1.2,
});
snap y liveSnap: dos momentos distintos
La diferencia es de cuándo se aplica, y confundirlos produce sensaciones muy distintas.
snap se aplica al terminar: el gesto y el lanzamiento por inercia son libres, y al final el elemento va a la posición de ajuste más cercana al destino natural que calculó la inercia.
liveSnap se aplica durante el arrastre: el elemento no puede estar en posiciones intermedias ni siquiera mientras lo mueves, y salta de una posición válida a otra bajo el dedo.
snap es lo que quieres para un carrusel. liveSnap es lo que quieres para una rejilla de diseño o un editor donde el elemento debe encajar en una cuadrícula visible.
Ambos aceptan los mismos formatos:
// Funcion: recibe el valor natural, devuelve el ajustado
snap: (valor) => Math.round(valor / 120) * 120
// Array: se elige el mas cercano
snap: [0, -320, -640, -960]
// Objeto por propiedad
snap: { x: [0, -320, -640], y: [0] }
// Puntos con radio, para ajuste en dos ejes a la vez
liveSnap: { points: [{ x: 0, y: 0 }, { x: 200, y: 0 }], radius: 30 }
La forma de puntos con radio merece atención porque resuelve el ajuste bidimensional bien: solo ajusta si el elemento está a menos del radio indicado de alguno de los puntos, así que fuera de esas zonas el movimiento es libre. points acepta también una función que recibe el punto actual y devuelve el ajustado, lo que permite una cuadrícula infinita sin enumerar posiciones.
Y liveSnap: true reutiliza lo que hayas definido en snap.
// Rejilla de 40px durante el arrastre, sin enumerar puntos
Draggable.create(".bloque", {
bounds: ".lienzo",
liveSnap: {
points: (punto) => ({
x: Math.round(punto.x / 40) * 40,
y: Math.round(punto.y / 40) * 40,
}),
radius: 20,
},
});
El carrusel arrastrable
Es el caso donde se combina todo. Un carril con varios paneles que se arrastra horizontalmente, tiene inercia, y se ajusta a los límites de cada panel.
const carril = document.querySelector(".carril");
const paneles = gsap.utils.toArray(".panel");
const anchoPanel = paneles[0].offsetWidth;
const maximo = -(carril.scrollWidth - carril.parentElement.offsetWidth);
const [arrastrable] = Draggable.create(carril, {
type: "x",
bounds: { minX: maximo, maxX: 0 },
inertia: true,
throwResistance: 1800,
edgeResistance: 0.85,
snap: {
x: (valor) => gsap.utils.clamp(maximo, 0, Math.round(valor / anchoPanel) * anchoPanel),
},
onThrowComplete: () => {
const indice = Math.round(-arrastrable.x / anchoPanel);
document.querySelectorAll(".punto").forEach((p, i) => {
p.classList.toggle("activo", i === indice);
});
},
});
Ese código funciona pegado tal cual, con un carril de anchura mayor que su contenedor y paneles de ancho uniforme. Tres detalles: los límites se expresan como mínimos y máximos porque el carril es más ancho que su marco; el ajuste va sujeto con clamp para que no proponga posiciones fuera de rango; y onThrowComplete es donde se actualiza el indicador, no onDragEnd, porque el movimiento sigue después de soltar.
El valor de anchoPanel y el de maximo se calculan una vez, así que hay que recalcularlos al redimensionar y llamar a applyBounds(). Es exactamente el mismo problema de remedición que aparece en ScrollTrigger.
Un uso menos evidente y muy potente: en lugar de mover el elemento arrastrado, usar su posición para controlar el progreso de una timeline. Se arrastra un tirador, se lee su x en onDrag, se convierte a un valor entre 0 y 1 con gsap.utils.normalize(), y se asigna a tl.progress(). Con eso tienes un control deslizante que reproduce cualquier animación, y con inertia activado el usuario puede lanzarlo.
Errores frecuentes
La inercia no hace nada. Falta cargar y registrar InertiaPlugin. No hay error en consola.
El ajuste propone posiciones imposibles. La función de snap no está sujeta a los límites. bounds restringe el arrastre pero no filtra lo que devuelve tu función; hay que aplicar el clamp tú.
El elemento se descoloca al redimensionar. Los límites y las posiciones de ajuste se calcularon con medidas viejas. Recalcula y llama a applyBounds().
El indicador se actualiza a destiempo. Se está leyendo en onDragEnd, que se dispara al soltar, no al terminar el lanzamiento. Usa onThrowComplete.
Merece la pena fijarse en un detalle del API que dice mucho sobre el problema que resuelve. Si esto fuera una simulación física de verdad, los parámetros se llamarían masa, coeficiente de rozamiento y quizá restitución, tendrían unidades y se podrían derivar de las propiedades del objeto. En lugar de eso se llaman throwResistance, overshootTolerance, minDuration y maxDuration, no tienen unidades interpretables y se ajustan a ojo. Esa elección no es descuido: es reconocer que lo que estás modelando no es un objeto físico sino la expectativa que el usuario tiene sobre cómo debería comportarse un objeto en una pantalla, y esas dos cosas divergen de forma sistemática. Un panel de mil píxeles de ancho que se comportase como una masa real con rozamiento real necesitaría tres segundos para pararse, y tres segundos son una eternidad en una interfaz. Por eso existen minDuration y maxDuration, que son un recorte arbitrario sin equivalente físico ninguno: la simulación se trunca porque la simulación fiel resultaría molesta. Lo mismo pasa con el rebote en los límites, que en física sería una colisión elástica con una pérdida de energía determinada, y aquí es un parámetro suelto que se ajusta hasta que “se siente bien”. La lección general es que en las interfaces la física es una metáfora, no un modelo, y las metáforas se evalúan por si comunican, no por si son correctas. Cuando ajustes estos valores, no busques el número físicamente plausible: busca el que hace que el usuario entienda al instante que el objeto tiene peso, y luego recórtalo hasta que no le haga esperar. Casi siempre ese punto está bastante lejos de lo que la física diría, y bastante más cerca de lo que tu impaciencia diría.
- Monta un arrastrable con
inertia: truesin cargar InertiaPlugin y comprueba que no hay ningún aviso. - Prueba
throwResistancea 200, 1000 y 5000 sobre el mismo panel y anota cuál se parece más a una lista de móvil. - Compara
snapyliveSnapcon las mismas posiciones y describe la diferencia de sensación. - Monta el carrusel del ejemplo y quita el
clampde la función de ajuste. Arrastra hasta el extremo y observa qué propone. - Convierte un Draggable en un control deslizante que fije el
progress()de una timeline.