Entender os gestos

Há vários termos e conceitos importantes para entender ao trabalhar com o processamento de gestos em um aplicativo. Nesta página, explicamos os termos ponteiros, eventos de ponteiro e gestos, além de apresentar os diferentes níveis de abstração para gestos. Ele também se aprofunda no consumo e na propagação de eventos.

Definições

Para entender os vários conceitos desta página, é preciso compreender parte da terminologia usada:

  • Ponteiro: um objeto físico que você pode usar para interagir com o aplicativo. Em dispositivos móveis, o ponteiro mais comum é o dedo interagindo com a tela touch. Como alternativa, você pode usar uma stylus em vez do dedo. Em telas grandes, é possível usar um mouse ou trackpad para interagir indiretamente com a tela. Um dispositivo de entrada precisa ser capaz de "apontar" para uma coordenada para ser considerado um ponteiro. Portanto, um teclado, por exemplo, não pode ser considerado um ponteiro. 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 um 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 de pressionamento seguido por um evento de liberação. 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. As funções que podem ser compostas, como Button, incluem automaticamente suporte a gestos. Para adicionar suporte a gestos a componentes personalizados, adicione modificadores de gesto, como clickable, a elementos de composição arbitrários. Por fim, se você precisar de um gesto personalizado, use o modificador pointerInput.

Como regra, use o nível mais alto de abstração que oferece a funcionalidade necessária. 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, um LazyColumn responde a gestos de arrastar rolando o conteúdo, um Button mostra uma ondulação quando você o pressiona e o componente SwipeToDismiss contém uma lógica de deslizar para dispensar um elemento. Esse tipo de processamento de gestos funciona automaticamente.

Além do processamento de gestos interno, muitos componentes também exigem que o autor da chamada processe o gesto. Por exemplo, um Button detecta automaticamente toques e aciona um evento de clique. Você transmite uma lambda onClick para o Button para reagir ao gesto. Da mesma forma, adicione uma 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 em componentes, porque eles incluem suporte pronto para uso para foco e acessibilidade e são bem testados. Por exemplo, um Button é marcado de uma maneira especial para que os serviços de acessibilidade o descrevam corretamente como um botão, em vez de apenas um 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 de composição arbitrários com modificadores

É possível aplicar modificadores de gesto a qualquer elemento combinável arbitrário para que ele detecte gestos. Por exemplo, é possível 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, prefira modificadores de gestos prontos para uso em vez de processamento de gestos personalizado. Os modificadores adicionam mais funcionalidades ao 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 em interações, passagem do cursor, foco e suporte ao teclado. Confira o código-fonte de clickable para ver como a funcionalidade está sendo adicionada.

Adicionar gestos personalizados a elementos combináveis arbitrários com o modificador pointerInput

Nem todos os gestos são implementados com um modificador de gesto pronto para uso. Por exemplo, não é possível usar um modificador para reagir a um arrasto após tocar e manter pressionado, um clique com a tecla Control ou um toque com três dedos. Em vez disso, você pode criar seu próprio gerenciador de gestos para identificar esses gestos personalizados. É possível criar um gerenciador de gestos com o modificador pointerInput, que dá acesso aos eventos de ponteiro brutos.

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

@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. Quando o valor de uma dessas chaves 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 de ponteiro precisará 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 ocorra um próximo evento do ponteiro.

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

Detectar gestos completos

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

Esses são detectores de nível superior, então 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 do loop while(true) que passa por cada evento bruto. O método awaitEachGesture reinicia o bloco de contenção 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 é melhor usar awaitEachGesture, a menos que você esteja respondendo a eventos de ponteiro sem identificar gestos. Um exemplo disso é hoverable, que não responde a eventos de pressionar ou soltar o ponteiro. 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 de gestos:

Aplicar cálculos para eventos multitoque

Quando um usuário faz 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 oferecerem controle refinado o suficiente para seu caso de uso, você poderá detectar os eventos brutos e aplicar cálculos a eles. Esses métodos auxiliares são calculateCentroid, calculateCentroidSize, calculatePan, calculateRotation e calculateZoom.

Envio de eventos e teste de hit

Nem todos os eventos de ponteiro são enviados para todos os modificadores pointerInput. A expedição 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 hits dos elementos que podem ser compostos "qualificados". Um elemento combinável é considerado qualificado quando tem recursos de processamento de entrada de ponteiro. O teste de hit flui de cima para baixo na árvore da interface. Um elemento combinável é "atingido" quando o evento de ponteiro ocorre dentro dos limites dele. Esse processo resulta em uma cadeia de funções de composição 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 o elemento com o maior índice z é "atingido". Por exemplo, quando você adiciona dois elementos que podem ser compostos Button sobrepostos a um Box, apenas o que está desenhado por 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 essa mesma cadeia de composição 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 desse elemento. Os elementos que não estão na cadeia nunca recebem eventos de ponteiro, mesmo quando o ponteiro está dentro dos limites deles.

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

Consumo de eventos

Quando mais de um elemento combinável tem um gerenciador de gestos atribuído, esses gerenciadores não podem entrar em conflito. Por exemplo, confira 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 favoritos, 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 até o artigo. Em termos de entrada de ponteiro, o botão precisa consumir esse evento para que o pai saiba que não deve 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 isso, use o método PointerInputChange.consume:

Modifier.pointerInput(Unit) {

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

Consumir um evento não interrompe a propagação dele para outros elementos combináveis. Em vez disso, um elemento que pode ser composto precisa ignorar explicitamente os eventos consumidos. Ao criar 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 de eventos

Como mencionado antes, as mudanças de ponteiro são transmitidas para cada elemento combinável que elas atingem. Mas se houver mais de um elemento combinável desse tipo, em que ordem os eventos vão se propagar? Se você usar o exemplo da última seção, essa interface será traduzida para a árvore de interface abaixo, em que apenas ListItem e Button respondem a eventos de ponteiro:

Estrutura em árvore. A camada de cima é ListItem, a segunda tem Image, Column e Button, e Column se divide em dois Texts. ListItem e Button estão destacados.

Os eventos de ponteiro fluem por cada um desses elementos que podem ser compostos três vezes, durante três "passagens":

  • Na passagem inicial, o evento flui da parte de cima da árvore da IU para a de baixo. 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 de Button.
  • Na passagem principal, o evento flui dos nós de folha da árvore da IU até a raiz dela. Essa fase é onde você normalmente consome gestos e é a passagem padrão ao detectar eventos. Processar gestos nessa passagem 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 passagem final, o evento flui mais uma vez da parte de cima da árvore da interface para os nós de folha. Esse fluxo permite que os elementos mais altos na pilha respondam ao consumo de eventos pelo elemento pai. Por exemplo, um botão remove a indicação de ondulação quando um toque se transforma em um arrasto do pai rolável.

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

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

No código, é possível especificar o cartão em que você tem interesse:

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

Nesse snippet de código, o mesmo evento idêntico é retornado por cada uma dessas chamadas de método de espera, 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 que você execute gestos completos de nível mais alto (como pinça ou clique longo) ou gestos de nível mais baixo (como mover o cursor por uma determinada quantidade de pixels):

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

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

Saiba mais

Para saber mais sobre gestos no Jetpack Compose, consulte os seguintes recursos: