Comprende los gestos

Hay varios términos y conceptos que es importante comprender cuando se trabaja en el control gestual 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 que se mencionan en esta página, debes comprender 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. También puedes usar una pluma stylus para reemplazar el dedo. En el caso de pantallas grandes, puedes usar un mouse o un panel táctil para interactuar con la pantalla de forma indirecta. Un dispositivo de entrada debe poder "apuntar" a una coordenada para que se 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 punteros 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á un evento. En Compose, toda la información relevante para ese evento se encuentra en la clase PointerEvent.
  • Gesto: Una secuencia de eventos de puntero que se pueden interpretar como una sola acción. Por ejemplo, un gesto de toque puede considerarse una secuencia de un evento de presión seguido de un evento de presión. Hay gestos comunes que usan muchas apps, como presionar, arrastrar o transformar, pero también puedes crear tu propio gesto personalizado cuando lo necesites.

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 componentes personalizados, puedes agregar modificadores de gestos, como clickable, a elementos arbitrarios que admiten composición. Por último, si necesitas un gesto personalizado, puedes usar el modificador pointerInput.

Como regla, 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 con componentes

Muchos componentes listos para usar en Compose incluyen algún tipo de control interno de gestos. Por ejemplo, un objeto LazyColumn responde a los gestos de arrastre desplazándose por su contenido, un objeto Button muestra una ondulación cuando presionas hacia abajo y el componente SwipeToDismiss incluye la lógica de deslizamiento para descartar un elemento. Este tipo de control gestual funciona automáticamente.

Además del control gestual interno, muchos componentes también requieren que el llamador controle el gesto. Por ejemplo, un objeto Button detecta automáticamente toques y activa un evento de clic. Pasa una lambda onClick a Button para reaccionar al gesto. De manera similar, agregas una lambda onValueChange a una Slider para reaccionar cuando el usuario arrastre el controlador del control deslizante.

Cuando se adapte a tu caso de uso, prioriza los gestos incluidos en los componentes, ya que incluyen compatibilidad inmediata para 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.

Cómo agregar gestos específicos a elementos componibles arbitrarios con modificadores

Puedes aplicar modificadores de gestos a cualquier elemento componible arbitrario para que este escuche los gestos. Por ejemplo, puedes permitir que una Box genérica controle los gestos de presión si la estableces como clickable, o bien puedes permitir que una Column controle el desplazamiento vertical aplicando verticalScroll.

Hay muchos modificadores para controlar diferentes tipos de gestos:

Como regla, es preferible usar modificadores de gestos listos para usar en lugar del control gestual personalizado. Los modificadores agregan más funcionalidad además del control puro de eventos de puntero. Por ejemplo, el modificador clickable no solo agrega detección de pulsaciones y presiones, sino que también agrega información semántica, indicaciones visuales sobre interacciones, desplazamiento, enfoque y 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, hacer clic con el control o presionar 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 del 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 divides 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 teclas, se vuelve a ejecutar la expresión lambda del contenido del modificador. En la muestra, se pasa un filtro opcional al elemento componible. Si el valor de ese filtro cambia, el controlador de eventos del puntero debe volver a ejecutarse para asegurarte de que se registren los eventos correctos.
  • awaitPointerEventScope crea un alcance de corrutinas que se puede usar para esperar eventos del puntero.
  • awaitPointerEvent suspende la corrutina hasta que se produce un próximo evento de puntero.

Aunque escuchar eventos de entrada sin procesar es 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 del puntero sin procesar, puedes escuchar para que ocurran gestos específicos y responder de manera apropiada. 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 los toques, 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 independientes del modificador pointerInput:

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 que lo contiene 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 los eventos del puntero sin identificar los gestos. Un ejemplo de esto es hoverable, que no responde a los eventos del puntero hacia abajo o hacia arriba; solo necesita saber cuándo un puntero entra o sale de sus límites.

Esperar un evento o un gesto secundario específico

Hay un conjunto de métodos que ayudan a identificar las partes comunes de los gestos:

Aplicar cálculos para eventos de varios puntos de contacto

