מדריך מהיר לאנימציות בכתיבה

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

הנפשה של מאפיינים נפוצים של רכיבים שאפשר להרכיב

‫Compose מספקת ממשקי API נוחים שמאפשרים לפתור הרבה תרחישי שימוש נפוצים של אנימציה. בקטע הזה נראה איך אפשר להנפיש מאפיינים נפוצים של רכיב שאפשר להרכיב.

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

רכיב Green composable שמוצג ומוסתר
איור 1. אנימציה של הופעה והיעלמות של פריט בעמודה

משתמשים בAnimatedVisibility כדי להסתיר או להציג רכיב. ילדים בתוך AnimatedVisibility יכולים להשתמש ב-Modifier.animateEnterExit() למעבר משלהם לכניסה או ליציאה.

var visible by remember {
    mutableStateOf(true)
}
// Animated visibility will eventually remove the item from the composition once the animation has finished.
AnimatedVisibility(visible) {
    // your composable here
    // ...
}

הפרמטרים enter ו-exit של AnimatedVisibility מאפשרים להגדיר את ההתנהגות של קומפוזבל כשהוא מופיע ונעלם. מידע נוסף זמין במסמכי התיעוד המלאים.

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

var visible by remember {
    mutableStateOf(true)
}
val animatedAlpha by animateFloatAsState(
    targetValue = if (visible) 1.0f else 0f,
    label = "alpha"
)
Box(
    modifier = Modifier
        .size(200.dp)
        .graphicsLayer {
            alpha = animatedAlpha
        }
        .clip(RoundedCornerShape(8.dp))
        .background(colorGreen)
        .align(Alignment.TopCenter)
) {
}

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

הוספת אנימציה לאלפא של רכיב שאפשר להרכיב
איור 2. הנפשה של ערך האלפא של רכיב שאפשר להרכיב

הנפשת צבע הרקע

אפשר להוסיף לו אנימציה של שינוי צבע הרקע לאורך זמן, שבה הצבעים דוהים אחד לתוך השני.
איור 3. הנפשת צבע הרקע של קומפוזיציה

val animatedColor by animateColorAsState(
    if (animateBackgroundColor) colorGreen else colorBlue,
    label = "color"
)
Column(
    modifier = Modifier.drawBehind {
        drawRect(animatedColor)
    }
) {
    // your composable here
}

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

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

הנפשה של הגודל של רכיב Composable

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

ב-Compose אפשר להנפיש את הגודל של רכיבי Composable בכמה דרכים שונות. משתמשים ב-animateContentSize() לאנימציות בין שינויים בגודל של רכיבים שאפשר להרכיב.

לדוגמה, אם יש לכם תיבה שמכילה טקסט שיכול להתרחב משורה אחת לכמה שורות, אתם יכולים להשתמש ב-Modifier.animateContentSize() כדי ליצור מעבר חלק יותר:

var expanded by remember { mutableStateOf(false) }
Box(
    modifier = Modifier
        .background(colorBlue)
        .animateContentSize()
        .height(if (expanded) 400.dp else 200.dp)
        .fillMaxWidth()
        .clickable(
            interactionSource = remember { MutableInteractionSource() },
            indication = null
        ) {
            expanded = !expanded
        }

) {
}

אפשר גם להשתמש ב-AnimatedContent, עם SizeTransform כדי לתאר איך השינויים בגודל צריכים להתבצע.

הנפשת המיקום של רכיב שאפשר להרכיב

רכיב ירוק שניתן להרכבה, עם אנימציה חלקה של תנועה למטה וימינה
איור 5. הזזה של רכיב במיקום מסוים

כדי להנפיש את המיקום של רכיב שאפשר להרכיב, משתמשים ב-Modifier.offset{ } בשילוב עם animateIntOffsetAsState().

var moved by remember { mutableStateOf(false) }
val pxToMove = with(LocalDensity.current) {
    100.dp.toPx().roundToInt()
}
val offset by animateIntOffsetAsState(
    targetValue = if (moved) {
        IntOffset(pxToMove, pxToMove)
    } else {
        IntOffset.Zero
    },
    label = "offset"
)

Box(
    modifier = Modifier
        .offset {
            offset
        }
        .background(colorBlue)
        .size(100.dp)
        .clickable(
            interactionSource = remember { MutableInteractionSource() },
            indication = null
        ) {
            moved = !moved
        }
)

אם רוצים לוודא שרכיבי Composable לא יוצגו מעל או מתחת לרכיבי Composable אחרים במהלך אנימציה של מיקום או גודל, צריך להשתמש ב-Modifier.layout{ }. המשנה הזה מעביר שינויים בגודל ובמיקום אל רכיב האב, ואז הם משפיעים על רכיבי צאצא אחרים.

