고급 애니메이션의 예: 동작

터치 이벤트와 애니메이션을 사용할 때는 애니메이션만 사용할 때에 비해 여러 가지 사항을 고려해야 합니다. 무엇보다도 사용자 상호작용의 우선순위가 가장 높아야 하므로 터치 이벤트가 시작될 때 진행 중인 애니메이션을 중단해야 할 수도 있습니다.

아래 예에서는 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())

또 하나의 빈번한 패턴은 애니메이션 값을 드래그와 같은 터치 이벤트에서 발생하는 값과 동기화해야 한다는 것입니다. 아래 예에서는 '스와이프하여 닫기'가 SwipeToDismiss 컴포저블을 사용하는 대신 Modifier로 구현됩니다. 요소의 가로 오프셋은 Animatable로 표시됩니다. 이 API에는 동작 애니메이션에 유용한 특성이 있습니다. 특성의 값은 애니메이션은 물론 터치 이벤트에서도 변경할 수 있습니다. 터치 다운 이벤트가 수신되면 Animatablestop 메서드를 사용하여 정지되고 진행 중인 애니메이션이 중단됩니다.

드래그 이벤트 중에 snapTo를 사용하여 Animatable 값을 터치 이벤트에서 계산된 값으로 업데이트합니다. 플링의 경우 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) }
}