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

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

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

הנפשה של הגודל של רכיב קומפוזבילי

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

ב-Compose אפשר להנפיש את הגודל של רכיבים שניתנים להרכבה בכמה דרכים שונות. משתמשים ב-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)
    )
}

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

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

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

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

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

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

רכיב טקסט שאומר Hello, עם אנימציה של שינוי הגודל מקטן לגדול.
איור 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)
    )
}

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

המילים Hello Compose (שלום, כתיבה) מונפשות בצבעים ירוק וכחול
איור 10. דוגמה להנפשה של צבע הטקסט

כדי להנפיש את צבע הטקסט, משתמשים בביטוי ה-lambda‏ color בקוד הקומפוזבילי 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.

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

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

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

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) או בממשק Transition API. אם משתמשים בכמה פונקציות launch בהקשר של שגרת המשך (coroutine), ההנפשות מופעלות באותו הזמן:

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

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

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

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

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

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

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

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

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

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

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

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