Comprende los gestos

Hay varios términos y conceptos que es importante comprender cuando se trabaja en el control de gestos en una aplicación. En esta página, se explican los términos punteros, eventos de puntero y gestos, y se presentan los diferentes niveles de abstracción para los gestos. También profundiza en el consumo y la propagación de eventos.

Definiciones

Para comprender los diversos conceptos de esta página, debes entender parte de la terminología que se usa:

  • Puntero: Es un objeto físico que puedes usar para interactuar con tu aplicación. En los dispositivos móviles, el puntero más común es el dedo que interactúa con la pantalla táctil. Como alternativa, puedes usar una pluma stylus en lugar del dedo. En las pantallas grandes, puedes usar un mouse o un panel táctil para interactuar de forma indirecta con la pantalla. Un dispositivo de entrada debe poder "apuntar" a una coordenada para que se lo considere un puntero, por lo que un teclado, por ejemplo, no puede considerarse un puntero. En Compose, el tipo de puntero se incluye en los cambios de puntero con PointerType.
  • Evento de puntero: Describe una interacción de bajo nivel de uno o más punteros con la aplicación en un momento determinado. Cualquier interacción con el puntero, como colocar un dedo en la pantalla o arrastrar un mouse, activaría un evento. En Compose, toda la información relevante para un evento de este tipo se encuentra en la clase PointerEvent.
  • Gesto: Es una secuencia de eventos de puntero que se puede interpretar como una sola acción. Por ejemplo, un gesto de presión se puede considerar una secuencia de un evento de presión seguido de un evento de liberación. Existen gestos comunes que usan muchas apps, como presionar, arrastrar o transformar, pero también puedes crear tu propio gesto personalizado cuando sea necesario.

Diferentes niveles de abstracción

Jetpack Compose proporciona diferentes niveles de abstracción para controlar los gestos. En el nivel superior, se encuentra la compatibilidad con componentes. Los elementos componibles, como Button, incluyen automáticamente compatibilidad con gestos. Para agregar compatibilidad con gestos a los componentes personalizados, puedes agregar modificadores de gestos, como clickable, a funciones de componibilidad arbitrarias. Por último, si necesitas un gesto personalizado, puedes usar el modificador pointerInput.

Como regla general, compila en el nivel más alto de abstracción que ofrezca la funcionalidad que necesitas. De esta manera, te beneficias de las prácticas recomendadas incluidas en la capa. Por ejemplo, Button contiene más información semántica, que se usa para la accesibilidad, que clickable, que contiene más información que una implementación de pointerInput sin procesar.

Compatibilidad de componentes

Muchos componentes listos para usar en Compose incluyen algún tipo de control de gestos interno. Por ejemplo, un LazyColumn responde a los gestos de arrastre desplazando su contenido, un Button muestra una onda cuando lo presionas y el componente SwipeToDismiss contiene lógica de deslizamiento para descartar un elemento. Este tipo de control de gestos funciona automáticamente.

Además del control interno de gestos, muchos componentes también requieren que el emisor controle el gesto. Por ejemplo, un Button detecta automáticamente las presiones y activa un evento de clic. Pasas una expresión lambda onClick a Button para reaccionar al gesto. Del mismo modo, agregas una expresión lambda onValueChange a un Slider para reaccionar cuando el usuario arrastra el controlador del control deslizante.

Cuando se ajuste a tu caso de uso, prefiere los gestos incluidos en los componentes, ya que incluyen compatibilidad lista para usar con el enfoque y la accesibilidad, y están bien probados. Por ejemplo, un Button se marca de una manera especial para que los servicios de accesibilidad lo describan correctamente como un botón, en lugar de cualquier elemento en el que se pueda hacer clic:

// Talkback: "Click me!, Button, double tap to activate"
Button(onClick = { /* TODO */ }) { Text("Click me!") }
// Talkback: "Click me!, double tap to activate"
Box(Modifier.clickable { /* TODO */ }) { Text("Click me!") }

Para obtener más información sobre la accesibilidad en Compose, consulta Accesibilidad en Compose.

Agrega gestos específicos a elementos componibles arbitrarios con modificadores

