Esempio di animazione avanzata: gesti

Ci sono diversi aspetti che dobbiamo prendere in considerazione quando lavoriamo con gli eventi touch e le animazioni rispetto a quando lavoriamo con le sole animazioni. Prima di tutto, potrebbe essere necessario interrompere un'animazione in corso quando iniziano gli eventi tocco, in quanto l'interazione dell'utente deve avere la massima priorità.

Nell'esempio che segue, utilizziamo un elemento Animatable per rappresentare la posizione di offset di un componente cerchio. Gli eventi touch vengono elaborati con il modificatore di pointerInput. Quando rileviamo un nuovo evento di tocco, chiamiamo animateTo per animare il valore di offset nella posizione di tocco. Un evento di tocco può verificarsi anche durante l'animazione e, in questo caso, animateTo interrompe l'animazione in corso e avvia l'animazione nella nuova posizione target mantenendo la velocità dell'animazione interrotta.

@Composable
fun Gesture() {
    val offset = remember { Animatable(Offset(0f, 0f), Offset.VectorConverter) }
    Box(
        modifier = Modifier
            .fillMaxSize()
            .pointerInput(Unit) {
                coroutineScope {
                    while (true) {
                        // Detect a tap event and obtain its position.
                        awaitPointerEventScope {
                            val position = awaitFirstDown().position

                            launch {
                                // Animate to the tap position.
                                offset.animateTo(position)
                            }
                        }
                    }
                }
            }
    ) {
        Circle(modifier = Modifier.offset { offset.value.toIntOffset() })
    }
}

private fun Offset.toIntOffset() = IntOffset(x.roundToInt(), y.roundToInt())

Un altro caso frequente è la sincronizzazione dei valori dell'animazione con i valori provenienti da eventi tocco, come il trascinamento. Nell'esempio riportato di seguito, vediamo "scorri per ignorare" implementato come Modifier (anziché utilizzare l'elemento componibile SwipeToDismiss). L'offset orizzontale dell'elemento è rappresentato come Animatable. Questa API ha una caratteristica utile nell'animazione dei gesti. Il suo valore può essere modificato dagli eventi tocco e dall'animazione. Quando riceviamo un evento di touchpoint, interrompiamo Animatable con il metodo stop in modo da intercettare qualsiasi animazione in corso.

Durante un evento di trascinamento, utilizziamo snapTo per aggiornare il valore Animatable con il valore calcolato dagli eventi tocco. Per lo scorrimento, Compose fornisce VelocityTracker per registrare eventi di trascinamento e calcolare la velocità. La velocità può essere fornita direttamente a animateDecay per l'animazione di scorrimento. Quando vogliamo riportare il valore di offset alla posizione originale, specifichiamo il valore di offset target di 0f con il metodo animateTo.

fun Modifier.swipeToDismiss(
    onDismissed: () -> Unit
): Modifier = composed {
    val offsetX = remember { Animatable(0f) }
    pointerInput(Unit) {
        // Used to calculate fling decay.
        val decay = splineBasedDecay<Float>(this)
        // Use suspend functions for touch events and the Animatable.
        coroutineScope {
            while (true) {
                val velocityTracker = VelocityTracker()
                // Stop any ongoing animation.
                offsetX.stop()
                awaitPointerEventScope {
                    // Detect a touch down event.
                    val pointerId = awaitFirstDown().id

                    horizontalDrag(pointerId) { change ->
                        // Update the animation value with touch events.
                        launch {
                            offsetX.snapTo(
                                offsetX.value + change.positionChange().x
                            )
                        }
                        velocityTracker.addPosition(
                            change.uptimeMillis,
                            change.position
                        )
                    }
                }
                // No longer receiving touch events. Prepare the animation.
                val velocity = velocityTracker.calculateVelocity().x
                val targetOffsetX = decay.calculateTargetValue(
                    offsetX.value,
                    velocity
                )
                // The animation stops when it reaches the bounds.
                offsetX.updateBounds(
                    lowerBound = -size.width.toFloat(),
                    upperBound = size.width.toFloat()
                )
                launch {
                    if (targetOffsetX.absoluteValue <= size.width) {
                        // Not enough velocity; Slide back.
                        offsetX.animateTo(
                            targetValue = 0f,
                            initialVelocity = velocity
                        )
                    } else {
                        // The element was swiped away.
                        offsetX.animateDecay(velocity, decay)
                        onDismissed()
                    }
                }
            }
        }
    }
        .offset { IntOffset(offsetX.value.roundToInt(), 0) }
}