Entender os gestos

Há vários termos e conceitos que é importante entender ao trabalhar no processamento de gestos em um aplicativo. Esta página explica os termos ponteiros, eventos de ponteiro e gestos e apresenta os diferentes níveis de abstração para gestos. Ele também detalha o consumo e a propagação de eventos.

Definições

Para entender os vários conceitos nesta página, é preciso entender alguns dos termos usados:

  • Ponteiro: um objeto físico que pode ser usado para interagir com seu aplicativo. Em dispositivos móveis, o ponteiro mais comum é o dedo que interage com a tela touchscreen. Como alternativa, você pode usar uma stylus para substituir o dedo. Em telas grandes, você pode usar um mouse ou trackpad para interagir indiretamente com a tela. Um dispositivo de entrada precisa ser capaz de "apontar" uma coordenada para ser considerado um ponteiro. Portanto, um teclado, por exemplo, não pode ser considerado um apontador. No Compose, o tipo de ponteiro é incluído nas mudanças de ponteiro usando PointerType.
  • Evento de ponteiro: descreve uma interação de baixo nível de um ou mais ponteiros com o aplicativo em um determinado momento. Qualquer interação com o ponteiro, como colocar um dedo na tela ou arrastar o mouse, aciona um evento. No Compose, todas as informações relevantes para esse evento estão contidas na classe PointerEvent.
  • Gesto: uma sequência de eventos de ponteiro que pode ser interpretada como uma única ação. Por exemplo, um gesto de toque pode ser considerado uma sequência de um evento para baixo seguido por um evento para cima. Há gestos comuns usados por muitos apps, como tocar, arrastar ou transformar, mas você também pode criar seu próprio gesto personalizado quando necessário.

Diferentes níveis de abstração

O Jetpack Compose oferece diferentes níveis de abstração para processar gestos. No nível superior está o suporte a componentes. Elementos combináveis, como Button, incluem automaticamente suporte a gestos. Para adicionar suporte a gestos a componentes personalizados, adicione modificadores de gestos, como clickable, a elementos combináveis arbitrários. Por fim, se você precisar de um gesto personalizado, use o modificador pointerInput.

Como regra, crie no nível de abstração mais alto que ofereça a funcionalidade de que você precisa. Dessa forma, você se beneficia das práticas recomendadas incluídas na camada. Por exemplo, Button contém mais informações semânticas, usadas para acessibilidade, do que clickable, que contém mais informações do que uma implementação pointerInput bruta.

Suporte a componentes

Muitos componentes prontos para uso no Compose incluem algum tipo de processamento interno de gestos. Por exemplo, uma LazyColumn responde a gestos de arrastar rolando o conteúdo, uma Button mostra uma ondulação quando você pressiona o botão, e o componente SwipeToDismiss contém a lógica de deslizar para dispensar um elemento. Esse tipo de processamento de gestos funciona automaticamente.

Ao lado do processamento interno de gestos, muitos componentes também exigem que o autor da chamada processe o gesto. Por exemplo, um Button detecta toques automaticamente e aciona um evento de clique. Transmita um lambda onClick ao Button para reagir ao gesto. Da mesma forma, adicione um lambda onValueChange a um Slider para reagir ao usuário arrastando a alça do controle deslizante.

Quando for adequado ao seu caso de uso, prefira gestos incluídos nos componentes, já que eles incluem suporte pronto para foco e acessibilidade e são bem testados. Por exemplo, uma Button é marcada de uma forma especial para que os serviços de acessibilidade a descrevam corretamente como um botão, em vez de apenas qualquer elemento clicável:

// 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 saber mais sobre acessibilidade no Compose, consulte Acessibilidade no Compose.

Adicionar gestos específicos a elementos combináveis arbitrários com modificadores

Você pode aplicar modificadores de gesto a qualquer elemento arbitrário para fazer com que o elemento combinável detecte gestos. Por exemplo, você pode permitir que um Box genérico processe gestos de toque tornando-o clickable ou permitir que um Column processe a rolagem vertical aplicando verticalScroll.

Há muitos modificadores para processar diferentes tipos de gestos:

Como regra geral, prefira modificadores de gestos prontos para uso em vez do processamento de gestos personalizados. Os modificadores adicionam mais funcionalidades além do processamento de eventos de ponteiro puro. Por exemplo, o modificador clickable não apenas adiciona a detecção de pressionamentos e toques, mas também adiciona informações semânticas, indicações visuais sobre interações, passagem do cursor, foco e suporte ao teclado. Verifique o código-fonte de clickable para ver como a funcionalidade está sendo adicionada.

Adicionar um gesto personalizado a elementos combináveis arbitrários com o modificador pointerInput