Cuando un usuario realiza un gesto de varios toques con más de un puntero, es complejo 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 sobre ellos. Estos métodos auxiliares son calculateCentroid, calculateCentroidSize, calculatePan, calculateRotation y calculateZoom.

Envío de eventos y prueba de posicionamiento

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

  • Los eventos del puntero se envían a una jerarquía componible. En el momento en que un puntero nuevo activa su primer evento de puntero, el sistema comienza a probar los elementos que admiten composición "elegibles". Un elemento componible se considera apto cuando tiene capacidades de control de entrada de puntero. Las pruebas de posicionamiento fluyen desde la parte superior del árbol de IU hasta la parte inferior. Un elemento componible es "acierto" cuando el evento del puntero ocurre dentro de los límites de ese elemento. Este proceso da como resultado una cadena de elementos componibles que hacen una prueba de posicionamiento positiva.
  • De forma predeterminada, cuando hay varios elementos componibles aptos en el mismo nivel del árbol, solo el elemento con el índice z más alto tendrá el valor "hit". Por ejemplo, cuando agregas dos elementos componibles Button superpuestos a un Box, solo el dibujado en la parte superior recibe eventos de puntero. En teoría, puedes anular este comportamiento si creas tu propia implementación de PointerInputModifierNode y configuras sharePointerInputWithSiblings como verdadero.
  • Los eventos adicionales del mismo puntero se despachan a esa misma cadena de elementos componibles y fluyen según la lógica de propagación de eventos. El sistema no realizará más pruebas de posicionamiento para este puntero. Eso significa que cada elemento componible de la cadena recibe todos los eventos de ese puntero, incluso cuando aquellos ocurren fuera de los límites de ese elemento. 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 colocar el cursor sobre un elemento, que se activan con un mouse o una pluma stylus, son una excepción a las reglas que se definen aquí. Los eventos de colocar el cursor sobre un elemento se envían a cualquier elemento de componibilidad que alcance. Por lo tanto, cuando un usuario coloca el cursor sobre 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 un controlador de gestos asignado, 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 del puntero, el botón debe consumir este evento para que su elemento superior sepa no volver a reaccionar a él. Los gestos incluidos en los componentes listos para usar y los modificadores de gestos comunes incluyen este comportamiento de consumo. Sin embargo, si escribes tu propio gesto personalizado, debes consumir 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() }
        }
    }
}

El consumo de un evento no detiene su propagación a otros elementos componibles. Un elemento que admite composición debe ignorar de forma explícita los eventos consumidos. Cuando escribes 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 recibe. Sin embargo, si existe más de un elemento componible, ¿en qué orden se propagan los eventos? Si tomas el ejemplo de la última sección, esta IU se traduce al 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 la Columna se divide en dos Texts. Se destacan ListItem y Button.

Los eventos del puntero fluyen a través de cada uno de estos elementos componibles tres veces, durante tres "pases":

  • En el Pase 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 secundario pueda consumirlo. Por ejemplo, la información sobre la herramienta debe interceptar una presión prolongada en lugar de pasarla a sus elementos secundarios. En nuestro ejemplo, ListItem recibe el evento antes de Button.
  • En el Pase principal, el evento fluye desde los nodos hoja del árbol de IU hasta la raíz del árbol de IU. En esta fase, sueles consumir gestos y es el pase predeterminado cuando escuchas eventos. El control de los gestos en este pase implica que los nodos de hoja tienen prioridad sobre los superiores, que es el comportamiento más lógico de la mayoría de los gestos. En nuestro ejemplo, Button recibe el evento antes de ListItem.
  • En el Pase 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 superiores en la pila respondan al consumo de eventos por parte de su elemento superior. Por ejemplo, un botón quita su indicación de ondas cuando una pulsació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, la información se pasa desde 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 en espera muestra el mismo evento idéntico, aunque podrían haber cambiado los datos sobre el consumo.

Cómo probar gestos

En tus métodos de prueba, puedes enviar eventos de puntero de forma manual con el método performTouchInput. De esta manera, puedes realizar gestos completos de nivel superior (como pellizcar o clic largo) o gestos de bajo nivel (como mover el cursor una determinada 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: