Gestos no Jetpack compose – Tudo o que você precisa saber

Tempo de leitura: 11 minutes

Contexto

Os gestos desempenham um papel fundamental na interação do usuário, oferecendo um meio de navegar, selecionar e manipular elementos em um aplicativo.

De simples toques e deslizes a gestos complexos de pinça para zoom e multitoque, o Jetpack Compose fornece uma estrutura robusta para integrar perfeitamente essas interações à interface do usuário do seu aplicativo.

Neste guia abrangente, vamos nos aprofundar nos recursos de manipulação de gestos do Jetpack Compose. Exploraremos tudo o que você precisa saber, desde os conceitos básicos de detecção de toques até técnicas avançadas, como a manipulação de efeitos de arremesso e o rastreamento manual de interações.

 

Modificadores de gestos e detectores de gestos

Na composição, qualquer tipo de entrada do usuário que interaja com a tela é chamado de ponteiro. De tocar na tela para mover o dedo e liberar a torneira pela figura é o gesto. O Jetpack Compose fornece uma ampla gama de API para lidar com gestos. Vamos dividi-lo em modificadores e detectores.

1. O modificador de baixo nível

Modifier.pointerInput() é para processar as entradas brutas do ponteiro, ou podemos dizer eventos.

2. Os detectores de gestos

O Compose fornece reconhecedores embutidos para detectar gestos específicos no modificador pointerInput. Esses detectores detectam o movimento específico no PointerInputScope

Modifier.pointerInput(Unit) { 
  detectTapGestures(onTap = {}, onDoubleTap = {}, 
                    onLongPress = {}, onPress = {})

  detectDragGestures(onDrag = { change, dragAmount -> },
                    onDragStart = {}, onDragEnd = {},
                    onDragCancel = {})

  detectHorizontalDragGestures(onHorizontalDrag = {change, dragAmount ->  },
                    onDragStart = {}, onDragEnd = {}, onDragCancel = {})

  detectVerticalDragGestures(onVerticalDrag = {change, dragAmount ->  },
                    onDragStart = {},onDragEnd = {}, onDragCancel = {})

  detectDragGesturesAfterLongPress(onDrag = { change, dragAmount -> },
                    onDragStart = {}, onDragEnd = {},onDragCancel = {})

  detectTransformGestures(panZoomLock = false, 
       onGesture = {
           centroid: Offset, pan: Offset, zoom: Float, rotation: Float ->  
        })
}

detectTapGestures(...) : detecta diferentes gestos de toque, como toques, toques duplos e pressionamentos longos.

Tenha em mente

  • Se você fornecer onDoubleTap, o sistema aguardará um curto período de tempo antes de considerar um toque como um toque duplo. Se for um toque duplo, onDoubleTap será chamado; caso contrário, onTap será chamado.
  • Se o toque do usuário se afastar do elemento ou outro gesto assumir o controle, os gestos serão considerados cancelados. Isso significa que onDoubleTap, onLongPress e onTap não serão chamados se o gesto for cancelado.
  • Se outra coisa consumir o evento de toque inicial (o primeiro evento para baixo), o gesto inteiro será ignorado, inclusive a função onPress.

detectDragGestures(...) : detecta gestos de arrastar, como quando um usuário passa o dedo ou arrasta algo na tela.

  1. Início do arrasto: Quando um usuário toca a tela e começa a mover o dedo (arrastar), a função onDragStart é chamada. Ela fornece a posição de toque inicial.
  2. Atualização do arrasto: à medida que o usuário continua a arrastar, a função onDrag é chamada repetidamente. Ela fornece informações sobre o quanto o usuário moveu o dedo (o dragAmount) e onde o dedo está atualmente (o objeto change).
  3. Fim do arrasto: Quando o usuário levanta o dedo, indicando o fim do arrasto, a função onDragEnd é chamada.
  4. Cancelar arrasto: se algo mais acontecer, como outro gesto assumir o controle, a função onDragCancel será chamada, indicando que o gesto de arrasto foi cancelado.

