Пример сложной анимации: жесты

При работе с событиями касания и анимацией необходимо учитывать несколько факторов, которые не имеют значения, если вы работаете только с анимацией. Прежде всего, нам может потребоваться прервать текущую анимацию, когда начинаются события касания, поскольку взаимодействие с пользователем должно иметь наивысший приоритет.

В следующем примере мы используем Animatable для представления позиции смещения компонента круга. События касания обрабатываются с помощью модификатора pointerInput. Когда мы обнаруживаем новое событие нажатия, мы вызываем animateTo, чтобы анимировать значение смещения до позиции нажатия. Событие нажатия может произойти и во время анимации. В этом случае animateTo прерывает текущую анимацию и начинает новую, перемещая объект в новую целевую позицию с той же скоростью.

@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())

Другой частый паттерн – необходимость синхронизировать значения анимации со значениями, поступающими от событий касания, например перетаскивания. В примере ниже показано, как реализовать жест смахивания для закрытия с помощью Modifier (а не composable-функции SwipeToDismiss). Горизонтальное смещение элемента представлено в виде Animatable. У этого API есть характеристика, полезная для анимации жестов. Его значение может меняться как в результате событий касания, так и в результате анимации. Когда мы получаем событие касания, мы останавливаем Animatable с помощью метода stop, чтобы прервать любую текущую анимацию.

Во время перетаскивания мы используем snapTo, чтобы обновить значение Animatable на основе значения, рассчитанного по событиям касания. Для броска Compose предоставляет VelocityTracker, чтобы записывать события перетаскивания и вычислять скорость. Скорость можно передать непосредственно в animateDecay для анимации броска. Чтобы вернуть смещение в исходное положение, мы указываем целевое значение 0f с помощью метода 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) }
}