Nem todos os gestos são implementados com um modificador pronto para uso. Por exemplo, não é possível usar um modificador para reagir a uma ação de arrastar após tocar e manter pressionado, um clique com o botão "Control" ou toque de três dedos. Em vez disso, crie seu próprio gerenciador de gestos para identificar esses gestos personalizados. Você pode criar um gerenciador de gestos com o modificador pointerInput, que fornece acesso aos eventos de ponteiro brutos.

O código a seguir detecta eventos brutos de ponteiro:

@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}"
                            }
                        }
                    }
                }
        )
    }
}

Se você dividir esse snippet, os componentes principais serão:

  • O modificador pointerInput. Você transmite uma ou mais chaves a ele. Quando o valor de uma dessas teclas muda, a lambda de conteúdo do modificador é executada novamente. O exemplo transmite um filtro opcional para o elemento combinável. Se o valor desse filtro mudar, o manipulador de eventos do ponteiro deverá ser executado novamente para garantir que os eventos certos sejam registrados.
  • awaitPointerEventScope cria um escopo de corrotina que pode ser usado para aguardar eventos de ponteiro.
  • awaitPointerEvent suspende a corrotina até que um próximo evento de ponteiro ocorra.

Embora detectar eventos de entrada brutos seja eficiente, também é complexo escrever um gesto personalizado com base nesses dados brutos. Para simplificar a criação de gestos personalizados, há muitos métodos utilitários disponíveis.

Detectar gestos completos

Em vez de processar os eventos de ponteiro brutos, você pode detectar gestos específicos que ocorrem e responder adequadamente. O AwaitPointerEventScope fornece métodos para detectar:

Esses são detectores de nível superior. Portanto, não é possível adicionar vários detectores em um modificador pointerInput. O snippet a seguir detecta apenas os toques, não os arrastos:

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" }
            }
    )
}

Internamente, o método detectTapGestures bloqueia a corrotina, e o segundo detector nunca é alcançado. Se você precisar adicionar mais de um listener de gestos a um elemento combinável, use instâncias separadas do 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" }
            }
    )
}

Processar eventos por gesto

Por definição, os gestos começam com um evento de ponteiro para baixo. É possível usar o método auxiliar awaitEachGesture em vez da repetição while(true) que transmite cada evento bruto. O método awaitEachGesture reinicia o bloco que o contém quando todos os ponteiros são levantados, indicando que o gesto foi concluído:

@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()
                    }
                }
            }
    )
}

Na prática, quase sempre é recomendável usar awaitEachGesture, a menos que você esteja respondendo a eventos de ponteiro sem identificar gestos. Um exemplo disso é o hoverable, que não responde a eventos de ponteiro para baixo ou para cima. Ele só precisa saber quando um ponteiro entra ou sai dos limites.

Aguardar um evento ou subgesto específico

Há um conjunto de métodos que ajuda a identificar partes comuns dos gestos:

Aplicar cálculos para eventos multitoque

Quando um usuário está realizando um gesto multitoque usando mais de um ponteiro, é complexo entender a transformação necessária com base nos valores brutos. Se o modificador transformable ou os métodos detectTransformGestures não fornecerem um controle detalhado suficiente para seu caso de uso, será possível detectar os eventos brutos e aplicar cálculos neles. Esses métodos auxiliares são calculateCentroid, calculateCentroidSize, calculatePan, calculateRotation e calculateZoom.

Envio de eventos e teste de hit

Nem todo evento de ponteiro é enviado para cada modificador pointerInput. O envio de eventos funciona da seguinte maneira:

  • Os eventos de ponteiro são enviados para uma hierarquia combinável. No momento em que um novo ponteiro aciona o primeiro evento de ponteiro, o sistema começa a testar os elementos combináveis "qualificados". Um elemento combinável é considerado qualificado quando tem recursos de processamento de entrada de ponteiro. O teste de hit flui da parte superior da árvore de interface para a parte inferior. Um elemento combinável é "hit" quando o evento de ponteiro ocorreu dentro dos limites desse elemento. Esse processo resulta em uma cadeia de elementos combináveis que têm um teste de hit positivo.
  • Por padrão, quando há vários elementos combináveis qualificados no mesmo nível da árvore, apenas aquele com o maior Z-index é "hit". Por exemplo, quando você adiciona dois elementos combináveis Button sobrepostos a um Box, somente o mostrado na parte de cima recebe eventos de ponteiro. Teoricamente, é possível substituir esse comportamento criando sua própria implementação de PointerInputModifierNode e definindo sharePointerInputWithSiblings como verdadeiro.
  • Outros eventos para o mesmo ponteiro são enviados para a mesma cadeia de elementos combináveis e fluem de acordo com a lógica de propagação de eventos. O sistema não realiza mais testes de hit para esse ponteiro. Isso significa que cada elemento combinável na cadeia recebe todos os eventos desse ponteiro, mesmo quando eles ocorrem fora dos limites do elemento. Os elementos combináveis que não estão na cadeia nunca recebem eventos de ponteiro, mesmo quando o ponteiro está dentro dos limites.