detectHorizontalDragGestures(...) : é igual a detectDragGestures, mas garante que o arrasto seja detectado somente se o usuário mover o dedo horizontalmente além de um determinado limite (inclinação do toque).

detectVerticalDragGestures(...) : garante que o arrasto seja detectado somente se o usuário mover o dedo verticalmente.

detectDragGesturesAfterLongPress(...) : Essa função garante que o arrasto seja detectado somente após um gesto de pressão longa. Ela consome todas as alterações de posição após o pressionamento longo, o que significa que rastreia o movimento do dedo até que o usuário solte o toque.

detectTransformGestures(...) : detecta gestos multitoque como rotação, panorâmica (movimento) e zoom (escala) em um elemento.

3. Os modificadores de alto nível

O Compose tem um modificador que é construído sobre o modificador de entrada de ponteiro de baixo nível com algumas funcionalidades extras. Aqui está a lista de todos os modificadores disponíveis que podemos aplicar diretamente ao composable sem PointerInput

Modifier.clickable(onClick: () -> Unit)

Modifier.combinedClickable(
    enabled: Boolean = true,
    onLongClick: (() -> Unit)? = null,
    onDoubleClick: (() -> Unit)? = null,
    onClick: () -> Unit
)

Modifier.draggable(state: DraggableState, orientation: Orientation, ...)

Modifier.anchoredDraggable(state: AnchoredDraggableState<T>,
   orientation: Orientation, ...
)

Modifier.scrollable( state: ScrollableState, orientation: Orientation, ...)

Modifier.horizontalScroll(state: ScrollState, ...)

Modifier.verticalScroll( state: ScrollState, ...) 

Modifier.transformable(state: TransformableState, ...)

Você deve ter se perguntado por que usaríamos detectores de gestos ou modificadores de gestos.

Quando usamos detectores, estamos apenas detectando os eventos, ao passo que os modificadores, além de manipularem os eventos, contêm mais informações do que uma implementação de entrada de ponteiro bruta.

Devemos escolher entre eles com base na complexidade e na especificidade das interações de toque.

Agora vamos ver os casos de uso desses detectores e modificadores de gestos.

 

Como lidar/detectar o toque e a pressão?

Muitos elementos do Jetpack Compose, como Button, IconButton, vêm com suporte integrado para interações de clique ou toque. Esses elementos são projetados para serem interativos por padrão, portanto você não precisa adicionar explicitamente um modificador clicável ou um código de detecção de gestos. Você pode simplesmente usar esses elementos compostáveis e fornecer a ação a ser executada quando eles forem clicados ou tocados.

Button(onClick = { /* Handle click action here*/ }) { Text("Click!") }

 

a. Modificador. clickable{}

O modificador Modifier.clickable {} é uma maneira simples e conveniente de lidar com gestos de toque no Jetpack Compose. Você o aplica a um elemento composto como Box, e o lambda dentro de clickable é executado quando o elemento é tocado.

Com o Modificador clicável, também podemos adicionar recursos adicionais, como fonte de interação, indicação visual, foco, foco no mouse etc. Vejamos um exemplo em que alteramos o raio do canto com base na fonte de interação.

val interactionSource = remember { MutableInteractionSource() }
val isPressed = interactionSource.collectIsPressedAsState()
val cornerRadius by animateDpAsState(targetValue = if (isPressed.value) 10.dp else 50.dp)

Box(
    modifier = Modifier
        .background(color = pink, RoundedCornerShape(cornerRadius))
        .size(100.dp)
        .clip(RoundedCornerShape(cornerRadius))
        .clickable(
            interactionSource = interactionSource,
            indication = rememberRipple()
        ) {
            //Clicked
        }
        .padding(horizontal = 20.dp),
    contentAlignment = Alignment.Center
) {
    Text(
        text = "Click!",
        color = Color.White
    )
}

b. Modifier.combinedClickable{}

É usado para tratar o clique duplo ou o clique longo juntamente com o clique único. Da mesma forma que o Modifier clickable, podemos fornecer a fonte de interação, uma indicação para combinedClickable

Box(
    modifier = Modifier
        .size(100.dp)
        .background(Color.Blue)
        .combinedClickable(
            onClick = { /* Handle click action here */},
            onDoubleClick = { /* Handle double click here */ },
            onLongClick = { /* Handle long click here */ },
        )
)

 

c. PointerInputScope.detectTapGestures()

Detecta os eventos de entrada brutos.

Box(
    modifier = Modifier
        .size(100.dp)
        .background(Color.Blue)
        .pointerInput(Unit) {
            detectTapGestures(
                onTap = { /* Handle tap here */ },
                onDoubleTap = {/* Handle double tap here */ },
                onLongPress = { /* Handle long press here */ },
                onPress = { /* Handle press here */ }
            )
        }
)

Agora você pode ter uma pergunta: e se usarmos todos os itens acima juntos?

Se aplicarmos todos ao mesmo composable, o primeiro modificador na cadeia será substituído por um posterior. Se especificarmos clickable primeiro e depois combinedClickable, receberemos um evento de toque em combinedClickable em vez de clickable Modifier.

Para obter mais detalhes, consulte esta documentação oficial.

 

Como detectar movimentos como arrastar, deslizar etc.?

Para detectar o arrastamento, podemos usar a API Modifier.draggable, ou a API ordetectDragGesture, ou a API experimental Modifier.anchoredDraggable com estados ancorados, como deslizar para desviar. Vamos vê-los um a um.

a. Modifier.draggable

Crie um elemento de interface do usuário que possa ser arrastado em uma direção (como esquerda e direita ou para cima e para baixo) e meça a distância em que foi arrastado. Comum para controles deslizantes ou componentes arrastáveis.

@Composable
fun DraggableContent() {
    var offsetX by remember { mutableStateOf(0f) }

    Box(modifier = Modifier.fillMaxSize()) {
        Box(
            modifier = Modifier.padding(top = 20.dp)
                .graphicsLayer {
                    this.translationX = offsetX
                }
                .draggable(
                    state = rememberDraggableState  {delta ->
                        offsetX += delta
                    }, orientation = Orientation.Horizontal
                )
                .size(50.dp)
                .background(Color.Blue)

        )
        Text(text = "Offset $offsetX")
    }
}

b. Modificador.anchoredDraggable

anchoredDraggable permite arrastar o conteúdo em uma direção, horizontal ou verticalmente. Essa API experimental foi introduzida recentemente na versão 1.6.0-alpha01 e é uma substituição do Modifier.swipeable()

Ela tem duas partes importantes, AnchoredDraggableState e Modifier.anchoredDraggable. AnchoredDraggableState mantém o estado de arrastar. O modificador anchoredDraggable é construído sobre o Modifier.draggable().

Quando um arrasto é detectado, ele atualiza o deslocamento do AnchoredDraggableState com o delta (alteração) do arrasto. Esse deslocamento pode ser usado para mover o conteúdo da IU de acordo. Quando o arrasto termina, o deslocamento é suavemente animado para uma das âncoras predefinidas, e o valor associado é atualizado para corresponder à nova âncora.

Veja um exemplo simples,

@Composable
fun AnchoredDraggableDemo() {
    val density = LocalDensity.current
    val configuration = LocalConfiguration.current

    val state = remember {
        AnchoredDraggableState(
            initialValue = DragAnchors.Start,
            anchors = DraggableAnchors {
                DragAnchors.Start at 0f
                DragAnchors.End at 1000f
            },
            positionalThreshold = { distance: Float -> distance * 0.5f },
            velocityThreshold = { with(density) { 100.dp.toPx() } },
            animationSpec = tween(),
        )
    }

    Box(
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp)
    ) {
        Box(
            modifier = Modifier
                .padding(top = 40.dp)
                .offset {
                    IntOffset(
                        x = state
                            .requireOffset()
                            .roundToInt(), y = 0
                    )
                }
                .anchoredDraggable(state = state, orientation = Orientation.Horizontal)
                .size(50.dp)
                .background(Color.Red)

        )
    }
}

 

