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

Tempo de leitura: 6 minutes

Histórico

Bem-vindo de volta à segunda parte de nossa exploração detalhada dos gestos no Jetpack Compose. Na primeira parte, analisamos os fundamentos, abordando uma série de Modificadores e Detectores de Gestos. Percorremos os meandros da detecção de toques, movimentos como arrastar e deslizar e gestos de rolagem, fornecendo uma base sólida para aprimorar a interface do usuário com elementos interativos.

Agora, nesta parte, levaremos nosso conhecimento sobre gestos no Jetpack Compose para o próximo nível. Desvendaremos as complexidades de lidar com vários gestos ao mesmo tempo, criando um efeito de arremesso, rastreando manualmente as interações, desativando as interações quando necessário e até mesmo aguardando eventos específicos.

 

Como lidar com vários gestos juntos?

Podemos lidar com vários toques, como rotação, movimento panorâmico e zoom em um elemento, usando um detector – detectTransformGesture ou um modificador de alto nível – transformable().

a. Modificador.transformable

@Composable
fun TransformableStateExample() {
    var scale by remember { mutableStateOf(1f) }
    var offset by remember { mutableStateOf(Offset(0f, 0f)) }
    var rotation by remember { mutableStateOf(0f) }

    val state =
        rememberTransformableState(onTransformation = { zoomChange, panChange, rotationChange ->
            scale *= zoomChange
            rotation += rotationChange
            offset += panChange
        })

    Box(
        modifier = Modifier
            .fillMaxSize()
            .background(Color.Black), contentAlignment = Alignment.Center
    ) {

        Text(
            text = "Hello World!",
            style = TextStyle(
                fontSize = 35.sp,
                brush = Brush.linearGradient(
                    colors = RainbowColors
                )
            ),
            modifier = Modifier
                .graphicsLayer(
                    scaleX = scale,
                    scaleY = scale,
                    translationX = offset.x,
                    translationY = offset.y,
                    rotationZ = rotation
                ).transformable(state)

        )
    }
}

b. detectTransformGesture

@Composable
fun TransformGestureExample() {
    var scale by remember { mutableStateOf(1f) }
    var offset by remember { mutableStateOf(Offset(0f, 0f)) }
    var rotation by remember { mutableStateOf(0f) }

    Box(
        modifier = Modifier
            .fillMaxSize().background(Color.Black), contentAlignment = Alignment.Center
    ) {

        Text(
            text = "Hello World!",
            style = TextStyle(
                fontSize = 35.sp,
                brush = Brush.linearGradient(
                    colors = RainbowColors
                )
            ),
            modifier = Modifier
                .graphicsLayer(
                    scaleX = scale,
                    scaleY = scale,
                    translationX = offset.x,
                    translationY = offset.y,
                    rotationZ = rotation
                )
                .pointerInput(Unit) {
                    detectTransformGestures { _, pan, zoom, rotationChange ->
                        scale *= zoom
                        offset += pan
                        rotation += rotationChange
                    }
                }
        )

    }
}

O PointerInput.detectTransformGestures fornece informações sobre várias transformações, como escala, panorâmica e rotação, em uma única chamada de retorno. Você pode usar essa chamada de retorno para atualizar seu estado ou executar ações com base nos gestos combinados.

Ambas as abordagens alcançam funcionalidade semelhante, portanto, você pode escolher a que melhor se adapta ao seu estilo de codificação e às suas preferências.

Como lidar com um efeito de arremesso com gestos?

Capturando os eventos de toque do usuário e calculando a velocidade e a direção do deslize, podemos criar um efeito de arremesso suave e responsivo.

Podemos obter um efeito de movimento com o VelocityTracker no Jetpack Compose. Vamos criar um controle deslizante de cores com rolagem personalizada.