Os eventos de passagem de cursor, acionados pelo movimento do mouse ou com uma stylus, são uma exceção às regras definidas aqui. Os eventos de passagem de cursor são enviados para qualquer elemento combinável quando eles atingem. Portanto, quando um usuário passa o ponteiro do mouse sobre os limites de um elemento combinável para o próximo, em vez de enviar os eventos para o primeiro elemento combinável, os eventos são enviados para o novo elemento combinável.

Consumo de eventos

Quando mais de um elemento combinável tem um gerenciador de gestos atribuído, eles não podem entrar em conflito. Por exemplo, veja esta interface:

Item de lista com uma imagem, uma coluna com dois textos e um botão.

Quando um usuário toca no botão de favorito, a lambda onClick do botão processa esse gesto. Quando um usuário toca em qualquer outra parte do item da lista, o ListItem processa esse gesto e navega para o artigo. Em termos de entrada do ponteiro, o botão precisa consumir esse evento para que o pai saiba que não precisa mais reagir a ele. Os gestos incluídos em componentes prontos para uso e os modificadores de gestos comuns incluem esse comportamento de consumo, mas, se você estiver escrevendo seu próprio gesto personalizado, precisará consumir eventos manualmente. Para fazer isso, use o método PointerInputChange.consume:

Modifier.pointerInput(Unit) {

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

O consumo de um evento não interrompe a propagação dele para outros elementos combináveis. Um elemento combinável precisa ignorar explicitamente os eventos consumidos. Ao programar gestos personalizados, verifique se um evento já foi consumido por outro elemento:

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
            }
        }
    }
}

Propagação do evento

Como mencionado antes, as mudanças do ponteiro são transmitidas para cada elemento combinável atingido. No entanto, se mais de um elemento combinável existir, em que ordem os eventos se propagam? Se você usar o exemplo da última seção, essa interface será convertida na árvore da interface abaixo, em que apenas ListItem e Button respondem a eventos de ponteiro:

Estrutura de árvore. A camada superior é ListItem, a segunda tem Image, Column e Button, e a Column é dividida em dois textos. ListItem e Button estão em destaque.

Os eventos de ponteiro fluem por cada um desses elementos combináveis três vezes, durante três "passagens":

  • No passe inicial, o evento flui da parte superior da árvore da interface para a parte inferior. Esse fluxo permite que um pai intercepte um evento antes que o filho possa consumi-lo. Por exemplo, as dicas precisam interceptar um toque longo em vez de transmiti-lo aos filhos. No nosso exemplo, ListItem recebe o evento antes do Button.
  • No cartão principal, o evento flui dos nós de folha da árvore da interface até a raiz da árvore da interface. Essa é a fase em que você normalmente consome gestos e é o cartão padrão ao detectar eventos. O processamento de gestos nessa transmissão significa que os nós de folha têm precedência sobre os pais, que é o comportamento mais lógico para a maioria dos gestos. No nosso exemplo, o Button recebe o evento antes do ListItem.
  • Na Transferência final, o evento flui mais uma vez da parte de cima da árvore de interface para os nós de folha. Esse fluxo permite que elementos mais altos na pilha respondam ao consumo de eventos pelos pais. Por exemplo, um botão remove a indicação de ondulação quando o pressionamento se transforma em uma ação de arrastar do pai rolável.

Visualmente, o fluxo de eventos pode ser representado da seguinte maneira:

Depois que uma mudança de entrada é consumida, essas informações são transmitidas desse ponto em diante no fluxo:

No código, você pode especificar o cartão do seu interesse:

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

No snippet de código, o mesmo evento idêntico é retornado por cada uma dessas chamadas de método "await", embora os dados sobre o consumo possam ter mudado.

Testar gestos

Nos métodos de teste, é possível enviar eventos de ponteiro manualmente usando o método performTouchInput. Isso permite realizar gestos completos de nível superior (como fazer gesto de pinça ou clique longo) ou gestos de baixo nível (como mover o cursor em uma determinada quantidade de pixels):

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

Consulte a documentação do performTouchInput para mais exemplos.

Saiba mais

Saiba mais sobre gestos no Jetpack Compose usando estes recursos: