מעברים בין רכיבים משותפים ב-Compose

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

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

איור 1. הדגמה של רכיב משותף ב-Jetsnack.

ב-Compose, יש כמה ממשקי API ברמה גבוהה שעוזרים ליצור רכיבים משותפים:

  • SharedTransitionLayout: פריסת השכבה החיצונית שנדרשת להטמעה של מעברים בין רכיבים משותפים. הוא מספק SharedTransitionScope. כדי להשתמש במאפייני השינוי של הרכיב המשותף, צריך להוסיף את הרכיבים הקומפוזביליים ל-SharedTransitionScope.
  • Modifier.sharedElement(): המגדיר שמסמן ל-SharedTransitionScope את הרכיב הניתן להרכבה שצריך להתאים לרכיב ניתן להרכבה אחר.
  • Modifier.sharedBounds(): משנה (modifier) שמסמן ל-SharedTransitionScope שצריך להשתמש בגבולות של הרכיב הזה כגבולות של הקונטיינר שבו אמור להתרחש המעבר. בניגוד ל-sharedElement(), ‏ sharedBounds() מיועד לתוכן שונה מבחינה ויזואלית.

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

שימוש בסיסי

בקטע הזה נבנה את המעבר הבא, מפריט קטן יותר ב'רשימה' לפריט גדול יותר עם פרטים:

איור 2. דוגמה בסיסית למעבר של רכיב משותף בין שני רכיבי Composable.

הדרך הכי טובה להשתמש ב-Modifier.sharedElement() היא בשילוב עם AnimatedContent,‏ AnimatedVisibility או NavHost, כי כך המעבר בין רכיבים מורכבים מנוהל אוטומטית.

נקודת ההתחלה היא AnimatedContent בסיסי קייםMainContent, שאפשר DetailsContent להרכיב לפני שמוסיפים רכיבים משותפים:

איור 3. התחלה של AnimatedContent בלי מעברים של רכיבים משותפים.

  1. כדי להוסיף אנימציה למעבר בין שני הפריסות של הרכיבים המשותפים, מקיפים את ה-composable‏ AnimatedContent ב-SharedTransitionLayout. ההיקפים מ-SharedTransitionLayout ומ-AnimatedContent מועברים אל MainContent ואל DetailsContent:

    var showDetails by remember {
        mutableStateOf(false)
    }
    SharedTransitionLayout {
        AnimatedContent(
            showDetails,
            label = "basic_transition"
        ) { targetState ->
            if (!targetState) {
                MainContent(
                    onShowDetails = {
                        showDetails = true
                    },
                    animatedVisibilityScope = this@AnimatedContent,
                    sharedTransitionScope = this@SharedTransitionLayout
                )
            } else {
                DetailsContent(
                    onBack = {
                        showDetails = false
                    },
                    animatedVisibilityScope = this@AnimatedContent,
                    sharedTransitionScope = this@SharedTransitionLayout
                )
            }
        }
    }

  2. מוסיפים את Modifier.sharedElement() לשרשרת המשנים המורכבים בשני הרכיבים המורכבים שתואמים. תצור אובייקט SharedContentState ותזכור אותו באמצעות rememberSharedContentState(). האובייקט SharedContentState מאחסן את המפתח הייחודי שקובע את הרכיבים המשותפים. מזינים מפתח ייחודי לזיהוי התוכן ומשתמשים ב-rememberSharedContentState() כדי שהפריט יישמר. ה-AnimatedContentScope מועבר למקש הצירוף, שמשמש לתיאום האנימציה.

    @Composable
    private fun MainContent(
        onShowDetails: () -> Unit,
        modifier: Modifier = Modifier,
        sharedTransitionScope: SharedTransitionScope,
        animatedVisibilityScope: AnimatedVisibilityScope
    ) {
        Row(
            // ...
        ) {
            with(sharedTransitionScope) {
                Image(
                    painter = painterResource(id = R.drawable.cupcake),
                    contentDescription = "Cupcake",
                    modifier = Modifier
                        .sharedElement(
                            rememberSharedContentState(key = "image"),
                            animatedVisibilityScope = animatedVisibilityScope
                        )
                        .size(100.dp)
                        .clip(CircleShape),
                    contentScale = ContentScale.Crop
                )
                // ...
            }
        }
    }
    
    @Composable
    private fun DetailsContent(
        modifier: Modifier = Modifier,
        onBack: () -> Unit,
        sharedTransitionScope: SharedTransitionScope,
        animatedVisibilityScope: AnimatedVisibilityScope
    ) {
        Column(
            // ...
        ) {
            with(sharedTransitionScope) {
                Image(
                    painter = painterResource(id = R.drawable.cupcake),
                    contentDescription = "Cupcake",
                    modifier = Modifier
                        .sharedElement(
                            rememberSharedContentState(key = "image"),
                            animatedVisibilityScope = animatedVisibilityScope
                        )
                        .size(200.dp)
                        .clip(CircleShape),
                    contentScale = ContentScale.Crop
                )
                // ...
            }
        }
    }

