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

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

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

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

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

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

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

שימוש בסיסי

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

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

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

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

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

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

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

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