c. detectDragGesture

Para obter controle total sobre os gestos de arrastar, use o detector de gestos de arrastar com o modificador de entrada do ponteiro.

@Composable
fun DraggableContent() {
    var offsetX by remember { mutableStateOf(0f) }
    var offsetY by remember { mutableStateOf(0f) }

    Box(modifier = Modifier
        .fillMaxSize()
        .padding(16.dp)) {
        Box(
            modifier = Modifier
                .padding(top = 40.dp)
                .graphicsLayer {
                    this.translationX = offsetX
                    this.translationY = offsetY
                }
                .pointerInput(Unit) {
                    detectDragGestures { change, dragAmount ->
                        change.consume()
                        offsetX += dragAmount.x
                        offsetY += dragAmount.y
                    }
                }
                .size(50.dp)
                .background(Color.Red)

        )
        Text(text = "Offset X $offsetX \nOffset Y: $offsetY")
    }
}

d. detectHorizontalDragGestures & detectVerticalDragGestures

Semelhante ao detector de gestos acima, para detectar o arrasto em uma direção específica, temos esses dois gestos.

@Composable
fun DraggableContent() {
    var offsetX by remember { mutableStateOf(0f) }
    var offsetY by remember { mutableStateOf(0f) }

    Box(
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp)
    ) {
        Box(
            modifier = Modifier
                .padding(top = 40.dp)
                .offset {
                    IntOffset(x = offsetX.roundToInt(), y = offsetY.roundToInt())
                }
                .pointerInput(Unit) {
                    detectHorizontalDragGestures { change, dragAmount ->
                        change.consume()
                        offsetX += dragAmount
                    }
                    /*
                    detectVerticalDragGestures { change, dragAmount ->
                        change.consume()
                        offsetY += dragAmount
                    }
                    */
                }
                .size(50.dp)
                .background(Color.Red)

        )
        Text(text = "Offset X $offsetX \nOffset Y: $offsetY")
    }
}

E se especificarmos ambos no mesmo escopo de entrada do ponteiro?

Simplesmente, o primeiro detector de gestos na cadeia será invocado, e o posterior será ignorado. Se você quiser usar os dois gestos para composição, encadeie dois Modifier.pointerInput .

 

e. detectDragGesturesAfterLongPress

Essa detecção de gestos nos permite ter um controle preciso sobre o arrastar; ela invoca a chamada de retorno onDrag somente após um toque longo. Isso pode ser útil em vários cenários em que se deseja fornecer aos usuários uma maneira de reorganizar ou interagir com itens em uma lista, reordenar elementos em uma grade ou executar ações que exijam confirmação ou iniciação por meio de um toque longo antes de permitir o arrastamento.

@Preview
@Composable
fun DraggableContent() {
    var offsetX by remember { mutableStateOf(0f) }
    var offsetY by remember { mutableStateOf(0f) }

    Box(
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp)
    ) {
        Box(
            modifier = Modifier
                .padding(top = 40.dp)
                .offset {
                    IntOffset(x = offsetX.roundToInt(), y = offsetY.roundToInt())
                }
                .pointerInput(Unit) {
                    detectDragGesturesAfterLongPress { change, dragAmount ->
                        offsetX += dragAmount.x
                        offsetY += dragAmount.y
                    }
                }
                .size(50.dp)
                .background(Color.Green)

        )
        Text(text = "Offset X $offsetX \nOffset Y: $offsetY")
    }
}