Puedes aplicar modificadores de gestos a cualquier elemento componible arbitrario para que escuche los gestos. Por ejemplo, puedes permitir que un Box genérico controle los gestos de toque si lo conviertes en clickable o que un Column controle el desplazamiento vertical si aplicas verticalScroll.

Existen muchos modificadores para controlar diferentes tipos de gestos:

Como regla, prefiere los modificadores de gestos listos para usar en lugar del control de gestos personalizado. Los modificadores agregan más funcionalidad además del control de eventos de puntero puro. Por ejemplo, el modificador clickable no solo agrega la detección de presiones y presiones breves, sino que también agrega información semántica, indicaciones visuales sobre las interacciones, el desplazamiento, el enfoque y la compatibilidad con el teclado. Puedes consultar el código fuente de clickable para ver cómo se agrega la funcionalidad.

Agrega un gesto personalizado a elementos componibles arbitrarios con el modificador pointerInput

No todos los gestos se implementan con un modificador de gestos listo para usar. Por ejemplo, no puedes usar un modificador para reaccionar a un arrastre después de mantener presionado, un clic con la tecla Control o un toque con tres dedos. En su lugar, puedes escribir tu propio controlador de gestos para identificar estos gestos personalizados. Puedes crear un controlador de gestos con el modificador pointerInput, que te brinda acceso a los eventos de puntero sin procesar.

El siguiente código escucha los eventos de puntero sin procesar:

@Composable
private fun LogPointerEvents(filter: PointerEventType? = null) {
    var log by remember { mutableStateOf("") }
    Column {
        Text(log)
        Box(
            Modifier
                .size(100.dp)
                .background(Color.Red)
                .pointerInput(filter) {
                    awaitPointerEventScope {
                        while (true) {
                            val event = awaitPointerEvent()
                            // handle pointer event
                            if (filter == null || event.type == filter) {
                                log = "${event.type}, ${event.changes.first().position}"
                            }
                        }
                    }
                }
        )
    }
}

Si desglosas este fragmento, los componentes principales son los siguientes:

  • El modificador pointerInput Le pasas una o más claves. Cuando cambia el valor de una de esas claves, se vuelve a ejecutar la lambda de contenido del modificador. En el ejemplo, se pasa un filtro opcional a la función de componibilidad. Si el valor de ese filtro cambia, se debe volver a ejecutar el controlador de eventos de puntero para garantizar que se registren los eventos correctos.
  • awaitPointerEventScope crea un permiso de corrutina que se puede usar para esperar eventos de puntero.
  • awaitPointerEvent suspende la corrutina hasta que se produce un siguiente evento del puntero.

Si bien escuchar eventos de entrada sin procesar es una opción potente, también es complejo escribir un gesto personalizado basado en estos datos sin procesar. Para simplificar la creación de gestos personalizados, hay muchos métodos de utilidad disponibles.

Cómo detectar gestos completos

En lugar de controlar los eventos de puntero sin procesar, puedes escuchar gestos específicos que se produzcan y responder de forma adecuada. AwaitPointerEventScope proporciona métodos para escuchar lo siguiente:

Estos son detectores de nivel superior, por lo que no puedes agregar varios detectores dentro de un modificador pointerInput. En el siguiente fragmento, solo se detectan las presiones, no los arrastres:

var log by remember { mutableStateOf("") }
Column {
    Text(log)
    Box(
        Modifier
            .size(100.dp)
            .background(Color.Red)
            .pointerInput(Unit) {
                detectTapGestures { log = "Tap!" }
                // Never reached
                detectDragGestures { _, _ -> log = "Dragging" }
            }
    )
}

De forma interna, el método detectTapGestures bloquea la corrutina y nunca se alcanza el segundo detector. Si necesitas agregar más de un objeto de escucha de gestos a un elemento componible, usa instancias de modificador pointerInput independientes:

var log by remember { mutableStateOf("") }
Column {
    Text(log)
    Box(
        Modifier
            .size(100.dp)
            .background(Color.Red)
            .pointerInput(Unit) {
                detectTapGestures { log = "Tap!" }
            }
            .pointerInput(Unit) {
                // These drag events will correctly be triggered
                detectDragGestures { _, _ -> log = "Dragging" }
            }
    )
}

Cómo controlar eventos por gesto

