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

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

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

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

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

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

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

שימוש בסיסי

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

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

הדרך הכי טובה להשתמש ב-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() לשרשרת המשנים של ה-composable בשני ה-composables התואמים. יוצרים אובייקט SharedContentState וזוכרים אותו באמצעות rememberSharedContentState(). האובייקט SharedContentState מאחסן את המפתח הייחודי שקובע אילו רכיבים משותפים. צריך לספק מפתח ייחודי לזיהוי התוכן, ולהשתמש ב-rememberSharedContentState() כדי שהפריט יישמר בזיכרון. הערך של AnimatedContentScope מועבר ל-modifier, שמשמש לתיאום האנימציה.

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

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

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

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

  • ‫sharedBounds() מיועד לתוכן שונה מבחינה חזותית, אבל צריך לחלוק את אותו אזור בין המצבים, ואילו sharedElement() מצפה שהתוכן יהיה זהה.
  • ב-sharedBounds(), התוכן שנכנס למסך ויוצא ממנו גלוי במהלך המעבר בין שני המצבים, ואילו ב-sharedElement() רק תוכן היעד מוצג בגבולות השינוי. ל-Modifier.sharedBounds() יש פרמטרים של enter ו-exit כדי לציין איך התוכן צריך לעבור, בדומה לאופן שבו AnimatedContent פועל.
  • תרחיש השימוש הנפוץ ביותר ל-sharedBounds() הוא תבנית שינוי הצורה של מאגר, ואילו ל-sharedElement() תרחיש השימוש לדוגמה הוא מעבר של רכיב ראשי.
  • כשמשתמשים ברכיבים קומפוזביליים של 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. גבולות משותפים בין שני רכיבים קומפוזביליים.

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

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

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

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

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

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

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

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

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

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

החריג היחיד הוא אם משתמשים ב-resizeMode = ScaleToBounds() לאנימציה, או ב-Modifier.skipToLookaheadSize() ב-composable. במקרה כזה, 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() ו-equals().

ניהול ידני של הרכיבים המשותפים שמוצגים

אם אתם לא משתמשים ב-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 האלה יש כמה מגבלות. השינויים הבולטים ביותר:

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