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

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

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

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

בקטע 'כתיבה' יש כמה ממשקי API ברמה גבוהה שעוזרים ליצור רכיבים:

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

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

שימוש בסיסי

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

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

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

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