Por definición, los gestos comienzan con un evento de puntero hacia abajo. Puedes usar el método auxiliar awaitEachGesture en lugar del bucle while(true) que pasa por cada evento sin procesar. El método awaitEachGesture reinicia el bloque contenedor cuando se levantan todos los punteros, lo que indica que se completó el gesto:

@Composable
private fun SimpleClickable(onClick: () -> Unit) {
    Box(
        Modifier
            .size(100.dp)
            .pointerInput(onClick) {
                awaitEachGesture {
                    awaitFirstDown().also { it.consume() }
                    val up = waitForUpOrCancellation()
                    if (up != null) {
                        up.consume()
                        onClick()
                    }
                }
            }
    )
}

En la práctica, casi siempre querrás usar awaitEachGesture, a menos que respondas a eventos de puntero sin identificar gestos. Un ejemplo de esto es hoverable, que no responde a los eventos de puntero hacia abajo o hacia arriba, solo necesita saber cuándo un puntero entra o sale de sus límites.

Espera un evento o un subgesto específicos

Existe un conjunto de métodos que ayuda a identificar las partes comunes de los gestos:

Aplica cálculos para eventos de múltiples puntos de contacto

Cuando un usuario realiza un gesto de varios toques con más de un puntero, es difícil comprender la transformación requerida en función de los valores sin procesar. Si el modificador transformable o los métodos detectTransformGestures no proporcionan un control lo suficientemente detallado para tu caso de uso, puedes escuchar los eventos sin procesar y aplicar cálculos en ellos. Estos métodos auxiliares son calculateCentroid, calculateCentroidSize, calculatePan, calculateRotation y calculateZoom.

Envío de eventos y pruebas de posicionamiento

No todos los eventos de puntero se envían a todos los modificadores pointerInput. El envío de eventos funciona de la siguiente manera:

  • Los eventos de puntero se envían a una jerarquía de elementos de componibilidad. En el momento en que un puntero nuevo activa su primer evento de puntero, el sistema comienza a realizar pruebas de posicionamiento de los elementos componibles "aptos". Se considera que un elemento que admite composición es apto cuando tiene capacidades de control de entrada de puntero. La prueba de posicionamiento fluye desde la parte superior del árbol de la IU hasta la parte inferior. Se "alcanza" un elemento componible cuando el evento de puntero se produce dentro de los límites de ese elemento componible. Este proceso genera una cadena de elementos componibles que realizan pruebas de posicionamiento de forma positiva.
  • De forma predeterminada, cuando hay varios elementos componibles aptos en el mismo nivel del árbol, solo se "presiona" el elemento componible con el índice Z más alto. Por ejemplo, cuando agregas dos elementos de componibilidad Button superpuestos a un Box, solo el que se dibuja en la parte superior recibe eventos de puntero. En teoría, puedes anular este comportamiento creando tu propia implementación de PointerInputModifierNode y configurando sharePointerInputWithSiblings como verdadero.
  • Los eventos adicionales para el mismo puntero se envían a esa misma cadena de elementos componibles y fluyen según la lógica de propagación de eventos. El sistema no realiza más pruebas de posicionamiento para este puntero. Esto significa que cada elemento componible de la cadena recibe todos los eventos de ese puntero, incluso cuando ocurren fuera de los límites de ese elemento componible. Los elementos componibles que no están en la cadena nunca reciben eventos de puntero, incluso cuando el puntero está dentro de sus límites.

Los eventos de desplazamiento, que se activan cuando se desplaza el mouse o la pluma stylus, son una excepción a las reglas que se definen aquí. Los eventos de desplazamiento se envían a cualquier elemento componible que alcancen. Por lo tanto, cuando un usuario coloca un puntero desde los límites de un elemento componible al siguiente, en lugar de enviar los eventos a ese primer elemento componible, los eventos se envían al nuevo elemento componible.

Consumo de eventos

Cuando más de un elemento componible tiene asignado un controlador de gestos, esos controladores no deben entrar en conflicto. Por ejemplo, observa esta IU:

Elemento de lista con una imagen, una columna con dos textos y un botón.

