Gestos: pointerInput, arrastrar y soltar, y la costura con la animación
El sistema táctil de Compose desde el bucle crudo de eventos hasta los modificadores de alto nivel: pointerInput y las tres pasadas de propagación, el consumo de eventos como resolución de conflictos, los detectores listos, el arrastrar y soltar entre componentes y entre apps, y la pieza que hace que todo se sienta físico: pasar la velocidad del dedo a la animación sin que haya una sola discontinuidad.
Un gesto no es un evento, es una interpretación. El sistema entrega posiciones de dedos a sesenta o más veces por segundo, y de esa lluvia de coordenadas alguien tiene que decidir si lo que está ocurriendo es una pulsación, un arrastre, un desplazamiento de la lista que hay debajo o el comienzo de un zoom a dos dedos. Esa decisión es competitiva —varios componentes anidados quieren el mismo dedo— y es temporal, porque a menudo no puede tomarse hasta que han pasado unos milisegundos y unos píxeles. Compose expone ese proceso entero en vez de esconderlo, y por eso pointerInput parece al principio innecesariamente crudo. Lo es a propósito: es el único sitio desde el que se puede arbitrar quién se queda con el dedo, y el arbitraje es la mitad difícil del táctil.
- Leer eventos crudos con
pointerInputy entender las tres pasadas de propagación. - Usar el consumo de eventos para resolver conflictos entre componentes anidados.
- Elegir en la escalera de abstracción: detectores, modificadores de gesto y arrastrar y soltar.
- Encadenar gesto y animación conservando la velocidad, sin discontinuidades perceptibles.
El bucle crudo: pointerInput y las tres pasadas
Modifier.pointerInput recibe una o varias claves y un bloque de suspensión que se ejecuta en un ámbito con acceso a los eventos de puntero. Dentro se escribe un bucle: esperar un dedo, esperar sus movimientos, terminar. Que sea código secuencial y no una máquina de estados con devoluciones de llamada es lo que hace legible al táctil.
Modifier.pointerInput(Unit) {
awaitEachGesture {
val primero = awaitFirstDown(requireUnconsumed = true)
var arrastrando = false
while (true) {
val evento = awaitPointerEvent()
val cambio = evento.changes.first { it.id == primero.id }
if (cambio.changedToUp()) break
if (!arrastrando && (cambio.position - primero.position).getDistance() > viewConfiguration.touchSlop) {
arrastrando = true
}
if (arrastrando) {
mover(cambio.positionChange())
cambio.consume()
}
}
}
}
Dos detalles de ese bucle no son decorativos. El primero es el touchSlop: hasta que el dedo no se ha movido una distancia mínima, no se declara arrastre, porque los dedos humanos tiemblan y sin ese umbral cualquier pulsación se convertiría en un microdesplazamiento. El segundo es consume, que es el mecanismo de arbitraje entero.
Cada evento se propaga en tres pasadas. En la pasada Initial recorre el árbol de fuera hacia dentro, dando a los ancestros la primera oportunidad de reclamar el gesto —así es como un contenedor de desplazamiento puede quedarse con un movimiento vertical antes de que su hijo lo vea—. En la pasada Main, la que se usa por defecto, va de dentro hacia fuera, de modo que el hijo más profundo decide primero: es el orden natural, el que hace que un botón dentro de una lista reciba su pulsación. En la pasada Final vuelve hacia fuera informando de lo que ya se consumió, para que quien había apostado por otra interpretación se retire limpiamente.
Consumir un cambio es declarar públicamente que ese movimiento ya tiene dueño. Los detectores comprueban si el evento llega consumido y se abstienen, y por eso un carrusel horizontal dentro de una columna vertical funciona sin escribir una sola línea de coordinación: el que primero reconoce su gesto lo consume y el otro lo cede.
El bloque de pointerInput se relanza únicamente cuando cambian sus claves. Escribir pointerInput(Unit) y capturar dentro una lambda o un valor del estado congela para siempre la versión de esa lambda que existía en la primera composición, y el gesto acabará llamando a una función obsoleta o comparando contra un estado viejo. Hay dos salidas correctas y ninguna es pasar el estado como clave sin pensarlo, porque eso reinicia el gesto a mitad: envolver la lambda en rememberUpdatedState y leerla desde dentro, o pasar como clave solo aquello cuyo cambio deba de verdad cancelar y reconstruir el reconocedor.
La escalera de abstracción
Casi nunca hace falta escribir el bucle a mano. Sobre él hay dos pisos de utilidades, y la profesionalidad consiste en usar el más alto que resuelva el problema.
El primer piso son los detectores, funciones de suspensión que se llaman dentro de pointerInput y encapsulan un reconocedor completo: detectTapGestures, con sus variantes de pulsación, pulsación larga, doble pulsación y presión; detectDragGestures y sus versiones restringidas a un eje; detectTransformGestures, que entrega a la vez desplazamiento, escala y rotación a partir de dos dedos.
El segundo piso son los modificadores, que además de reconocer el gesto ya traen semántica, accesibilidad e integración con el resto del sistema. Modifier.clickable no es solo una pulsación: aporta el nodo semántico para TalkBack, el foco por teclado y la ondulación de Material. Modifier.draggable gestiona un arrastre en un eje con su estado. Modifier.scrollable participa en el desplazamiento anidado. Modifier.transformable cubre el zoom con dos dedos. Y Modifier.anchoredDraggable, sucesor del antiguo modificador de deslizamiento, resuelve el patrón de arrastrar entre posiciones ancladas —un panel inferior con tres alturas, una fila que se desliza para revelar acciones— incluyendo el cálculo de a qué ancla lanzar el elemento según la velocidad de salida.
flowchart TD A[Que necesito reconocer] --> B[Pulsacion o pulsacion larga] A --> C[Arrastre en un eje o desplazamiento] A --> D[Arrastre entre posiciones ancladas] A --> E[Zoom y rotacion a dos dedos] A --> F[Nada de lo anterior encaja] B --> B1[Modifier clickable o combinedClickable] C --> C1[Modifier draggable o scrollable] D --> D1[Modifier anchoredDraggable] E --> E1[Modifier transformable] F --> F1[pointerInput con detectores] F1 --> F2[Si tampoco basta: awaitEachGesture a mano] style B1 fill:#a6e3a1,color:#11111b style D1 fill:#89b4fa,color:#11111b style F2 fill:#f38ba8,color:#11111b
Bajar de piso tiene un coste que no siempre se contabiliza: cada escalón que desciendes es semántica y accesibilidad que dejas de recibir gratis y tienes que reponer a mano con Modifier.semantics. Un componente arrastrable escrito con pointerInput puro es invisible para TalkBack y para la navegación por teclado salvo que alguien añada explícitamente sus acciones semánticas, y ese alguien casi nunca aparece.
Arrastrar y soltar
El arrastre que mueve un elemento dentro de su propia pantalla se resuelve con lo anterior y un valor de desplazamiento. El arrastrar y soltar de verdad —coger algo y llevarlo a otro contenedor, o incluso a otra app en pantalla dividida— es otro problema, porque hay que transportar un dato y negociar con un receptor que quizá no sea tuyo.
// Origen: inicia el arrastre con una pulsacion larga y ofrece el dato
Modifier.dragAndDropSource {
detectTapGestures(onLongPress = {
startTransfer(
DragAndDropTransferData(
ClipData.newPlainText("etiqueta", texto),
),
)
})
}
// Destino: declara que acepta y reacciona a los eventos
Modifier.dragAndDropTarget(
shouldStartDragAndDrop = { evento ->
evento.mimeTypes().contains(ClipDescription.MIMETYPE_TEXT_PLAIN)
},
target = remember {
object : DragAndDropTarget {
override fun onDrop(event: DragAndDropEvent): Boolean {
soltar(event.toAndroidDragEvent().clipData.getItemAt(0).text.toString())
return true
}
override fun onEntered(event: DragAndDropEvent) { resaltado = true }
override fun onExited(event: DragAndDropEvent) { resaltado = false }
}
},
)
El dato viaja como ClipData, el mismo tipo que usa el portapapeles, y esa elección es la que permite que el destino sea otra aplicación. El destino declara por tipo MIME qué acepta, y recibe eventos de entrada, movimiento, salida y soltado con los que dar la realimentación visual imprescindible: si el usuario no ve qué contenedor aceptaría lo que lleva en el dedo, el gesto se convierte en adivinanza.
La costura: la velocidad es el puente
Queda la pieza que decide si toda la interacción se siente física o de plástico. Durante el arrastre, el valor debe seguir al dedo exactamente, sin animación de por medio: cualquier suavizado introduce retraso y el usuario percibe que la pantalla le va detrás. Por eso se usa snapTo y no animateTo mientras el dedo está abajo. Al soltar, en cambio, el movimiento debe continuar por inercia, y para ello hace falta saber a qué velocidad venía.
val desplazamiento = remember { Animatable(0f) }
val rastreador = remember { VelocityTracker() }
val alcance = rememberCoroutineScope()
val decaimiento = rememberSplineBasedDecay<Float>()
Modifier.pointerInput(Unit) {
detectHorizontalDragGestures(
onDragStart = { alcance.launch { desplazamiento.stop() } },
onHorizontalDrag = { cambio, delta ->
rastreador.addPosition(cambio.uptimeMillis, cambio.position)
alcance.launch { desplazamiento.snapTo(desplazamiento.value + delta) }
},
onDragEnd = {
val velocidad = rastreador.calculateVelocity().x
rastreador.resetTracking()
alcance.launch {
val destino = decaimiento.calculateTargetValue(desplazamiento.value, velocidad)
if (abs(destino) > anchoUmbral) {
desplazamiento.animateDecay(velocidad, decaimiento)
alDescartar()
} else {
desplazamiento.animateTo(0f, spring(), initialVelocity = velocidad)
}
}
},
)
}
Ese bloque final concentra casi todo lo que hay que saber de la costura. El rastreador acumula posiciones con su marca de tiempo y calcula al soltar una velocidad fiable, mucho mejor que dividir el último desplazamiento entre el último intervalo. calculateTargetValue responde a la pregunta decisiva antes de animar nada: si dejo esto correr con esta velocidad, dónde acabaría. Con esa predicción se decide el destino, y solo entonces se anima —con decaimiento si sale de pantalla, con resorte si vuelve—, siempre pasando la velocidad como condición inicial. Ese es el detalle que elimina la única discontinuidad que el ojo detecta sin falta: el instante en que un objeto lanzado se para para volver a arrancar.
El error conceptual más extendido al programar interacción táctil es tratar el gesto y la animación como dos etapas separadas con un traspaso entre ellas: primero el dedo manda, luego manda el framework. Esa separación es la causa directa de las interfaces que se sienten baratas, y se nota siempre en el mismo punto, el momento de soltar, porque es ahí donde el traspaso se hace visible como una parada. La forma correcta de pensarlo es que hay un único sistema dinámico —un objeto con posición y velocidad— sobre el que actúan fuerzas que cambian de origen. Mientras el dedo está en la pantalla, la fuerza es el dedo y la posición se copia directamente. Cuando el dedo se va, la fuerza pasa a ser la fricción y el resorte que tira hacia el ancla más cercana. Lo que jamás cambia entre esos dos regímenes es el estado del objeto, y en particular su velocidad. Toda la sensación de materialidad de una buena interfaz táctil se sostiene sobre esa única invariante, y todas las APIs que has visto están diseñadas para preservarla: el rastreador de velocidad existe para medirla, initialVelocity para heredarla, animateDecay para consumirla con una fricción creíble, y el mutex de mutación de Animatable para que ningún otro escritor la destruya al tomar el relevo. Hay además una consecuencia de diseño que va más allá de lo táctil. Un sistema en el que la velocidad se conserva es un sistema en el que el usuario puede intervenir en cualquier instante sin romper nada, y esa reversibilidad continua es lo que convierte a una interfaz en algo que se explora en vez de algo que se ejecuta: se puede empezar a arrastrar un panel, dudar, retroceder a medias y soltar, y el resultado es siempre razonable porque nunca hubo una transición atómica que hubiera que completar o abortar. Las interfaces modernas se sienten como materia y no como formularios exactamente por eso, y el precio de esa sensación se paga en unas pocas líneas —umbral, rastreador, velocidad inicial— que ningún atajo perdona.
- Implementa una fila que se descarte al deslizar, primero con
pointerInputydetectHorizontalDragGesturesy sin ninguna animación al soltar. Anota qué se siente mal. - Añade el rastreador de velocidad y decide el destino con
calculateTargetValueantes de animar. Compara ese criterio con uno basado solo en la distancia recorrida. - Encadena con
animateDecaypara el descarte y conanimateToy velocidad inicial para el retorno. Quita después la velocidad inicial y describe el instante exacto en que se rompe la ilusión. - Reescribe todo con
Modifier.anchoredDraggabley compara líneas, comportamiento y qué pierdes o ganas en control. - Mete tu fila dentro de una lista vertical y comprueba el arbitraje: verifica con el consumo de eventos quién se queda con el gesto en un movimiento diagonal.
- Añade acciones semánticas a tu componente gestual y navega por él con TalkBack activado.