אנימציות מבוססות-ערך

בדף הזה מוסבר איך ליצור אנימציות שמבוססות על ערכים ב-Jetpack Compose, תוך התמקדות בממשקי API שיוצרים אנימציה של ערכים על סמך המצבים הנוכחיים ומצבי היעד שלהם.

הנפשה של ערך יחיד באמצעות animate*AsState

הפונקציות animate*AsState הן ממשקי API פשוטים של אנימציה ב-Compose, שמאפשרים להוסיף אנימציה לערך יחיד. אתם מספקים רק את ערך היעד (או ערך הסיום), וה-API מתחיל את האנימציה מהערך הנוכחי לערך שצוין.

בדוגמה הבאה מוצגת אנימציה של אלפא באמצעות ה-API הזה. כשעוטפים את ערך היעד ב-animateFloatAsState, ערך האלפא הוא עכשיו ערך אנימציה בין הערכים שסופקו (1f או 0.5f במקרה הזה).

var enabled by remember { mutableStateOf(true) }

val animatedAlpha: Float by animateFloatAsState(if (enabled) 1f else 0.5f, label = "alpha")
Box(
    Modifier
        .fillMaxSize()
        .graphicsLayer { alpha = animatedAlpha }
        .background(Color.Red)
)

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

כברירת מחדל, Compose מספק פונקציות animate*AsState עבור Float,‏ Color,‏ Dp,‏ Size,‏ Offset,‏ Rect,‏ Int,‏ IntOffset ו-IntSize. כדי להוסיף תמיכה בסוגי נתונים אחרים, אפשר לספק TwoWayConverter ל-animateValueAsState שמקבל סוג גנרי.

כדי להתאים אישית את מפרט האנימציה, אפשר לספק AnimationSpec. מידע נוסף זמין בכתובת AnimationSpec.

הנפשה של כמה מאפיינים בו-זמנית באמצעות מעבר

‫Transition מנהל אנימציה אחת או יותר כצאצאים שלו ומריץ אותן בו-זמנית בין כמה מצבים.

המצבים יכולים להיות מכל סוג נתונים. במקרים רבים, אפשר להשתמש בסוג enum בהתאמה אישית כדי לוודא מניעת שגיאות הקלדה, כמו בדוגמה הזו:

enum class BoxState {
    Collapsed,
    Expanded
}

‫updateTransition יוצר וזוכר מופע של Transition ומעדכן את המצב שלו.

var currentState by remember { mutableStateOf(BoxState.Collapsed) }
val transition = updateTransition(currentState, label = "box state")

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

val rect by transition.animateRect(label = "rectangle") { state ->
    when (state) {
        BoxState.Collapsed -> Rect(0f, 0f, 100f, 100f)
        BoxState.Expanded -> Rect(100f, 100f, 300f, 300f)
    }
}
val borderWidth by transition.animateDp(label = "border width") { state ->
    when (state) {
        BoxState.Collapsed -> 1.dp
        BoxState.Expanded -> 0.dp
    }
}

אפשר גם להעביר פרמטר transitionSpec כדי לציין AnimationSpec שונה לכל אחד מהשילובים של שינויי מצב המעבר. מידע נוסף זמין במאמר AnimationSpec.

val color by transition.animateColor(
    transitionSpec = {
        when {
            BoxState.Expanded isTransitioningTo BoxState.Collapsed ->
                spring(stiffness = 50f)

            else ->
                tween(durationMillis = 500)
        }
    }, label = "color"
) { state ->
    when (state) {
        BoxState.Collapsed -> MaterialTheme.colorScheme.primary
        BoxState.Expanded -> MaterialTheme.colorScheme.background
    }
}

אחרי שהמעבר הגיע למצב היעד, Transition.currentState זהה ל-Transition.targetState. אפשר להשתמש בזה כאינדיקטור לכך שהמעבר הסתיים.

לפעמים תרצו שהמצב הראשוני יהיה שונה ממצב היעד הראשון. כדי לעשות את זה, אפשר להשתמש ב-updateTransition עם MutableTransitionState. לדוגמה, אפשר להפעיל אנימציה ברגע שהקוד נכנס לקומפוזיציה.

// Start in collapsed state and immediately animate to expanded
var currentState = remember { MutableTransitionState(BoxState.Collapsed) }
currentState.targetState = BoxState.Expanded
val transition = rememberTransition(currentState, label = "box state")
// ……

למעבר מורכב יותר שכולל כמה פונקציות קומפוזביליות, אפשר להשתמש ב-createChildTransition כדי ליצור מעבר צאצא. השיטה הזו שימושית להפרדת תחומי האחריות בין כמה רכיבי משנה ברכיב Composable מורכב. המעבר הראשי מודע לכל ערכי האנימציה במעברי הצאצא.

enum class DialerState { DialerMinimized, NumberPad }

@Composable
fun DialerButton(isVisibleTransition: Transition<Boolean>) {
    // `isVisibleTransition` spares the need for the content to know
    // about other DialerStates. Instead, the content can focus on
    // animating the state change between visible and not visible.
}

@Composable
fun NumberPad(isVisibleTransition: Transition<Boolean>) {
    // `isVisibleTransition` spares the need for the content to know
    // about other DialerStates. Instead, the content can focus on
    // animating the state change between visible and not visible.
}

@Composable
fun Dialer(dialerState: DialerState) {
    val transition = updateTransition(dialerState, label = "dialer state")
    Box {
        // Creates separate child transitions of Boolean type for NumberPad
        // and DialerButton for any content animation between visible and
        // not visible
        NumberPad(
            transition.createChildTransition {
                it == DialerState.NumberPad
            }
        )
        DialerButton(
            transition.createChildTransition {
                it == DialerState.DialerMinimized
            }
        )
    }
}

שימוש במעבר עם AnimatedVisibility ו-AnimatedContent

הפונקציות AnimatedVisibility ו-AnimatedContent זמינות כפונקציות של התוסף Transition. ה-targetState של Transition.AnimatedVisibility ו-Transition.AnimatedContent נגזר מה-Transition, ומפעיל אנימציות של כניסה, יציאה ו-sizeTransform לפי הצורך כש-targetState של Transition משתנה. פונקציות התוספים האלה מאפשרות להעביר את כל האנימציות של הכניסה, היציאה ו-sizeTransform, שאחרת היו פנימיות ל-AnimatedVisibility/AnimatedContent, אל Transition. בעזרת פונקציות ההרחבה האלה, אפשר לצפות בשינוי המצב של AnimatedVisibility/AnimatedContent מבחוץ. במקום פרמטר בוליאני visible, הגרסה הזו של AnimatedVisibility מקבלת פונקציית למבדה שממירה את מצב היעד של המעבר הראשי לערך בוליאני.

פרטים נוספים זמינים במאמרים AnimatedVisibility ו-AnimatedContent.

var selected by remember { mutableStateOf(false) }
// Animates changes when `selected` is changed.
val transition = updateTransition(selected, label = "selected state")
val borderColor by transition.animateColor(label = "border color") { isSelected ->
    if (isSelected) Color.Magenta else Color.White
}
val elevation by transition.animateDp(label = "elevation") { isSelected ->
    if (isSelected) 10.dp else 2.dp
}
Surface(
    onClick = { selected = !selected },
    shape = RoundedCornerShape(8.dp),
    border = BorderStroke(2.dp, borderColor),
    shadowElevation = elevation
) {
    Column(
        modifier = Modifier
            .fillMaxWidth()
            .padding(16.dp)
    ) {
        Text(text = "Hello, world!")
        // AnimatedVisibility as a part of the transition.
        transition.AnimatedVisibility(
            visible = { targetSelected -> targetSelected },
            enter = expandVertically(),
            exit = shrinkVertically()
        ) {
            Text(text = "It is fine today.")
        }
        // AnimatedContent as a part of the transition.
        transition.AnimatedContent { targetState ->
            if (targetState) {
                Text(text = "Selected")
            } else {
                Icon(imageVector = Icons.Default.Phone, contentDescription = "Phone")
            }
        }
    }
}

אנקפסולציה של מעבר והפיכתו לניתן לשימוש חוזר

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

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

enum class BoxState { Collapsed, Expanded }

@Composable
fun AnimatingBox(boxState: BoxState) {
    val transitionData = updateTransitionData(boxState)
    // UI tree
    Box(
        modifier = Modifier
            .background(transitionData.color)
            .size(transitionData.size)
    )
}

// Holds the animation values.
private class TransitionData(
    color: State<Color>,
    size: State<Dp>
) {
    val color by color
    val size by size
}

// Create a Transition and return its animation values.
@Composable
private fun updateTransitionData(boxState: BoxState): TransitionData {
    val transition = updateTransition(boxState, label = "box state")
    val color = transition.animateColor(label = "color") { state ->
        when (state) {
            BoxState.Collapsed -> Color.Gray
            BoxState.Expanded -> Color.Red
        }
    }
    val size = transition.animateDp(label = "size") { state ->
        when (state) {
            BoxState.Collapsed -> 64.dp
            BoxState.Expanded -> 128.dp
        }
    }
    return remember(transition) { TransitionData(color, size) }
}

צור אנימציה שחוזרת על עצמה בלי סוף עם rememberInfiniteTransition

‫InfiniteTransition מכיל אנימציה אחת או יותר של צאצאים כמו Transition, אבל האנימציות מתחילות לפעול ברגע שהן נכנסות לקומפוזיציה, ולא מפסיקות אלא אם מסירים אותן. אפשר ליצור מופע של InfiniteTransition באמצעות rememberInfiniteTransition, ולהוסיף אנימציות צאצא באמצעות animateColor, animateFloat או animateValue. צריך גם לציין infiniteRepeatable כדי להגדיר את האנימציה.

val infiniteTransition = rememberInfiniteTransition(label = "infinite")
val color by infiniteTransition.animateColor(
    initialValue = Color.Red,
    targetValue = Color.Green,
    animationSpec = infiniteRepeatable(
        animation = tween(1000, easing = LinearEasing),
        repeatMode = RepeatMode.Reverse
    ),
    label = "color"
)

Box(
    Modifier
        .fillMaxSize()
        .background(color)
)

ממשקי API לאנימציה ברמה נמוכה

כל ממשקי ה-API של האנימציה ברמה גבוהה שמוזכרים בקטע הקודם מבוססים על ממשקי ה-API של האנימציה ברמה נמוכה.

הפונקציות של animate*AsState הן ממשקי API פשוטים שמעבירים שינוי מיידי בערך כערך של אנימציה. הפונקציונליות הזו מגובה על ידי Animatable, ממשק API שמבוסס על קורוטינה להנפשה של ערך יחיד.

updateTransition יוצר אובייקט מעבר שיכול לנהל כמה ערכי אנימציה ולהפעיל אותם כשמצב משתנה. rememberInfiniteTransition דומה, אבל יוצר מעבר אינסופי שיכול לנהל כמה אנימציות שנמשכות ללא הגבלת זמן. כל ממשקי ה-API האלה הם פונקציות קומפוזביליות, חוץ מ-Animatable, מה שאומר שאפשר ליצור את האנימציות האלה מחוץ לקומפוזיציה.

כל ממשקי ה-API האלה מבוססים על ה-API הבסיסי יותר, Animation. רוב האפליקציות לא יתקשרו ישירות עם Animation, אבל אפשר לגשת לחלק מיכולות ההתאמה האישית שלו דרך ממשקי API ברמה גבוהה יותר. מידע נוסף על AnimationVector ו-AnimationSpec זמין במאמר התאמה אישית של אנימציות.

הקשר בין ממשקי Animation API ברמה נמוכה
איור 1. הקשר בין ממשקי API של אנימציה ברמה נמוכה.

‫Animatable: אנימציה של ערך יחיד שמבוססת על Coroutine

‫Animatable הוא מאגר ערכים שיכול להוסיף אנימציה לערך כשהוא משתנה באמצעות animateTo. זה ה-API שמגבה את ההטמעה של animate*AsState. היא מבטיחה המשכיות עקבית ובלעדיות הדדית, כלומר השינוי בערך תמיד רציף ו-Compose מבטל כל אנימציה שפועלת.

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