Cuando un usuario presiona el botón de favoritos, la lambda onClick del botón controla ese gesto. Cuando un usuario presiona cualquier otra parte del elemento de la lista, ListItem controla ese gesto y navega al artículo. En términos de entrada de puntero, el botón debe consumir este evento para que su elemento superior sepa que ya no debe reaccionar a él. Los gestos incluidos en los componentes listos para usar y los modificadores de gestos comunes incluyen este comportamiento de consumo, pero, si escribes tu propio gesto personalizado, debes consumir los eventos de forma manual. Para ello, usa el método PointerInputChange.consume:

Modifier.pointerInput(Unit) {

    awaitEachGesture {
        while (true) {
            val event = awaitPointerEvent()
            // consume all changes
            event.changes.forEach { it.consume() }
        }
    }
}

Consumir un evento no detiene su propagación a otros elementos componibles. En su lugar, un elemento componible debe ignorar explícitamente los eventos consumidos. Cuando escribas gestos personalizados, debes verificar si otro elemento ya consumió un evento:

Modifier.pointerInput(Unit) {
    awaitEachGesture {
        while (true) {
            val event = awaitPointerEvent()
            if (event.changes.any { it.isConsumed }) {
                // A pointer is consumed by another gesture handler
            } else {
                // Handle unconsumed event
            }
        }
    }
}

Propagación de eventos

Como se mencionó antes, los cambios de puntero se pasan a cada elemento componible que alcanza. Sin embargo, si existe más de un elemento componible de ese tipo, ¿en qué orden se propagan los eventos? Si tomas el ejemplo de la última sección, esta IU se traduce en el siguiente árbol de IU, en el que solo ListItem y Button responden a los eventos de puntero:

Estructura de árbol. La capa superior es ListItem, la segunda capa tiene Image, Column y Button, y Column se divide en dos Texts. ListItem y Button están destacados.

Los eventos de puntero fluyen por cada uno de estos elementos componibles tres veces, durante tres "pasadas":

  • En el Paso inicial, el evento fluye desde la parte superior del árbol de IU hasta la parte inferior. Este flujo permite que un elemento superior intercepte un evento antes de que el elemento secundario pueda consumirlo. Por ejemplo, las descripciones emergentes deben interceptar una acción de mantener presionado en lugar de pasarla a sus elementos secundarios. En nuestro ejemplo, ListItem recibe el evento antes que Button.
  • En el Paso principal, el evento fluye desde los nodos de hoja del árbol de IU hasta la raíz del árbol de IU. En esta fase, normalmente consumes gestos, y es el pase predeterminado cuando escuchas eventos. Controlar los gestos en este pase significa que los nodos de hoja tienen prioridad sobre sus elementos superiores, que es el comportamiento más lógico para la mayoría de los gestos. En nuestro ejemplo, el Button recibe el evento antes que el ListItem.
  • En el Paso final, el evento fluye una vez más desde la parte superior del árbol de IU hasta los nodos de hoja. Este flujo permite que los elementos que se encuentran más arriba en la pila respondan al consumo de eventos por parte de su elemento superior. Por ejemplo, un botón quita su indicador de ondas cuando una presión se convierte en un arrastre de su elemento superior desplazable.

Visualmente, el flujo de eventos se puede representar de la siguiente manera:

Una vez que se consume un cambio de entrada, esta información se pasa de ese punto en el flujo en adelante:

En el código, puedes especificar el pase que te interesa:

Modifier.pointerInput(Unit) {
    awaitPointerEventScope {
        val eventOnInitialPass = awaitPointerEvent(PointerEventPass.Initial)
        val eventOnMainPass = awaitPointerEvent(PointerEventPass.Main) // default
        val eventOnFinalPass = awaitPointerEvent(PointerEventPass.Final)
    }
}

En este fragmento de código, cada una de estas llamadas de método await devuelve el mismo evento idéntico, aunque los datos sobre el consumo podrían haber cambiado.

Prueba los gestos

En tus métodos de prueba, puedes enviar eventos de puntero de forma manual con el método performTouchInput. Esto te permite realizar gestos completos de nivel superior (como pellizcar o hacer clic largo) o gestos de nivel inferior (como mover el cursor una cierta cantidad de píxeles):

composeTestRule.onNodeWithTag("MyList").performTouchInput {
    swipeUp()
    swipeDown()
    click()
}

Consulta la documentación de performTouchInput para ver más ejemplos.

Más información

Puedes obtener más información sobre los gestos en Jetpack Compose en los siguientes recursos: