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

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

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

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

אנימציה של הופעה או היעלמות

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

משתמשים ב-AnimatedVisibility כדי להסתיר או להציג רכיב Composable. ילדים בקבוצה 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
    // ...
}

הפרמטרים של הכניסה והיציאה של 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 כדי לתאר איך השינויים בגודל צריכים להתרחש.

אנימציה של המיקום של רכיב ה-Composable

קומפוזיציה ירוקה עם אנימציה חלקה למטה ולימין
איור 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{ }

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

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

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

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

משפט שאפשר ליצור ממנו טקסט
איור 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 כדי להוסיף אנימציה למעבר בין רכיבים שונים של Composables. אם רוצים רק מעבר סטנדרטי בין רכיבים של Composables, משתמשים ב-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. אנימציה בין רכיבים מורכבים באמצעות navigation-compose

כדי להוסיף אנימציה למעברים בין רכיבי composable כשמשתמשים בארטיפקט navigation-compose, צריך לציין את הערכים enterTransition ו-exitTransition ברכיב composable. אפשר גם להגדיר את אנימציית ברירת המחדל שתהיה רלוונטית לכל היעדים ברמה העליונה 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 עם השיטה 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. אם משתמשים בכמה פונקציות הפעלה בהקשר של קורוטין, האנימציות יופעלו בו-זמנית:

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

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

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

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

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

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

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

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

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

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