לדוגמה, אם מעבירים Box בתוך Column וצריך להעביר את שאר הילדים כשמעבירים את Box, צריך לכלול את פרטי ההיסט עם Modifier.layout{ } באופן הבא:

var toggled by remember {
    mutableStateOf(false)
}
val interactionSource = remember {
    MutableInteractionSource()
}
Column(
    modifier = Modifier
        .padding(16.dp)
        .fillMaxSize()
        .clickable(indication = null, interactionSource = interactionSource) {
            toggled = !toggled
        }
) {
    val offsetTarget = if (toggled) {
        IntOffset(150, 150)
    } else {
        IntOffset.Zero
    }
    val offset = animateIntOffsetAsState(
        targetValue = offsetTarget, label = "offset"
    )
    Box(
        modifier = Modifier
            .size(100.dp)
            .background(colorBlue)
    )
    Box(
        modifier = Modifier
            .layout { measurable, constraints ->
                val offsetValue = if (isLookingAhead) offsetTarget else offset.value
                val placeable = measurable.measure(constraints)
                layout(placeable.width + offsetValue.x, placeable.height + offsetValue.y) {
                    placeable.placeRelative(offsetValue)
                }
            }
            .size(100.dp)
            .background(colorGreen)
    )
    Box(
        modifier = Modifier
            .size(100.dp)
            .background(colorBlue)
    )
}

‫2 תיבות, כשהתיבה השנייה מונפשת במיקום X,Y שלה, והתיבה השלישית מגיבה על ידי הזזה של עצמה במיקום Y.
איור 6. יצירת אנימציה באמצעות Modifier.layout{ }

יצירת אנימציה של ריווח פנימי של קומפוזיציה

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

כדי להנפיש את הריווח הפנימי של רכיב שאפשר להרכיב, משתמשים ב-animateDpAsState בשילוב עם Modifier.padding():

var toggled by remember {
    mutableStateOf(false)
}
val animatedPadding by animateDpAsState(
    if (toggled) {
        0.dp
    } else {
        20.dp
    },
    label = "padding"
)
Box(
    modifier = Modifier
        .aspectRatio(1f)
        .fillMaxSize()
        .padding(animatedPadding)
        .background(Color(0xff53D9A1))
        .clickable(
            interactionSource = remember { MutableInteractionSource() },
            indication = null
        ) {
            toggled = !toggled
        }
)

הוספת אנימציה לשינוי הגובה של רכיב שאפשר להרכיב

איור 8. הגבהה של רכיב שאפשר להרכיב ממנו ממשק משתמש מונפשת בלחיצה

כדי להנפיש את הגובה של רכיב שאפשר להרכיב, משתמשים ב-animateDpAsState בשילוב עם Modifier.graphicsLayer{ }. לשינויי גובה חד-פעמיים, משתמשים בתג Modifier.shadow(). אם אתם יוצרים אנימציה לצל, השימוש בשינוי Modifier.graphicsLayer{ } הוא האפשרות עם הביצועים הטובים יותר.

val mutableInteractionSource = remember {
    MutableInteractionSource()
}
val pressed = mutableInteractionSource.collectIsPressedAsState()
val elevation = animateDpAsState(
    targetValue = if (pressed.value) {
        32.dp
    } else {
        8.dp
    },
    label = "elevation"
)
Box(
    modifier = Modifier
        .size(100.dp)
        .align(Alignment.Center)
        .graphicsLayer {
            this.shadowElevation = elevation.value.toPx()
        }
        .clickable(interactionSource = mutableInteractionSource, indication = null) {
        }
        .background(colorGreen)
) {
}

אפשר גם להשתמש ב-Card composable ולהגדיר את מאפיין הגובה לערכים שונים לכל מצב.

הוספת אנימציה לשינוי גודל, להזזה או לסיבוב של טקסט

רכיב שאפשר להוסיף לו טקסט
איור 9. אנימציה חלקה של טקסט בין שני גדלים

כשמנפישים שינוי גודל, תרגום או סיבוב של טקסט, מגדירים את הפרמטר textMotion ב-TextStyle לערך TextMotion.Animated. כך המעברים בין אנימציות הטקסט יהיו חלקים יותר. משתמשים ב-Modifier.graphicsLayer{ } כדי לתרגם, לסובב או לשנות את הגודל של הטקסט.