@Composable
fun ColorSlider(
    modifier: Modifier = Modifier,
    colors: List<Color> = emptyList(),
) {

    val animatable = remember {
        Animatable(0f)
    }

    val itemWidth = with(LocalDensity.current) { 52.dp.toPx() }

    Column(
        modifier = modifier,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {

        Box(
            modifier = Modifier
                .fillMaxWidth(),
            contentAlignment = Alignment.TopCenter,
        ) {

            colors.forEachIndexed { index, color ->
                val offsetX = (index - animatable.value) * itemWidth
                Column(
                    modifier = Modifier
                        .width(50.dp)
                        .graphicsLayer(
                            translationX = offsetX
                        ),
                    horizontalAlignment = Alignment.CenterHorizontally,
                ) {
                    Box(
                        modifier = Modifier
                            .size(50.dp)
                            .background(color)
                    )
                }
            }

        }

        Icon(Icons.Filled.KeyboardArrowUp, tint = Color.White, contentDescription = null)
    }
}

Nada sofisticado, apenas uma interface de usuário simples que calcula a posição da caixa de cor e a renderiza. Vamos ver o resultado.

Atualmente, um controle deslizante não é rolável, vamos torná-lo rolável com pointerInput

private fun Modifier.fling(
    animatable: Animatable<Float, AnimationVector1D>,
    itemCount: Int,
    itemWidth: Float,
) = pointerInput(Unit) {
    coroutineScope {
        while (true) {
            // 1
            val pointerId = awaitPointerEventScope { awaitFirstDown().id }
            animatable.stop()
            awaitPointerEventScope {
                // 2
                horizontalDrag(pointerId) { change ->
                    val horizontalDragOffset =
                        animatable.value - change.positionChange().x / itemWidth
                    launch {
                        val value = horizontalDragOffset.coerceIn(0f, itemCount.toFloat())
                        // 3
                        animatable.snapTo(value)
                    }
                    // 4
                    change.consume()
                }
            }
        }
    }
}

Criamos uma extensão personalizada Modifier, que recebe animatable, itemCount e itemWidth

  1. Ele aguarda o primeiro evento de ponteiro para baixo e recupera o id do ponteiro.
  2. Aguarda eventos de arrasto horizontal para o ponteiro especificado. Calcula o deslocamento de arrasto horizontal considerando a alteração na posição x do ponteiro em relação ao itemWidth.
  3. A função snapTo é chamada no animatable para se ajustar ao novo valor.
  4. Por fim, consome as alterações.

Saída

Como você pode ver, a rolagem não é suave. Para corrigir isso, usaremos a animação de decaimento com um rastreador de velocidade. Esse aprimoramento evita que a animação pare repentinamente em posições arbitrárias. Vamos modificar o Modificador acima para adicionar primeiro a animação de decaimento.

private fun Modifier.fling(
    animatable: Animatable<Float, AnimationVector1D>,
    itemCount: Int,
    itemWidth: Float,
) = pointerInput(Unit) {
    val decay = splineBasedDecay<Float>(this)
    val decayAnimationSpec = FloatSpringSpec(
        dampingRatio = Spring.DampingRatioLowBouncy,
        stiffness = Spring.StiffnessLow,
    )
 ....

Agora vamos adicionar o VelocityTracker e rastrear as alterações de posição.

...
    val pointerId = awaitPointerEventScope { awaitFirstDown().id }
    animatable.stop()
    val tracker = VelocityTracker()
    awaitPointerEventScope {
        horizontalDrag(pointerId) { change ->
            ...
            tracker.addPosition(change.uptimeMillis, change.position)
            ...
        }
    }
// rest of the code

tracker.addPosition(change.uptimeMillis, change.position) registra a posição do ponteiro no momento determinado no VelocityTracker. Essa informação é crucial para estimar a velocidade do arrasto.

Agora vamos calcular a velocidade e animar o valor quando o evento de arrastar for concluído.

coroutineScope {
        ...
        val tracker = VelocityTracker()
        awaitPointerEventScope {
            ....
        }

        launch {
            val velocity = tracker.calculateVelocity().x / itemCount
            val targetValue = decay.calculateTargetValue(animatable.value, -velocity)
            val target = targetValue.roundToInt().coerceIn(0, itemCount).toFloat()
            animatable.animateTo(
                target, initialVelocity = velocity,
                animationSpec = decayAnimationSpec
            )
        }
    }
}

A função decay.calculateTargetValue é usada para calcular o valor-alvo da animação de decaimento. Ela recebe o valor atual animatable e a negação da velocidade, indicando a direção do decaimento.

Por fim, o animável é animado para o valor-alvo calculado usando animateTo.

Agora, quando soltarmos o ponteiro depois de arrastar, nosso controle deslizante de cor será animado para o valor-alvo e parará de acordo com a velocidade.

Saída

 

Como coletar a interação manualmente?

Usando o MutableInteractionSource, podemos rastrear o evento de interação do usuário, como quando o botão é pressionado ou liberado etc.

O MutableInteractionSource fornece um fluxo de interações do componente.

Vamos ver como podemos coletar interações

@Composable
fun TrackInteractionDemo() {
    val interactionSource = remember { MutableInteractionSource() }
    var color by remember { mutableStateOf(Color.White) }
    Button({}, interactionSource = intSource) {
        Text("Click me!", color = color)
    }
    LaunchedEffect(Unit) {
        interactionSource.interactions.collect {
            color = when (it) {
                is PressInteraction.Press -> Color.Red
                else -> Color.White
            }
        }
    }
}

O LaunchedEffect observa os eventos de interação, atualizando o estado da cor de acordo. O resultado é um botão que muda de cor dinamicamente com base nos pressionamentos e liberações do usuário.

Saída

Além disso, podemos coletar uma interação específica como estado de interação, algo assim,

 val intSource = remember { MutableInteractionSource() }
 val isPressed = intSource.collectIsPressedAsState()
 val isDragged = intSource.collectIsDraggedAsState()
 val isFocused = intSource.collectIsFocusedAsState()
 val isHovered = intSource.collectIsHoveredAsState()

 

 

Como desativar a interação composta child?

Suponha que você tenha uma interface de usuário complexa composta de vários elementos interativos e queira desativar as interações do usuário quando uma determinada condição for atendida, como quando os dados estiverem sendo carregados ou durante um estado específico.

A abordagem é consumir todos os eventos de ponteiro pelo composable pai ou superior, algo assim.

fun Modifier.gesturesDisabled(disabled: Boolean = true) =
    if (disabled) {
        pointerInput(Unit) {
            awaitPointerEventScope {
                while (true) {
                    awaitPointerEvent()
                        .changes
                        .forEach(PointerInputChange::consume)
                }
            }
        }
    } else {
        this
    }

 

 

Conclusão

Concluindo a Parte 2 de nossa exploração dos gestos no Jetpack Compose.

Nesta parte, fomos além do básico e exploramos como fazer coisas como lidar com muitos gestos ao mesmo tempo, criar efeitos de arremesso e acompanhar o que os usuários estão fazendo de forma mais manual. Também verificamos como observar o que os usuários estão fazendo sem assumir o controle, interrompendo a interação quando necessário e aguardando eventos específicos.