כדי לקבל מידע על התאמה של רכיב משותף, מחלצים את rememberSharedContentState() למשתנה ושולחים שאילתה ל-isMatchFound.

התוצאה היא האנימציה האוטומטית הבאה:

איור 4. דוגמה בסיסית למעבר של רכיב משותף בין שני רכיבי Composable.

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

גבולות משותפים לעומת רכיב משותף

הפונקציה Modifier.sharedBounds() דומה לפונקציה Modifier.sharedElement(). עם זאת, יש הבדלים בין שינויי ההתאמה:

  • sharedBounds() מיועד לתוכן ששונה מבחינה ויזואלית אבל צריך לחלוק את אותו אזור בין המצבים, ואילו sharedElement() מצפה שהתוכן יהיה זהה.
  • ב-sharedBounds(), התוכן שנכנס למסך ויוצא ממנו גלוי במהלך המעבר בין שני המצבים, ואילו ב-sharedElement() רק תוכן היעד מוצג בגבולות המשתנים. ל-Modifier.sharedBounds() יש פרמטרים של enter ו-exit לציון אופן המעבר בין התכנים, בדומה לאופן הפעולה של AnimatedContent.
  • התרחיש הנפוץ ביותר לשימוש ב-sharedBounds() הוא דפוס הטרנספורמציה של הקונטיינר, ואילו התרחיש לדוגמה לשימוש ב-sharedElement() הוא מעבר של רכיב מרכזי.
  • כשמשתמשים ב-composables של Text, עדיף להשתמש ב-sharedBounds() כדי לתמוך בשינויים בגופן, כמו מעבר בין נטוי למודגש או שינויים בצבע.

בדוגמה הקודמת, הוספת Modifier.sharedBounds() ל-Row ול-Column בשני התרחישים השונים תאפשר לנו לשתף את הגבולות של שניהם ולהפעיל את אנימציית המעבר, כך שהם יגדלו אחד בתוך השני:

@Composable
private fun MainContent(
    onShowDetails: () -> Unit,
    modifier: Modifier = Modifier,
    sharedTransitionScope: SharedTransitionScope,
    animatedVisibilityScope: AnimatedVisibilityScope
) {
    with(sharedTransitionScope) {
        Row(
            modifier = Modifier
                .padding(8.dp)
                .sharedBounds(
                    rememberSharedContentState(key = "bounds"),
                    animatedVisibilityScope = animatedVisibilityScope,
                    enter = fadeIn(),
                    exit = fadeOut(),
                    resizeMode = SharedTransitionScope.ResizeMode.ScaleToBounds()
                )
                // ...
        ) {
            // ...
        }
    }
}

@Composable
private fun DetailsContent(
    modifier: Modifier = Modifier,
    onBack: () -> Unit,
    sharedTransitionScope: SharedTransitionScope,
    animatedVisibilityScope: AnimatedVisibilityScope
) {
    with(sharedTransitionScope) {
        Column(
            modifier = Modifier
                .padding(top = 200.dp, start = 16.dp, end = 16.dp)
                .sharedBounds(
                    rememberSharedContentState(key = "bounds"),
                    animatedVisibilityScope = animatedVisibilityScope,
                    enter = fadeIn(),
                    exit = fadeOut(),
                    resizeMode = SharedTransitionScope.ResizeMode.ScaleToBounds()
                )
                // ...

        ) {
            // ...
        }
    }
}

איור 5. גבולות משותפים בין שני רכיבי Composable.

הסבר על היקפים

כדי להשתמש ב-Modifier.sharedElement(), צריך להוסיף את ה-composable ל-SharedTransitionScope. הרכיב הקומפוזבילי SharedTransitionLayout מספק את SharedTransitionScope. חשוב להציב את התג באותה נקודה ברמה העליונה בהיררכיית ממשק המשתמש שמכילה את הרכיבים שרוצים לשתף.

באופן כללי, גם את הקומפוזיציות צריך למקם בתוך AnimatedVisibilityScope. בדרך כלל, המידע הזה מסופק באמצעות AnimatedContent כדי לעבור בין רכיבים שאפשר להוסיף או כשמשתמשים ב-AnimatedVisibility ישירות, או באמצעות פונקציית הרכיב שאפשר להוסיף NavHost, אלא אם מנהלים את הנראות באופן ידני. כדי להשתמש בכמה היקפי הרשאה, צריך לשמור את היקפי ההרשאה הנדרשים ב-CompositionLocal, להשתמש במקבלים של הקשר ב-Kotlin או להעביר את היקפי ההרשאה כפרמטרים לפונקציות.

