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

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

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

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

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

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

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

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

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

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

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

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

דוגמאות נוספות לאנימציות משעשעות חדשניות ב'כתיבה':