val infiniteTransition = rememberInfiniteTransition(label = "infinite transition")
val scale by infiniteTransition.animateFloat(
    initialValue = 1f,
    targetValue = 8f,
    animationSpec = infiniteRepeatable(tween(1000), RepeatMode.Reverse),
    label = "scale"
)
Box(modifier = Modifier.fillMaxSize()) {
    Text(
        text = "Hello",
        modifier = Modifier
            .graphicsLayer {
                scaleX = scale
                scaleY = scale
                transformOrigin = TransformOrigin.Center
            }
            .align(Alignment.Center),
        // Text composable does not take TextMotion as a parameter.
        // Provide it via style argument but make sure that we are copying from current theme
        style = LocalTextStyle.current.copy(textMotion = TextMotion.Animated)
    )
}

הוספת אנימציה לצבע הטקסט

המילים
איור 10. דוגמה להנפשה של צבע הטקסט

כדי להנפיש את צבע הטקסט, משתמשים ב-lambda‏ color ב-composable‏ BasicText:

val infiniteTransition = rememberInfiniteTransition(label = "infinite transition")
val animatedColor by infiniteTransition.animateColor(
    initialValue = Color(0xFF60DDAD),
    targetValue = Color(0xFF4285F4),
    animationSpec = infiniteRepeatable(tween(1000), RepeatMode.Reverse),
    label = "color"
)

BasicText(
    text = "Hello Compose",
    color = {
        animatedColor
    },
    // ...
)

מעבר בין סוגים שונים של תוכן

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

כדי ליצור אנימציה בין רכיבים שונים שאפשר להרכיב, משתמשים ב-AnimatedContent. אם רוצים רק ליצור מעבר רגיל בין רכיבים שאפשר להרכיב, משתמשים ב-Crossfade.

var state by remember {
    mutableStateOf(UiState.Loading)
}
AnimatedContent(
    state,
    transitionSpec = {
        fadeIn(
            animationSpec = tween(3000)
        ) togetherWith fadeOut(animationSpec = tween(3000))
    },
    modifier = Modifier.clickable(
        interactionSource = remember { MutableInteractionSource() },
        indication = null
    ) {
        state = when (state) {
            UiState.Loading -> UiState.Loaded
            UiState.Loaded -> UiState.Error
            UiState.Error -> UiState.Loading
        }
    },
    label = "Animated Content"
) { targetState ->
    when (targetState) {
        UiState.Loading -> {
            LoadingScreen()
        }
        UiState.Loaded -> {
            LoadedScreen()
        }
        UiState.Error -> {
            ErrorScreen()
        }
    }
}

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

אנימציה בזמן ניווט ליעדים שונים

שני קומפוזבילים, אחד ירוק עם הכיתוב 'דף נחיתה' ואחד כחול עם הכיתוב 'פרטים', שמונפשים על ידי החלקת הקומפוזביל של הפרטים מעל הקומפוזביל של דף הנחיתה.
איור 12. הנפשה של מעבר בין רכיבי Composable באמצעות navigation-compose

כדי להנפיש מעברים בין קומפוזיציות כשמשתמשים בארטיפקט navigation-compose, צריך לציין את enterTransition ואת exitTransition בקומפוזיציה. אפשר גם להגדיר את האנימציה שתשמש כברירת מחדל לכל היעדים ברמה העליונה NavHost:

val navController = rememberNavController()
NavHost(
    navController = navController, startDestination = "landing",
    enterTransition = { EnterTransition.None },
    exitTransition = { ExitTransition.None }
) {
    composable("landing") {
        ScreenLanding(
            // ...
        )
    }
    composable(
        "detail/{photoUrl}",
        arguments = listOf(navArgument("photoUrl") { type = NavType.StringType }),
        enterTransition = {
            fadeIn(
                animationSpec = tween(
                    300, easing = LinearEasing
                )
            ) + slideIntoContainer(
                animationSpec = tween(300, easing = EaseIn),
                towards = AnimatedContentTransitionScope.SlideDirection.Start
            )
        },
        exitTransition = {
            fadeOut(
                animationSpec = tween(
                    300, easing = LinearEasing
                )
            ) + slideOutOfContainer(
                animationSpec = tween(300, easing = EaseOut),
                towards = AnimatedContentTransitionScope.SlideDirection.End
            )
        }
    ) { backStackEntry ->
        ScreenDetails(
            // ...
        )
    }
}

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

חזרה על אנימציה

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

כדי שהאנימציה תחזור על עצמה ללא הפסקה, משתמשים ב-rememberInfiniteTransition עם infiniteRepeatable animationSpec. משנים את RepeatModes כדי לציין איך הוא צריך לנוע קדימה ואחורה.

משתמשים ב-finiteRepeatable כדי לחזור על פעולה מספר מסוים של פעמים.

