דוגמה לאנימציה מתקדמת: תנועות

יש כמה דברים שצריך להביא בחשבון כשעובדים עם אירועי מגע ואנימציות, בהשוואה לעבודה עם אנימציות בלבד. קודם כול, יכול להיות שנצטרך להפריע לאנימציה שפועלת כרגע כשאירועי מגע מתחילים, כי לאינטראקציה של המשתמש צריכה להיות העדיפות הגבוהה ביותר.

בדוגמה הבאה, אנחנו משתמשים ב-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 הזה יש מאפיין שמועיל באנימציה של תנועות. אפשר לשנות את הערך שלו באמצעות אירועי מגע וגם באמצעות אנימציה. כשאנחנו מקבלים אירוע של נגיעה, אנחנו עוצרים את ה-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) }
}