// Start out gray and animate to green/red based on `ok`
val color = remember { Animatable(Color.Gray) }
LaunchedEffect(ok) {
    color.animateTo(if (ok) Color.Green else Color.Red)
}
Box(
    Modifier
        .fillMaxSize()
        .background(color.value)
)

בדוגמה הקודמת, אתם יוצרים וזוכרים מופע של Animatable עם הערך הראשוני של Color.Gray. בהתאם לערך של הדגל הבוליאני ok, הצבע משתנה ל-Color.Green או ל-Color.Red. כל שינוי בערך הבוליאני יפעיל אנימציה לצבע השני. אם אנימציה פועלת כשהערך משתנה, Compose מבטל את האנימציה, והאנימציה החדשה מתחילה מערך תמונת המצב הנוכחי במהירות הנוכחית.

ה-API הזה של Animatable הוא ההטמעה הבסיסית של animate*AsState שהוזכר בקטע הקודם. שימוש ישיר ב-Animatable מאפשר שליטה מדויקת יותר בכמה דרכים:

  • קודם כול, הערך הראשוני של Animatable יכול להיות שונה מערך היעד הראשון שלו. לדוגמה, בקוד שלמעלה מוצגת בהתחלה תיבה אפורה, שמיד הופכת לירוקה או לאדומה.
  • שנית, Animatable מספק יותר פעולות על ערך התוכן, במיוחד snapTo ו-animateDecay.
    • ‫snapTo מגדיר את הערך הנוכחי לערך היעד באופן מיידי. האפשרות הזו שימושית כשהאנימציה היא לא המקור האמין היחיד וצריך לסנכרן אותה עם מצבים אחרים, כמו אירועי מגע.
    • ‫animateDecay מפעיל אנימציה שמאטה מהמהירות שצוינה. האפשרות הזו שימושית להטמעת התנהגות של תנועת החלקה מהירה.

מידע נוסף זמין במאמר תנועות ואנימציה.

כברירת מחדל, Animatable תומך ב-Float וב-Color, אבל אפשר להשתמש בכל סוג נתונים על ידי ציון TwoWayConverter. מידע נוסף זמין במאמר בנושא AnimationVector.

אפשר להתאים אישית את מפרט האנימציה באמצעות AnimationSpec. מידע נוסף זמין בכתובת AnimationSpec.

‫Animation: אנימציה בשליטה ידנית

‫Animation הוא ה-Animation API ברמה הנמוכה ביותר שזמין. הרבה מהאנימציות שראינו עד עכשיו מבוססות על Animation. יש שני סוגי משנה של Animation: TargetBasedAnimation ו-DecayAnimation.

משתמשים ב-Animation רק כדי לשלוט באופן ידני בזמן של האנימציה. ‫Animation הוא חסר מצב (stateless), ואין לו מושג של מחזור חיים. הוא משמש כמנוע חישוב של אנימציה לממשקי API ברמה גבוהה יותר.

TargetBasedAnimation

ממשקי API אחרים מכסים את רוב התרחישים לדוגמה, אבל שימוש ישיר ב-TargetBasedAnimation מאפשר לכם לשלוט בזמן ההפעלה של האנימציה. בדוגמה הבאה, אתם שולטים באופן ידני בזמן ההפעלה של TargetAnimation על סמך זמן רינדור הפריים שסופק על ידי withFrameNanos.

val anim = remember {
    TargetBasedAnimation(
        animationSpec = tween(200),
        typeConverter = Float.VectorConverter,
        initialValue = 200f,
        targetValue = 1000f
    )
}
var playTime by remember { mutableLongStateOf(0L) }

LaunchedEffect(anim) {
    val startTime = withFrameNanos { it }

    do {
        playTime = withFrameNanos { it } - startTime
        val animationValue = anim.getValueFromNanos(playTime)
    } while (someCustomCondition())
}

DecayAnimation

בניגוד ל-TargetBasedAnimation, ב-DecayAnimation לא צריך לספק targetValue. במקום זאת, היא מחשבת את targetValue על סמך תנאי ההתחלה שנקבעו על ידי initialVelocity ו-initialValue ועל סמך DecayAnimationSpec שסופק.

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