Como detectar gestos de rolagem?

Há poucos modificadores que tornam os compostáveis roláveis ou que nos permitem detectar a rolagem. Além disso, alguns composables, como LazyColumn ou LazyRow, vêm com rolagem embutida, e você pode usar a propriedade state do LazyListState para detectar eventos de rolagem.

a. Modifier.scrollable()

O Modifier.scrollable detecta os gestos de rolagem, mas não move automaticamente o conteúdo. O Modifier.scrollable ouve os gestos de rolagem e depende do ScrollableState fornecido para controlar o comportamento de rolagem de seu conteúdo.

Você deve usar Modifier.scrollable em vez de Modifier.verticalScroll() ou Modifier.horizontalScroll() quando precisar de um controle mais refinado sobre o comportamento de rolagem e quando quiser lidar com a lógica de rolagem personalizada, como rolagem aninhada ou interações complexas.

@Composable
fun ScrollableDemo() {
    val state = rememberScrollState()
    Box(
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp)
            .scrollable(state, orientation = Orientation.Horizontal)
    ) {
        Box(
            modifier = Modifier
                .padding(top = 40.dp)
                .offset { IntOffset(x = state.value, y = 0) }
                .size(50.dp)
                .background(Color.Red)

        )

        Text(text = "Offset X ${state.value} ")
    }
}

 

b. Modifier.verticalScroll() e Modifier.horizontalScroll()

Em contraste com Modifier.scrollable(), Modifier.verticalScroll() e Modifier.horizontalScroll() são opções mais simples e diretas para necessidades básicas de rolagem.

Eles são adequados para os casos em que você deseja tornar um único Composable rolável vertical ou horizontalmente sem a necessidade de manipulação de rolagem personalizada ou cenários de rolagem aninhados.

Esses modificadores oferecem uma maneira conveniente de ativar o comportamento de rolagem padrão com configuração mínima.

@Composable
fun HorizontalScrollDemo() {
    val state = rememberScrollState()
    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp)
    ) {
        Text(text = "Offset X ${state.value} ")

        Row(
            modifier = Modifier
                .fillMaxSize()
                .horizontalScroll(state) // or use .verticalScroll(...) for vertical scrolling
        ) {
            repeat(20) {
                Box(
                    modifier = Modifier
                        .padding(8.dp)
                        .size(50.dp)
                        .background(Color.Red)
                )
            }
        }
    }
}

c. Modificador.nestedScroll()

A rolagem aninhada permite interações de rolagem coordenadas entre vários elementos da interface do usuário, garantindo que as ações de rolagem se propaguem corretamente por uma hierarquia de componentes roláveis e não roláveis.

O componente de rolagem embutido, como o LazyList, suporta a rolagem aninhada, mas para componentes não roláveis, precisamos ativá-la manualmente com o NestedScrollConnection.

Há duas maneiras de um elemento participar da rolagem aninhada:

  • Como um filho de rolagem, ele envia eventos de rolagem por meio de um NestedScrollDispatcher para a cadeia de rolagem aninhada.
  • Como membro da cadeia de rolagem aninhada, ele fornece uma NestedScrollConnection, que é chamada quando outro filho de rolagem aninhado abaixo envia eventos de rolagem.

Você pode optar por usar um ou ambos os métodos com base em seu caso de uso específico.

Quatro fases principais da rolagem aninhada:

  • Pré-rolagem: Ocorre quando um descendente está prestes a executar uma operação de rolagem. Os pais podem consumir parte do delta do filho antecipadamente.
  • Pós-rolagem: Acionada depois que o descendente consome o delta, notificando os ancestrais sobre o delta não consumido.
  • Pré-rolagem: Isso acontece quando o descendente em rolagem está prestes a lançar, permitindo que os ancestrais consumam parte da velocidade.
  • Pós-fling: Ocorre depois que o descendente de rolagem termina de arremessar, notificando os ancestrais sobre a velocidade restante a ser consumida.