מומלץ להשתמש ב-CompositionLocals בתרחיש שבו יש לכם כמה היקפים למעקב, או היררכיה עם קינון עמוק. CompositionLocal מאפשר לבחור את ההיקפים המדויקים שרוצים לשמור ולהשתמש בהם. לעומת זאת, כשמשתמשים ב-context receivers, יכול להיות שפריסות אחרות בהיררכיה יבטלו בטעות את ההיקפים שסופקו. לדוגמה, אם יש לכם כמה תגי AnimatedContent מוטמעים אחד בתוך השני, יכול להיות שההיקפים יוחלפו.

val LocalNavAnimatedVisibilityScope = compositionLocalOf<AnimatedVisibilityScope?> { null }
val LocalSharedTransitionScope = compositionLocalOf<SharedTransitionScope?> { null }

@Composable
private fun SharedElementScope_CompositionLocal() {
    // An example of how to use composition locals to pass around the shared transition scope, far down your UI tree.
    // ...
    SharedTransitionLayout {
        CompositionLocalProvider(
            LocalSharedTransitionScope provides this
        ) {
            // This could also be your top-level NavHost as this provides an AnimatedContentScope
            AnimatedContent(state, label = "Top level AnimatedContent") { targetState ->
                CompositionLocalProvider(LocalNavAnimatedVisibilityScope provides this) {
                    // Now we can access the scopes in any nested composables as follows:
                    val sharedTransitionScope = LocalSharedTransitionScope.current
                        ?: throw IllegalStateException("No SharedElementScope found")
                    val animatedVisibilityScope = LocalNavAnimatedVisibilityScope.current
                        ?: throw IllegalStateException("No AnimatedVisibility found")
                }
                // ...
            }
        }
    }
}

לחלופין, אם ההיררכיה שלכם לא מוטמעת עמוק, אתם יכולים להעביר את ההיקפים כפרמטרים:

@Composable
fun MainContent(
    animatedVisibilityScope: AnimatedVisibilityScope,
    sharedTransitionScope: SharedTransitionScope
) {
}

@Composable
fun Details(
    animatedVisibilityScope: AnimatedVisibilityScope,
    sharedTransitionScope: SharedTransitionScope
) {
}

רכיבים משותפים עם AnimatedVisibility

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

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

var selectedSnack by remember { mutableStateOf<Snack?>(null) }

SharedTransitionLayout(modifier = Modifier.fillMaxSize()) {
    LazyColumn(
        // ...
    ) {
        items(listSnacks) { snack ->
            AnimatedVisibility(
                visible = snack != selectedSnack,
                enter = fadeIn() + scaleIn(),
                exit = fadeOut() + scaleOut(),
                modifier = Modifier.animateItem()
            ) {
                Box(
                    modifier = Modifier
                        .sharedBounds(
                            sharedContentState = rememberSharedContentState(key = "${snack.name}-bounds"),
                            // Using the scope provided by AnimatedVisibility
                            animatedVisibilityScope = this,
                            clipInOverlayDuringTransition = OverlayClip(shapeForSharedElement)
                        )
                        .background(Color.White, shapeForSharedElement)
                        .clip(shapeForSharedElement)
                ) {
                    SnackContents(
                        snack = snack,
                        modifier = Modifier.sharedElement(
                            sharedContentState = rememberSharedContentState(key = snack.name),
                            animatedVisibilityScope = this@AnimatedVisibility
                        ),
                        onClick = {
                            selectedSnack = snack
                        }
                    )
                }
            }
        }
    }
    // Contains matching AnimatedContent with sharedBounds modifiers.
    SnackEditDetails(
        snack = selectedSnack,
        onConfirmClick = {
            selectedSnack = null
        }
    )
}

איור 6. רכיבים משותפים עם AnimatedVisibility.

סדר ההרחבות

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

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

var selectFirst by remember { mutableStateOf(true) }
val key = remember { Any() }
SharedTransitionLayout(
    Modifier
        .fillMaxSize()
        .padding(10.dp)
        .clickable {
            selectFirst = !selectFirst
        }
) {
    AnimatedContent(targetState = selectFirst, label = "AnimatedContent") { targetState ->
        if (targetState) {
            Box(
                Modifier
                    .padding(12.dp)
                    .sharedBounds(
                        rememberSharedContentState(key = key),
                        animatedVisibilityScope = this@AnimatedContent
                    )
                    .border(2.dp, Color.Red)
            ) {
                Text(
                    "Hello",
                    fontSize = 20.sp
                )
            }
        } else {
            Box(
                Modifier
                    .offset(180.dp, 180.dp)
                    .sharedBounds(
                        rememberSharedContentState(
                            key = key,
                        ),
                        animatedVisibilityScope = this@AnimatedContent
                    )
                    .border(2.dp, Color.Red)
                    // This padding is placed after sharedBounds, but it doesn't match the
                    // other shared elements modifier order, resulting in visual jumps
                    .padding(12.dp)

            ) {
                Text(
                    "Hello",
                    fontSize = 36.sp
                )
            }
        }
    }
}

