Jetpack Compose ofrece diversas formas de manejar las interacciones táctiles, esenciales para la experiencia de usuario. Estas funcionalidades se implementan principalmente a través de modificadores, proporcionando una abstracción de alto nivel similar a la gestión de eventos en el sistema de vistas tradicional de Android. Exploraremos cómo capturar y responder a gestos comunes, así como técnicas para personalizar el comportamiento táctil.
- Gestos de Pulsación
1.1. Modifier.clickable
fun Modifier.clickable(
enabled: Boolean = true,
onClickLabel: String? = null,
role: Role? = null,
onClick: () -> Unit
)
El modificador Modifier.clickable permite detectar pulsaciones simples en un componente. Al activarse, aplica automáticamente un efecto de onda (ripple) sobre el elemento. Su uso es sencillo, requiriendo principalmente una función onClick para manejar el evento. También se puede controlar su habilitación mediante un estado Booleano.
@Composable
fun EjemploClickable() {
var habilitadoPorEstado by remember { mutableStateOf(true) }
Box(
modifier = Modifier
.size(200.dp)
.background(Color.Green)
.clickable(enabled = habilitadoPorEstado) {
// Lógica para el clic, por ejemplo, cambiar el estado de habilitado
Log.d("GestosCompose", "¡Pulsación simple detectada!")
habilitadoPorEstado = !habilitadoPorEstado // Ejemplo: deshabilitar después del primer clic
}
)
}
Es importante destacar que componentes de alto nivel como Button internamente utilizan Modifier.clickable para gestionar sus interacciones de pulsación, a menudo a través de un Surface, lo que demuestra la base de este modificador en la arquitectura de Compose.
1.2. Modifier.combinedClickable
fun Modifier.combinedClickable(
enabled: Boolean = true,
onClickLabel: String? = null,
role: Role? = null,
onLongClickLabel: String? = null,
onLongClick: (() -> Unit)? = null,
onDoubleClick: (() -> Unit)? = null,
onClick: () -> Unit
)
Para gestos de pulsación más complejos como el doble clic o la pulsación larga, Compose ofrece Modifier.combinedClickable. Este modificador extiende la funcionalidad de clickable, permitiendo configurar callbacks específicos para estos eventos, manteniendo el efecto visual de onda.
@Composable
fun EjemploCombinedClickable() {
var estaHabilitado by remember { mutableStateOf(true) }
Box(
modifier = Modifier
.size(200.dp)
.background(Color.Cyan)
.combinedClickable(
enabled = estaHabilitado,
onLongClick = {
Log.d("GestosCompose", "¡Pulsación larga detectada!")
},
onDoubleClick = {
Log.d("GestosCompose", "¡Doble pulsación detectada!")
},
onClick = {
Log.d("GestosCompose", "¡Pulsación simple detectada!")
}
)
)
}
- Gestos de Desplazamiento
Los modificadores de desplazamiento en Compose permiten que el contenido de un elemento se mueva. Es importante distinguirlos de los componentes LazyColumn o LazyRow, que están optimizados para listas de gran tamaño.
2.1. Modifier.verticalScroll / Modifier.horizontalScroll
fun Modifier.verticalScroll(
state: ScrollState,
enabled: Boolean = true,
flingBehavior: FlingBehavior? = null,
reverseScrolling: Boolean = false
)
Los modificadores Modifier.verticalScroll y Modifier.horizontalScroll habilitan el desplazamiento de cotnenido en una dirección específica. El parámetro clave es state, que gestiona la posición y el estado del desplazamiento. enabled controla si el desplazamiento está activo, flingBehavior define el comportamiento del "lanzamiento" tras un arrastre rápido, y reverseScrolling invierte la relación entre la posición visual y el valor del estado (por ejemplo, 0.dp puede ser la parte inferior en lugar de la superior).
@Composable
fun CajaDesplazableVertical() {
val estadoDesplazamiento = rememberScrollState()
Column(
modifier = Modifier
.background(Color.LightGray)
.width(IntrinsicSize.Max) // Ajusta el ancho al contenido más ancho
.height(150.dp) // Limita la altura para hacer visible el desplazamiento
.verticalScroll(estadoDesplazamiento)
) {
repeat(25) { indice ->
Text("Elemento visible número $indice")
}
}
// Para controlar programáticamente el desplazamiento, se puede usar:
LaunchedEffect(Unit) {
// estadoDesplazamiento.scrollTo(0) // Ir al inicio
// estadoDesplazamiento.animateScrollTo(estadoDesplazamiento.maxValue) // Ir al final con animación
}
}
El objeto ScrollState permite no solo observar la posición actual del desplazamiento, sino también controlarla programáticamente, por ejemplo, para llevar el contenido al inicio con state.scrollTo(0). De manera análoga, Modifier.horizontalScroll funciona para el desplazamiento en dirección horizontal.
2.2. Modifier.scrollable
A diferencia de verticalScroll y horizontalScroll, el modificador Modifier.scrollable es de nivel más bajo. Detecta gestos de desplazamiento y captura los deltas (incrementos), pero no mueve el contenido automáticamente. En su lugar, delega esta responsabilidad al desarrollador a través de un ScrollableState personalizado. Al construir este estado, se debe proporcionar una función consumeScrollDelta que se invoca con el incremento de píxeles del desplazamiento. Esta función es crucial para devolver la cantidad de desplazamiento que se ha "consumido", lo que asegura la correcta propagación de eventos en escenarios de desplazamiento anidado. Es importante recordar que scrollable no altera el layout del elemento al que se aplica; cualquier cambio visual debe implementarse manualmente en respuesta a los deltas proporcionados.
La función rememberScrollableState requiere una lambda consumeScrollDelta para gestionar el desplazamiento. Este enfoque es más flexible, ya que permite controlar cómo el desplazamiento afecta la interfaz. Los modificadores de desplazamiento específicos (verticalScroll, horizontalScroll) son de hecho implementaciones de más alto nivel que utilizan Modifier.scrollable internamente, proporcionando una gestión predeterminada del estado de desplazamiento.
@Composable
fun CajaDesplazablePersonalizada() {
var desplazamientoVerticalPx by remember { mutableFloatStateOf(0f) }
Column(
modifier = Modifier
.background(Color.LightGray)
.wrapContentWidth()
.height(150.dp) // Altura limitada para hacer el desplazamiento visible
.offset {
// Aplicar el desplazamiento calculado a la posición de la columna
IntOffset(x = 0, y = desplazamientoVerticalPx.roundToInt())
}
.scrollable(
orientation = Orientation.Vertical,
state = rememberScrollableState { deltaPx ->
// Actualizar el desplazamiento vertical con el delta
desplazamientoVerticalPx += deltaPx
deltaPx // Retornar el delta para indicar que ha sido consumido
}
)
) {
repeat(20) { indice ->
Text("Artículo con desplazamiento $indice")
}
}
}
- Gestos de Arrastre
3.1. Modifier.draggable
fun Modifier.draggable(
state: DraggableState,
orientation: Orientation,
enabled: Boolean = true,
interactionSource: MutableInteractionSource? = null,
startDragImmediately: Boolean = false,
onDragStarted: suspend CoroutineScope.(startedPosition: Offset) -> Unit = {},
onDragStopped: suspend CoroutineScope.(velocity: Float) -> Unit = {},
reverseDirection: Boolean = false
)
El modificador Modifier.draggable permite detectar arrastres en una única dirección, ya sea horizontal o vertical.
@Composable
fun CajaArrastrableHorizontal() {
var posicionX by remember { mutableFloatStateOf(0f) }
Box(
modifier = Modifier
.offset {
// El modificador .offset debe ir antes de .draggable para que el arrastre afecte la posición
IntOffset(x = posicionX.roundToInt(), y = 0)
}
.background(Color.Yellow)
.size(100.dp)
.draggable(
orientation = Orientation.Horizontal,
state = rememberDraggableState { deltaX ->
posicionX += deltaX
},
onDragStarted = { inicio ->
Log.d("GestosCompose", "Arrastre horizontal iniciado en: $inicio")
},
onDragStopped = { velocidad ->
Log.d("GestosCompose", "Arrastre horizontal detenido con velocidad: $velocidad")
}
)
)
}
Es crucial entender que Modifier.draggable por sí solo detecta el gesto de arrastre, pero no altera la posición visual del componente. Para mover el elemento, se debe actualizar un estado con el delta proporcionado por rememberDraggableState y aplicar este estado a un modificador de posición, como Modifier.offset. Es importatne que .offset se aplique antes de .draggable en la cadena de modificadores para que el arrastre afecte directamente la posición.
3.2. Modifier.draggable2D
Mientras que Modifier.draggable está limitado a una única dirección (horizontal o vertical), Modifier.draggable2D, introducido en versiones experimentales, permite el arrastre bidimensional, es decir, simultáneamente en los ejes X e Y.
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun CajaArrastrable2D() {
var posicion by remember { mutableStateOf(Offset.Zero) }
Box(
modifier = Modifier
.offset {
// Aplicar el desplazamiento bidimensional
IntOffset(x = posicion.x.roundToInt(), y = posicion.y.roundToInt())
}
.background(Color.Magenta)
.size(100.dp)
.draggable2D(
state = rememberDraggable2DState { delta ->
posicion += delta // Actualizar la posición con el delta bidimensional
},
onDragStarted = { inicio ->
Log.d("GestosCompose", "Arrastre 2D iniciado en: $inicio")
}
)
)
}
A diferencia de su contraparte unidireccional, Modifier.draggable2D no requiere el parámetro orientation. Su estado, Draggable2DState, maneja deltas de tipo Offset, lo que facilita el movimiento en cualquier dirección dentro de un plano 2D.
- Arrastre Anclado
fun <t> Modifier.anchoredDraggable(
state: AnchoredDraggableState<t>,
orientation: Orientation,
enabled: Boolean = true,
reverseDirection: Boolean = false,
interactionSource: MutableInteractionSource? = null
)
</t></t>
Introducido en Jetpack Compose 1.6.0 como reemplazo de Swipeable, Modifier.anchoredDraggable permite implementar arrastres con puntos de anclaje definidos, como el comportamiento de deslizar para descartar (swipe-to-dismiss) o paneles que se abren y cierran en posiciones fijas.
Los parámetros clave incluyen el state (una instancia de AnchoredDraggableState), la orientation (horizontal o vertical), enabled para activar/desactivar el gesto, y reverseDirection para invertir la dirección del arrastre.
El arrastre anclado consta de dos partes fundamentales: el modificador en sí y su estado asociado, AnchoredDraggableState, que dicta cómo funciona el arrastre. Además del constructor, es vital familiarizarse con los métodos updateAnchors y requireOffset.
4.1. AnchoredDraggableState
class AnchoredDraggableState<t>(
initialValue: T,
internal val positionalThreshold: (totalDistance: Float) -> Float,
internal val velocityThreshold: () -> Float,
val animationSpec: AnimationSpec<float>,
internal val confirmValueChange: (newValue: T) -> Boolean = { true }
)
</float></t>
El constructor de AnchoredDraggableState<T> requiere varios parámetros para definir el comportamiento del arrastre:
initialValue: El anclaje inicial para el contenido.positionalThreshold: Una lambda que determina si el contenido se animará al siguiente anclaje o regresará al original, basándose en la distancia arrastrada. Por ejemplo, a mitad de la distancia entre anclajes.velocityThreshold: Una lambda que devuelve una velocidad mínima, por encima de la cual el contenido se animará al siguiente anclaje, independientemente del umbral posicional.animationSpec: Especifica la animación utilizada para mover el contenido entre anclajes.confirmValueChange: Una lambda opcional que permite interceptar y potencialmente rechazar un cambio de anclaje.
Actualmente, no existe una función factory rememberAnchoredDraggableState predefinida, por lo que se debe crear la instancia de AnchoredDraggableState manualmente dentro de un remember para asegurar su persistencia entre recomposiciones.
4.2. updateAnchors
fun updateAnchors(
newAnchors: DraggableAnchors<t>,
newTarget: T = if (!offset.isNaN()) {
newAnchors.closestAnchor(offset) ?: targetValue
} else targetValue
)
</t>
El método updateAnchors se utiliza para definir los puntos de anclaje a los que el contenido puede "engancharse" durante el arrastre. Es necesario especificar al menos dos anclajes para que el arrastre sea funcional, pero se pueden añadir tantos como sean necesarios. Se utiliza un DraggableAnchors para mapear valores de un tipo genérico T a las posiciones de desplazamiento reales.
4.3. requireOffset
La función requireOffset simplemente retorna el desplazamiento actual del contenido. Al igual que con draggable, el modificador anchoredDraggable calcula el desplazamiento pero no lo aplica visualmente. Es responsabilidad del desarrollador usar este offset, típicamente con Modifier.offset, para mover el componente.
4.4. Ejemplo de Uso
enum class AnclajesArrastre {
Inicio, Fin
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun EjemploArrastreAnclado() {
val densidad = LocalDensity.current
val estadoArrastrable = remember {
AnchoredDraggableState(
initialValue = AnclajesArrastre.Inicio,
positionalThreshold = { distanciaTotal -> distanciaTotal * 0.5f },
velocityThreshold = { with(densidad) { 100.dp.toPx() } },
animationSpec = tween(),
confirmValueChange = { true }
).apply {
updateAnchors(
DraggableAnchors {
AnclajesArrastre.Inicio at 0f
AnclajesArrastre.Fin at with(densidad) { 200.dp.toPx() } // Un valor más razonable para el ejemplo
}
)
}
}
Box(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
// Un simple Box para simular el contenido arrastrable
Box(
modifier = Modifier
.offset {
// Aplicar el desplazamiento calculado por AnchoredDraggableState
IntOffset(x = 0, y = estadoArrastrable.requireOffset().roundToInt())
}
.size(100.dp)
.background(Color.Blue, CircleShape)
.anchoredDraggable(
state = estadoArrastrable,
orientation = Orientation.Vertical // Arrastre vertical
),
contentAlignment = Alignment.Center
) {
Text(
text = "Arrastra",
color = Color.White,
fontWeight = FontWeight.Bold
)
}
}
}
Al igual que con Modifier.draggable, el modificador .offset debe aplicarse antes de .anchoredDraggable para que el movimiento sea visible.
- Gestos de Transformación
5.1. Modifier.transformable
@ExperimentalFoundationApi
fun Modifier.transformable(
state: TransformableState,
canPan: (Offset) -> Boolean,
lockRotationOnZoomPan: Boolean = false,
enabled: Boolean = true
)
El modificador Modifier.transformable es ideal para manejar gestos multi-táctiles como pellizcar para hacer zoom, rotar con dos dedos o arrastrar bidimensionalmente. Proporciona los deltas de estos gestos para que el desarrollador aplique transformaciones visuales al componente.
Sus parámetros principales son:
state: UnTransformableStatecreado conrememberTransformableState, que recibe los cambios de zoom, paneo y rotación.canPan: Una lambda que puede decidir si se permite el paneo basado en elOffset.lockRotationOnZoomPan: Cuando estrue, la rotación se ignorará si ya se está realizando un zoom o paneo.enabled: Habilita o deshabilita el modificador.
@OptIn(ExperimentalFoundationApi::class) // Requerido para transformable en versiones experimentales
@Composable
fun CajaTransformable() {
var desplazamiento by remember { mutableStateOf(Offset.Zero) }
var anguloRotacion by remember { mutableStateOf(0f) }
var factorEscala by remember { mutableStateOf(1f) }
// El orden de los modificadores es crucial aquí.
// Las transformaciones (rotate, scale, offset) deben ir antes del background y de transformable.
Box(
modifier = Modifier
.size(150.dp)
.graphicsLayer {
// graphicsLayer es más eficiente para transformaciones visuales
translationX = desplazamiento.x
translationY = desplazamiento.y
rotationZ = anguloRotacion
scaleX = factorEscala
scaleY = factorEscala
}
.background(Color.LightGray)
.transformable(
state = rememberTransformableState { zoomDelta, panDelta, rotationDelta ->
factorEscala *= zoomDelta
desplazamiento += panDelta
anguloRotacion += rotationDelta
},
lockRotationOnZoomPan = false // Permite rotación, zoom y paneo simultáneos
)
.border(2.dp, Color.DarkGray, RoundedCornerShape(8.dp)),
contentAlignment = Alignment.Center
) {
Text("¡Transforma!", fontSize = 20.sp, color = Color.Black)
}
}
El orden de los modificadores es vital. Modificadores como .rotate, .scale y .offset deben aplicarse antes de .transformable y .background. Un error en el orden puede llevar a comportamientos inesperados, como la rotación afectando el eje de arrastre. Para transformaciones complejas, Modifier.graphicsLayer es una alternativa más eficiente.
- Detección de Gestos Personalizada
Cuando los modificadores de gesto de alto nivel no ofrecen suficiente flexibilidad, Modifier.pointerInput es la herramienta para una detección de gestos más granular y personalizada. Este modificador proporciona acceso a APIs de bajo nivel, las mismas que utilizan internamente los gestos de alto nivel.
6.1. Modifier.pointerInput
fun Modifier.pointerInput(
vararg keys: Any?,
block: suspend PointerInputScope.() -> Unit
)
La función Modifier.pointerInput toma dos argumentos principales:
keys: Un conjunto de claves (similar a las claves derememberoLaunchedEffect). Si estas claves cambian durante una recomposición, el bloque de gestión de gestos se reinicia, deteniendo cualquier proceso de gesto en curso.block: Un bloque de código suspendido (suspend PointerInputScope.() -> Unit) donde se define la lógica de detección de gestos. La naturaleza suspendida indica que la detección de gestos en Compose se realiza en corrutinas.
6.1.1. APIs Básicas para Gestos de Pulsación
Dentro del ámbito de PointerInputScope, se dispone de funciones suspendidas para detectar gestos de pulsación, como detectTapGestures. A diferencia de Modifier.clickable, estas APIs no aplican automáticamente efectos visuales como el ripple, permitiendo una personalización completa.
suspend fun PointerInputScope.detectTapGestures(
onDoubleTap: ((Offset) -> Unit)? = null,
onLongPress: ((Offset) -> Unit)? = null,
onPress: suspend PressGestureScope.(Offset) -> Unit = NoPressGesture,
onTap: ((Offset) -> Unit)? = null
)
onDoubleTap: Callback opcional para doble pulsación.onLongPress: Callback opcional para pulsación larga.onPress: Callback opcional para el momento inicial de la pulsación.onTap: Callback opcional para pulsación simple.
Es importante comprender el orden de ejecución de estos callbacks: onPress se activa inmediatamente al tocar la pantalla. Un doble toque resultará en dos onPress seguidos de onDoubleTap. Una pulsación mantenida activará onPress y, si se supera un umbral de tiempo (típicamente 400ms), onLongPress. Una pulsación rápida y liberación (dentro de 100ms) activará onPress seguido de onTap.
@Composable
fun EjemploDetectTapGestures() {
Box(
modifier = Modifier
.background(Color.Orange)
.size(120.dp)
.pointerInput(Unit) {
detectTapGestures(
onDoubleTap = { offset ->
Log.d("GestosCompose", "Doble toque en: $offset")
},
onLongPress = { offset ->
Log.d("GestosCompose", "Pulsación larga en: $offset")
},
onPress = { offset ->
Log.d("GestosCompose", "Presionado en: $offset")
},
onTap = { offset ->
Log.d("GestosCompose", "Toque simple en: $offset")
}
)
}
)
}
6.1.2. APIs Básicas para Gestos de Arrastre
Para la detección de gestos de arrastre, PointerInputScope ofrece un conjunto de APIs específicas:
detectDragGestures: Detecta arrastres generales.detectDragGesturesAfterLongPress: Inicia la detección de arrastre solo después de una pulsación larga.detectHorizontalDragGestures: Detecta arrastres exclusivamente horizontales.detectVerticalDragGestures: Detecta arrastres exclusivamente verticales.
@Composable
fun EjemploDetectDragGestures() {
var desplazamientoCuadro by remember { mutableStateOf(Offset.Zero) }
Box(
modifier = Modifier
.offset { IntOffset(desplazamientoCuadro.x.roundToInt(), desplazamientoCuadro.y.roundToInt()) }
.background(Color.Green)
.size(100.dp)
.pointerInput(Unit) {
detectDragGestures(
onDragStart = { startOffset ->
Log.d("GestosCompose", "Arrastre iniciado en: $startOffset")
},
onDragEnd = {
Log.d("GestosCompose", "Arrastre finalizado.")
},
onDragCancel = {
Log.d("GestosCompose", "Arrastre cancelado.")
},
onDrag = { change, dragAmount ->
change.consume() // Consumir el evento para que no se propague a otros handlers
desplazamientoCuadro += dragAmount
Log.d("GestosCompose", "Arrastrando, delta: $dragAmount, posición: $desplazamientoCuadro")
}
)
}
)
}
El callback onDragCancel se activa comúnmente en situaciones de conflicto de desplazamiento, por ejemplo, cuando un gesto de arrastre inicialmente detectado por un componente hijo es luego "interceptado" y consumido por un padre. Es importante notar que si bien detectDragGesturesAfterLongPress requiere una pulsación larga para iniciar el arrastre, no proporciona un callback para la pulsación larga en sí. Para detectarla, se debe combinar con detectTapGestures.
Un aspecto crucial de estas APIs de detección es que son "top-level" (de nivel superior) dentro de un bloque pointerInput. Esto significa que si se colocan múltiples detectores en un solo bloque pointerInput, solo el primero se ejecutará, ya que los detectores son funciones suspendidas que bloquean la corrutina. Para manejar múltiples tipos de gestos en el mismo componente, se debe utilizar un Modifier.pointerInput separado para cada detector:
@Composable
fun MultiplesDetectores() {
var mensajeLog by remember { mutableStateOf("Esperando gesto...") }
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(mensajeLog, modifier = Modifier.padding(bottom = 8.dp))
Box(
Modifier
.size(150.dp)
.background(Color.Red)
// Primer detector: solo detectará toques
.pointerInput(Unit) {
detectTapGestures { mensajeLog = "¡Toque detectado!" }
// El siguiente detector no sería alcanzado si estuviera aquí
}
// Segundo detector: detectará arrastres
.pointerInput(Unit) {
detectDragGestures { _, _ -> mensajeLog = "¡Arrastrando!" }
}
)
}
}
6.1.3. APIs Básicas para Gestos de Transformación
Para gestos de transformación a nivel bajo, se utiliza detectTransformGestures. A diferencia de Modifier.transformable, este API permite detectar arrastres de un solo dedo, además de los gestos multi-táctiles de zoom y rotación.
suspend fun PointerInputScope.detectTransformGestures(
panZoomLock: Boolean = false,
onGesture: (centroid: Offset, pan: Offset, zoom: Float, rotation: Float) -> Unit
)
panZoomLock: Booleano para bloquear la rotación si hay paneo o zoom.onGesture: Callback para recibir los deltas de paneo (Offset), zoom (Float) y rotación (Float).
@Composable
fun EjemploDetectTransformGestures() {
var desplazamiento by remember { mutableStateOf(Offset.Zero) }
var anguloRotacion by remember { mutableStateOf(0f) }
var factorEscala by remember { mutableStateOf(1f) }
Box(
modifier = Modifier
.graphicsLayer {
translationX = desplazamiento.x
translationY = desplazamiento.y
rotationZ = anguloRotacion
scaleX = factorEscala
scaleY = factorEscala
}
.background(Color.Blue)
.size(200.dp)
.pointerInput(Unit) {
detectTransformGestures(
panZoomLock = false, // Permite rotación, zoom y paneo simultáneos
onGesture = { centroid, pan, zoom, rotation ->
// El `centroid` es el punto medio entre los dedos
factorEscala *= zoom
desplazamiento += pan
anguloRotacion += rotation
}
)
},
contentAlignment = Alignment.Center
) {
Text("¡Multi-toque!", fontSize = 24.sp, color = Color.White)
}
}
6.2. awaitPointerEventScope
Las APIs detect...Gestures son abstracciones de más alto nivel. La verdadera esencia de la detección de gestos en Compose reside en el uso de corrutinas y la función awaitPointerEventScope. Esta función proporciona un ámbito AwaitPointerEventScope donde se pueden utilizar las APIs de gestos suspendidas de más bajo nivel. Una vez que todos los eventos de gesto dentro de este ámbito son procesados, awaitPointerEventScope se reanuda, devolviendo el resultado de la última expresión en la lambda.
suspend fun <r> awaitPointerEventScope(
block: suspend AwaitPointerEventScope.() -> R
): R
</r>
Dentro de AwaitPointerEventScope, se encuentran métodos como:
awaitPointerEvent: Espera el próximo evento de puntero (presionar, mover, soltar).awaitFirstDown: Espera la primera pulsación.drag,horizontalDrag,verticalDrag: Funciones para detectar y gestionar arrastres.
6.2.1. Eventos Crudos: awaitPointerEvent
La función awaitPointerEvent es la piedra angular de la detección de gestos de bajo nivel en Compose, análoga al método onTouchEvent() en el sistema de vistas tradicional. Suspende la corrutina hasta que se produce el siguiente evento de puntero (presionar, mover, soltar), retornando toda la información de interacción de los dedos en la pantalla.
@Composable
fun EjemploEventosCrudos() {
var logEventos by remember { mutableStateOf("Esperando eventos...") }
Box(
modifier = Modifier
.background(Color.Gray)
.size(150.dp)
.pointerInput(Unit) {
awaitPointerEventScope {
while (true) { // Loop infinito para procesar eventos continuamente
val evento = awaitPointerEvent() // Espera el siguiente evento de puntero
val primerCambio = evento.changes.firstOrNull() // Obtiene el primer cambio si existe
logEventos = if (primerCambio != null) {
"Evento: ${evento.type} | Pos: (${primerCambio.position.x}, ${primerCambio.position.y})"
} else {
"Evento: ${evento.type} (sin cambios de puntero)"
}
Log.d("GestosCompose", logEventos)
}
}
},
contentAlignment = Alignment.Center
) {
Text(logEventos, color = Color.White, fontSize = 12.sp)
}
}
Aunque awaitPointerEvent es extremadamente potente para el control de bajo nivel, su complejidad lo hace menos práctico para la mayoría de los casos de uso. Generalmente, se prefieren las APIs de detección de gestos de más alto nivel (detectTapGestures, etc.) por su simplicidad y eficacia.
6.3. awaitEachGesture
El manejo de gestos en Compose se realiza en corrutinas. Una corrutina que detecta gestos termina después de procesar una secuencia completa de eventos. Si se inicia un nuevo gesto, pero la corrutina anterior ya ha finalizado, los nuevos eventos se perderán. Para garantizar una gestión continua de los gestos, se utiliza awaitEachGesture. Esta función envuelve el bloque de detección en un bucle gestionado, asegurando que el sistema esté siempre listo para una nueva secuencia de gestos. De hecho, muchas de las APIs detect...Gestures utilizan awaitEachGesture internamente.
suspend fun PointerInputScope.awaitEachGesture(block: suspend AwaitPointerEventScope.() -> Unit) {
// ... implementación interna que asegura la resiliencia del detector
}
@Composable
fun EjemploAwaitEachGesture() {
var estadoPuntero by remember { mutableStateOf("Toca aquí") }
Box(
modifier = Modifier
.background(Color.Magenta)
.size(150.dp)
.pointerInput(Unit) {
awaitEachGesture {
// Esperar la primera pulsación de un dedo
val eventoAbajo = awaitFirstDown()
eventoAbajo.consume() // Consumir el evento para que no se propague
estadoPuntero = "Dedo ABAJO en: ${eventoAbajo.position}"
Log.d("GestosCompose", estadoPuntero)
// Esperar que el dedo se levante o que el gesto sea cancelado
val eventoArriba = waitForUpOrCancellation()
if (eventoArriba != null) {
eventoArriba.consume() // Consumir el evento
estadoPuntero = "Dedo ARRIBA en: ${eventoArriba.position}"
Log.d("GestosCompose", estadoPuntero)
} else {
estadoPuntero = "Gesto CANCELADO"
Log.d("GestosCompose", estadoPuntero)
}
}
},
contentAlignment = Alignment.Center
) {
Text(estadoPuntero, color = Color.White, fontSize = 14.sp)
}
}
6.3. Eventos Multi-puntero
Cuando se utilizan múltiples dedos, awaitPointerEvent retorna un PointerEvent que contiene una lista de objetos PointerInputChange (val changes: List<PointerInputChange>). Cada PointerInputChange encapsula la información específica de un puntero individual (dedo o stylus).
@Immutable
class PointerInputChange(
val id: PointerId,
val uptimeMillis: Long,
val position: Offset,
val pressed: Boolean,
val pressure: Float,
val previousUptimeMillis: Long,
val previousPosition: Offset,
val previousPressed: Boolean,
isInitiallyConsumed: Boolean,
val type: PointerType = PointerType.Touch,
val scrollDelta: Offset = Offset.Zero
)
Un PointerInputChange ofrece detalles como id (identificador del puntero), uptimeMillis (tiempo del evento), position (posición actual), pressed (estado de pulsación), previousPosition, entre otros. Esto permite rastrear cada puntero de forma independiente, como se mostró al acceder a event.changes[0].position en los ejemplos anteriores.
6.4. Despacho y Propagación de Eventos
6.4.1. Despacho de Eventos
No todos los modificadores pointerInput reciben todos los eventos de puntero. El mecanismo de despacho de eventos sigue una lógica específica:
- Prueba de Impacto (Hit-Test): Cuando un nuevo puntero toca la pantalla, el sistema realiza una prueba de impacto desde la parte superior de la jerarquía UI hacia abajo. Se identifican todos los componentes "calificados" (con capacidades de manejo de puntero) cuyas fronteras son impactadas por el puntero. Esto genera una "cadena de componentes impactados".
- Prioridad Z-Index: Por defecto, si múltiples componentes calificados se superponen en el mismo nivel, solo el de mayor índice Z (el que se dibuja más arriba) se considera "impactado".
- Eventos Posteriores: Una vez establecida la cadena de componentes impactados para un puntero, los eventos subsiguientes de ese mismo puntero se envía a toda la cadena, independientemente de si el puntero se mueve fuera de las fronteras de algunos de ellos. Los componentes fuera de esta cadena inicial no recibirán eventos para ese puntero.
@Composable
fun EjemploDespachoEventos() {
Column(modifier = Modifier.fillMaxSize()) {
Text("Toca en los cuadros y observa el logcat para ver el orden de los eventos.", modifier = Modifier.padding(16.dp))
Box(
modifier = Modifier
.background(Color.LightGray)
.size(300.dp)
.pointerInput(Unit) {
awaitEachGesture {
while (true) {
val evento = awaitPointerEvent()
Log.d("GestosCompose", "CAJA EXTERNA -> Evento: ${evento.type}")
}
}
}
) {
Box(
modifier = Modifier
.align(Alignment.Center) // Centrar la caja interna
.background(Color.Yellow)
.size(200.dp)
.pointerInput(Unit) {
awaitEachGesture {
while (true) {
val evento = awaitPointerEvent()
Log.d("GestosCompose", "CAJA INTERNA -> Evento: ${evento.type}")
}
}
}
)
}
}
}
Al tocar dentro de la "Caja Interna", ambos modificadores pointerInput reciben los eventos. Sin embargo, la "Caja Interna" (que se dibuja encima de la "Caja Externa") recibe el evento primero debido a su mayor Z-index, como se reflejará en el log.
6.4.2. Consumo de Eventos
La "consumición" de eventos es fundamental para evitar conflictos entre múltiples manejadores de gestos. Cuando un componente maneja un evento, debe "consumirlo" para indicar a los componentes padre que el evento ya ha sido procesado. Esto evita que los padres reaccionen a un gesto que ya ha sido gestionado por un hijo. Los modificadores de gestos de alto nivel de Compose gestionan la consumición automáticamente, pero al crear gestos personalizados con pointerInput, es necesario consumir los eventos manualmente mediante PointerInputChange.consume().
Consumir un evento no detiene su propagación a otros componentes; simplemente marca el evento como "manejado". Los componentes deben verificar explícitamente si un evento ya ha sido consumido (PointerInputChange.isConsumed) y adaptar su lógica en consecuencia.
@Composable
fun EjemploConsumoEventos() {
Column(modifier = Modifier.fillMaxSize()) {
Text("Toca el cuadro amarillo. Solo la caja interna debe responder directamente.", modifier = Modifier.padding(16.dp))
Box(
modifier = Modifier
.background(Color.LightGray)
.size(300.dp)
.pointerInput(Unit) {
awaitEachGesture {
while (true) {
val evento = awaitPointerEvent()
if (evento.changes.any { it.isConsumed }) {
Log.d("GestosCompose", "CAJA EXTERNA -> Evento ya CONSUMIDO por un hijo.")
} else {
Log.d("GestosCompose", "CAJA EXTERNA -> Evento NO CONSUMIDO: ${evento.type}")
}
}
}
}
) {
Box(
modifier = Modifier
.align(Alignment.Center)
.background(Color.Yellow)
.size(200.dp)
.pointerInput(Unit) {
awaitEachGesture {
while (true) {
val evento = awaitPointerEvent()
Log.d("GestosCompose", "CAJA INTERNA -> Evento procesado: ${evento.type}")
// Consumir todos los cambios de puntero para evitar que el padre lo maneje
evento.changes.forEach { it.consume() }
}
}
}
)
}
}
}
En este ejemplo, cuando se toca la "Caja Interna", esta consume los eventos. La "Caja Externa" sigue recibiendo los eventos, pero al detectar que ya fueron consumidos por un hijo (it.isConsumed), puede optar por no procesarlos o ejecutar una lógica alternativa. Esto demuestra que consumir un evento controla su procesamiento, no su propagación.
@Composable
fun EjemploConsumoAPIMayorNivel() {
Column(modifier = Modifier.fillMaxSize()) {
Text("Toca el cuadro amarillo. Solo la caja interna debe registrar el onTap.", modifier = Modifier.padding(16.dp))
Box(
modifier = Modifier
.background(Color.LightGray)
.size(300.dp)
.pointerInput(Unit) {
detectTapGestures(
onTap = {
Log.d("GestosCompose", "CAJA EXTERNA -> onTap")
}
)
}
) {
Box(
modifier = Modifier
.align(Alignment.Center)
.background(Color.Yellow)
.size(200.dp)
.pointerInput(Unit) {
detectTapGestures(
onTap = {
Log.d("GestosCompose", "CAJA INTERNA -> onTap")
}
)
}
)
}
}
}
En este caso, al usar detectTapGestures, solo la "Caja Interna" registra el evento. Esto se debe a que las APIs de gestos de alto nivel de Compose ya implementan lógicas de consumición y prioridad, asegurando que solo el componente de mayor Z-index que se ha "impactado" maneje el gesto.
6.4.3. Propagación de Eventos
Los eventos de puntero no solo se consumen, sino que también se propagan a través de la jerarquía de componentes. Este proceso ocurre en tres fases distintas:
- Initial (Inicial): Los eventos fluyen desde el nodo raíz de la UI hacia los nodos hoja. Esta fase permite a los componentes padre interceptar y procesar eventos antes que sus hijos.
- Main (Principal): Los eventos fluyen desde los nodos hoja hacia el nodo raíz. Esta es la fase por defecto y la más común para la detección de gestos, donde los componentes hijos tienen prioridad para manejar el evento.
- Final (Final): Los eventos vuelven a fluir desde el nodo raíz hacia los nodos hoja. Esta fase permite a los componentes reaccionar a la consumición de eventos por parte de sus padres o hermanos, por ejemplo, para revertir una animación de "presionar" si el gesto se convierte en un arrastre de un padre.
suspend fun awaitPointerEvent(
pass: PointerEventPass = PointerEventPass.Main
)
La función awaitPointerEvent tiene un parámetro pass, que por defecto es PointerEventPass.Main. El orden de despacho entre estas fases es siempre: Initial -> Main -> Final.
@Composable
fun EjemploPropagacionEventos() {
Column(modifier = Modifier.fillMaxSize()) {
Text("Toca el cuadro rojo (el más interno) y observa el orden en el logcat.", modifier = Modifier.padding(16.dp))
Box(
modifier = Modifier
.background(Color.LightGray)
.size(300.dp)
.pointerInput(Unit) {
awaitEachGesture {
while (true) {
val evento = awaitPointerEvent(PointerEventPass.Main)
Log.d("GestosCompose", "CAJA 1 (Main) -> ${evento.type}")
}
}
}
) {
Box(
modifier = Modifier
.align(Alignment.Center)
.background(Color.Yellow)
.size(250.dp)
.pointerInput(Unit) {
awaitEachGesture {
while (true) {
val evento = awaitPointerEvent(PointerEventPass.Initial)
Log.d("GestosCompose", "CAJA 2 (Initial) -> ${evento.type}")
}
}
}
) {
Box(
modifier = Modifier
.align(Alignment.Center)
.background(Color.Blue)
.size(200.dp)
.pointerInput(Unit) {
awaitEachGesture {
while (true) {
val evento = awaitPointerEvent(PointerEventPass.Final)
Log.d("GestosCompose", "CAJA 3 (Final) -> ${evento.type}")
}
}
}
) {
Box(
modifier = Modifier
.align(Alignment.Center)
.background(Color.Red)
.size(150.dp)
.pointerInput(Unit) {
awaitEachGesture {
while (true) {
val evento = awaitPointerEvent() // Por defecto es Main
Log.d("GestosCompose", "CAJA 4 (Default Main) -> ${evento.type}")
}
}
}
)
}
}
}
}
}
Al observar el logcat tras tocar la "Caja 4" (roja):
- Fase Initial: Los eventos fluyen de arriba abajo. La "Caja 2" (amarilla) detecta eventos en esta fase, por lo que su log aparece primero.
- Fase Main: Los eventos fluyen de abajo arriba. La "Caja 4" (roja) y la "Caja 1" (gris claro) están configuradas para escuchar en esta fase (la Caja 4 por defecto). Como la Caja 4 es una hoja y la Caja 1 es un padre, la Caja 4 registra el evento antes que la Caja 1.
- Fase Final: Los eventos vuelven a fluir de arriba abajo. Solo la "Caja 3" (azul) está configurada para escuchar en esta fase, por lo que su log aparece al final.
Esta secuenciación permite una gestión sofisticada de conflictos y reacciones entre componentes anidados.
- Desplazamiento Anidado (Modifier.NestedScroll)
El NestedScroll (desplazamiento anidado) es un tema más avanzado y complejo, pero Jetpack Compose proporciona Modifier.NestedScroll para su implementación. Dada su complejidad, será abordado en un artículo dedicado.