進階動畫範例:手勢

與單獨處理動畫相比,同時處理觸控事件和動畫時,必須考慮幾個事項。首先,在觸控事件開始時,我們可能必須中斷處理中的動畫,因為使用者互動事件應該擁有最高的優先順序。

在以下範例中,我們使用 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 (而不是使用 SwipeToDismiss 可組合項) 實作的「滑動關閉」動作。該元素的水平位移會以 Animatable 表示。這個 API 的特性適合在手勢動畫中使用。觸控事件和動畫都可以變更這個值。接收到觸控事件時,我們會透過 stop 方法停止 Animatable,以便攔截任何執行中的動畫。

在拖曳事件期間,我們會使用 snapToAnimatable 值更新為從觸控事件計算得出的值。針對快速滑過,Compose 可提供 VelocityTracker 來記錄拖曳事件並計算速率。該速率可直接動態饋給至 animateDecay 來執行快速滑過動畫。如要將位移值滑回原始位置,可使用 animateTo 方法指定 0f 的目標位移值。

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) }
}