Não vamos nos aprofundar no NestedScroll nesta publicação do blog, você pode consultar a documentação oficial para obter mais detalhes.

Vejamos um exemplo simples, em que temos uma rolagem horizontal aninhada com um componente arrastável ancorado.

@Composable
fun NestedScrollExample() {
    val density = LocalDensity.current

    val state = remember {
        AnchoredDraggableState(
            initialValue = DragAnchors.Center,
            anchors = DraggableAnchors {
                DragAnchors.Start at -200f
                DragAnchors.Center at 0f
                DragAnchors.End at 200f
            },
            positionalThreshold = { distance: Float -> distance * 0.5f },
            velocityThreshold = { with(density) { 100.dp.toPx() } },
            animationSpec = tween(),
        )
    }
    
    Box(
        modifier = Modifier.fillMaxSize().padding(20.dp)
    ) {
        Box(
            modifier = Modifier
                .fillMaxSize()
                .anchoredDraggable(state, Orientation.Horizontal)
                .offset {
                    IntOffset(state.requireOffset().roundToInt(), 0)
                }
        ) {
            Box(
                modifier = Modifier
                    .padding(4.dp)
                    .fillMaxWidth()
                    .height(60.dp)
                    .background(Color.Black, RoundedCornerShape(10.dp))
                    .padding(horizontal = 10.dp)
                    .horizontalScroll(rememberScrollState()),
                contentAlignment = Alignment.Center
            ) {
                Text(
                    text = "Nested scroll modifier demo. Hello, Jetpack compose",
                    color = Color.White,
                    fontSize = 24.sp,
                    maxLines = 1,
                    textAlign = TextAlign.Center
                )
            }
        }
    }
}

Você pode ver aqui que a rolagem vertical e o arrastar não estão funcionando juntos. Agora vamos adicionar a rolagem aninhada e enviar o deslocamento para tornar o composable draggable e scrollable.

val nestedScrollConnection = remember {
    object : NestedScrollConnection {
        override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
            if ((available.x > 0f && state.offset < 0f) || (available.x < 0f && state.offset > 0f)) {
                return Offset(state.dispatchRawDelta(available.x), 0f)
            }
            return Offset.Zero
        }
        override fun onPostScroll(
            consumed: Offset,
            available: Offset,
            source: NestedScrollSource,
        ): Offset {
            return Offset(state.dispatchRawDelta(available.x), 0f)
        }
    }
}
Box(
            modifier = Modifier
                .fillMaxSize()
                .anchoredDraggable(state, Orientation.Horizontal)
                .offset {
                    IntOffset(state.requireOffset().roundToInt(), 0)
                }
                .nestedScroll(nestedScrollConnection)
       ) {
            
 // .... content
}

 

Neste código:

  • Definimos uma NestedScrollConnection para gerenciar eventos de rolagem de um componente arrastável.
  • A função onPreScroll manipula os eventos de rolagem antes de serem consumidos e ajusta o estado do componente arrastável ancorado com base na direção da rolagem.
  • A função onPostScroll trata os eventos de rolagem depois que eles são consumidos e ajusta ainda mais o estado do componente.

 

Conclusão

Nesta primeira parte de nossa exploração dos gestos no Jetpack Compose, analisamos alguns aspectos fundamentais do manuseio de gestos que prepararão o terreno para técnicas e funcionalidades mais avançadas na Parte Dois.

Na Parte 1, abordamos os aspectos essenciais dos gestos no Jetpack Compose. Exploramos Modificadores de gestos, Detecção de toque, Movimentos como arrastar e deslizar e Gestos de rolagem.

Na Parte 2 de nossa série, exploraremos tópicos avançados, incluindo o manuseio de vários gestos simultaneamente, a criação de efeitos de arremesso, o rastreamento manual de interações, a observação sem consumo, a desativação de interações e a espera por eventos específicos.