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

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

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

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

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

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

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

לדוגמה, אם תמקמו משתנה 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
                )
            }
        }
    }
}

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

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

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

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

החריג לכך הוא אם משתמשים ב-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() ו-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 האלה יש כמה מגבלות. במיוחד:

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