גבולות תואמים

גבולות לא תואמים: שימו לב שהאנימציה של האלמנט המשותף נראית קצת מוזרה כי היא צריכה לשנות את הגודל שלה לגבולות שגויים

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

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

יוצא מן הכלל הוא אם משתמשים ב- לאנימציה, או ב- בקומפוזיציה.resizeMode = ScaleToBounds()Modifier.skipToLookaheadSize() במקרה הזה, פונקציית Compose מציבה את רכיב הצאצא באמצעות האילוצים של היעד, ומשתמשת במקדם קנה מידה כדי להפעיל את האנימציה במקום לשנות את גודל הפריסה עצמה.

מפתחות ייחודיים

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

איור 7. תמונה שבה מוצג Jetsnack עם הערות לכל חלק בממשק המשתמש.

אפשר ליצור enum כדי לייצג את סוג הרכיב המשותף. בדוגמה הזו, כרטיס החטיף כולו יכול להופיע גם בכמה מקומות שונים במסך הבית, למשל בקטע 'פופולרי' ובקטע 'מומלץ'. אתם יכולים ליצור מפתח עם snackId, origin ("פופולרי" / "מומלץ") וtype של הרכיב המשותף שאתם רוצים לשתף:

data class SnackSharedElementKey(
    val snackId: Long,
    val origin: String,
    val type: SnackSharedElementType
)

enum class SnackSharedElementType {
    Bounds,
    Image,
    Title,
    Tagline,
    Background
}

@Composable
fun SharedElementUniqueKey() {
    // ...
            Box(
                modifier = Modifier
                    .sharedElement(
                        rememberSharedContentState(
                            key = SnackSharedElementKey(
                                snackId = 1,
                                origin = "latest",
                                type = SnackSharedElementType.Image
                            )
                        ),
                        animatedVisibilityScope = this@AnimatedVisibility
                    )
            )
            // ...
}

מומלץ להשתמש במחלקות נתונים למפתחות כי הן מיישמות את hashCode() וisEquals().

ניהול החשיפה של רכיבים משותפים באופן ידני

במקרים שבהם אתם לא משתמשים ב-AnimatedVisibility או ב-AnimatedContent, אתם יכולים לנהל בעצמכם את ההגדרה של מי יכול לראות את הרכיב המשותף. משתמשים ב-Modifier.sharedElementWithCallerManagedVisibility() ומספקים תנאי משלכם שקובע מתי פריט צריך להיות גלוי ומתי לא:

var selectFirst by remember { mutableStateOf(true) }
val key = remember { Any() }
SharedTransitionLayout(
    Modifier
        .fillMaxSize()
        .padding(10.dp)
        .clickable {
            selectFirst = !selectFirst
        }
) {
    Box(
        Modifier
            .sharedElementWithCallerManagedVisibility(
                rememberSharedContentState(key = key),
                !selectFirst
            )
            .background(Color.Red)
            .size(100.dp)
    ) {
        Text(if (!selectFirst) "false" else "true", color = Color.White)
    }
    Box(
        Modifier
            .offset(180.dp, 180.dp)
            .sharedElementWithCallerManagedVisibility(
                rememberSharedContentState(
                    key = key,
                ),
                selectFirst
            )
            .alpha(0.5f)
            .background(Color.Blue)
            .size(180.dp)
    ) {
        Text(if (selectFirst) "false" else "true", color = Color.White)
    }
}

מגבלות נוכחיות

יש כמה מגבלות לשימוש בממשקי ה-API האלה. בעיקר:

  • אין תמיכה בהפעלה הדדית בין תצוגות לבין כתיבה. האיסור חל גם על רכיבים שאפשר להוסיף להם רכיבים אחרים ועוטפים את AndroidView, כמו Dialog או ModalBottomSheet.
  • אין תמיכה באנימציה אוטומטית במקרים הבאים:
    • רכיבי Shared Image composable:
      • כברירת מחדל, ContentScale לא מונפש. הוא נצמד לסוף המוגדר ContentScale.
    • חיתוך לפי צורה – אין תמיכה מובנית באנימציה אוטומטית בין צורות – לדוגמה, אנימציה מריבוע לעיגול בזמן המעבר בין הפריטים.
    • במקרים שלא נתמכים, צריך להשתמש ב-Modifier.sharedBounds() במקום ב-sharedElement() ולהוסיף את Modifier.animateEnterExit() לפריטים.