Esempio di animazione avanzata: gesti

Ci sono diversi aspetti da tenere in considerazione quando lavoriamo con eventi touch e animazioni, rispetto a quando lavoriamo da sole. Prima di tutto, potremmo dover interrompere un'animazione in corso quando iniziano gli eventi di tocco come interazione dell'utente dovrebbe avere la massima priorità.

Nell'esempio riportato di seguito, utilizziamo un Animatable per rappresentare la posizione di offset di un componente cerchio. Gli eventi tocco vengono elaborati con il modificatore pointerInput. Quando rileviamo un nuovo evento di tocco, chiamiamo animateTo per animare il valore dell'offset in base alla posizione del tocco. Durante l'animazione può verificarsi un evento di tocco e in questo caso animateTo interrompe l'animazione in corso e avvia l'animazione nella nuova posizione target mantenendo la velocità del l'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 pattern frequente è la necessità di sincronizzare i valori dell'animazione con quelli provenienti dagli eventi touch, come il trascinamento. Nell'esempio riportato di seguito, vediamo l'implementazione di "scorri per chiudere" come Modifier (anziché utilizzare il composable SwipeToDismiss). L'offset orizzontale dell'elemento è rappresentato da un Animatable. Questa API ha una caratteristica utile nell'animazione dei gesti. Il suo valore può essere modificato dagli eventi touch e dall'animazione. Quando riceviamo atterraggio, interrompiamo Animatable con il metodo stop in modo che l'animazione in corso viene intercettata.

Durante un evento di trascinamento, utilizziamo snapTo per aggiornare il valore Animatable con calcolato dagli eventi touch. Per il movimento brusco, Compose fornisce VelocityTracker per registrare gli eventi di trascinamento e calcolare la velocità. La velocità può da inviare direttamente a animateDecay per l'animazione flotta. Quando vogliamo far scorrere nuovamente il valore dell'offset nella posizione originale, specifichiamo il valore dell'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) }
}