val infiniteTransition = rememberInfiniteTransition(label = "infinite")
val color by infiniteTransition.animateColor(
    initialValue = Color.Green,
    targetValue = Color.Blue,
    animationSpec = infiniteRepeatable(
        animation = tween(1000, easing = LinearEasing),
        repeatMode = RepeatMode.Reverse
    ),
    label = "color"
)
Column(
    modifier = Modifier.drawBehind {
        drawRect(color)
    }
) {
    // your composable here
}

הפעלת אנימציה בהשקה של רכיב שאפשר להרכיב

LaunchedEffect פועל כשקומפוזבל נכנס לקומפוזיציה. הוא מתחיל אנימציה בהפעלה של קומפוזיציה, ואפשר להשתמש בו כדי להניע את שינוי מצב האנימציה. שימוש ב-Animatable עם ה-method‏ animateTo כדי להתחיל את האנימציה בהפעלה:

val alphaAnimation = remember {
    Animatable(0f)
}
LaunchedEffect(Unit) {
    alphaAnimation.animateTo(1f)
}
Box(
    modifier = Modifier.graphicsLayer {
        alpha = alphaAnimation.value
    }
)

יצירת אנימציות רציפות

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

משתמשים בממשקי ה-API של Animatable coroutine כדי להפעיל אנימציות רציפות או מקבילות. הפעלת animateTo ב-Animatable אחת אחרי השנייה גורמת לכל אנימציה להמתין עד שהאנימציות הקודמות יסתיימו לפני שהיא מתחילה . הסיבה לכך היא שזו פונקציית השעיה.

val alphaAnimation = remember { Animatable(0f) }
val yAnimation = remember { Animatable(0f) }

LaunchedEffect("animationKey") {
    alphaAnimation.animateTo(1f)
    yAnimation.animateTo(100f)
    yAnimation.animateTo(500f, animationSpec = tween(100))
}

יצירת אנימציות בו-זמניות

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

משתמשים בממשקי ה-API של קורוטינות (Animatable#animateTo() או animate) או ב-API‏ Transition כדי ליצור אנימציות מקבילות. אם משתמשים בכמה פונקציות launch בהקשר של קורוטינה, ההנפשות מופעלות באותו הזמן:

val alphaAnimation = remember { Animatable(0f) }
val yAnimation = remember { Animatable(0f) }

LaunchedEffect("animationKey") {
    launch {
        alphaAnimation.animateTo(1f)
    }
    launch {
        yAnimation.animateTo(100f)
    }
}

אתם יכולים להשתמש ב-API‏ updateTransition כדי להשתמש באותו מצב להפעלת הרבה אנימציות שונות של נכסים בו-זמנית. בדוגמה הבאה יש אנימציה של שני מאפיינים שנשלטים על ידי שינוי מצב, rect ו-borderWidth:

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

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

אופטימיזציה של ביצועי האנימציה

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

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

כדי לוודא שהאפליקציה תבצע כמה שפחות פעולות בזמן האנימציה, כדאי לבחור בגרסת ה-lambda של Modifier כשהדבר אפשרי. הפעולה הזו מדלגת על ההרכבה מחדש ומבצעת את האנימציה מחוץ לשלב ההרכבה. אחרת, צריך להשתמש ב-Modifier.graphicsLayer{ }, כי המגדיר הזה תמיד פועל בשלב הציור. מידע נוסף בנושא מופיע בקטע דחיית קריאות במסמכי התיעוד בנושא ביצועים.

שינוי התזמון של האנימציה

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

הנה סיכום של האפשרויות השונות של animationSpec:

  • spring: אנימציה שמבוססת על פיזיקה, ברירת המחדל לכל האנימציות. אפשר לשנות את הערכים של stiffness או dampingRatio כדי לשנות את המראה והתחושה של האנימציה.
  • tween (קיצור של between): אנימציה שמבוססת על משך זמן, יוצרת אנימציה בין שני ערכים באמצעות פונקציית Easing.
  • keyframes: מפרט להגדרת ערכים בנקודות מפתח מסוימות באנימציה.
  • repeatable: מפרט מבוסס-משך שמופעל מספר מסוים של פעמים, שמוגדר על ידי RepeatMode.
  • infiniteRepeatable: הגדרה מבוססת-משך שפועלת ללא הפסקה.
  • snap: מעבר מיידי לערך הסופי ללא אנימציה.
כאן כותבים את הטקסט החלופי
איור 16. אין קבוצת מפרטים לעומת קבוצת מפרטים מותאמת אישית של Spring

מידע נוסף על animationSpecs זמין במסמכי התיעוד המלאים.

מקורות מידע נוספים

דוגמאות נוספות לאנימציות ב-Compose: