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

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

AnimatedContent
בלי מעברים של רכיבים משותפים.כדי להוסיף אנימציה לאלמנטים המשותפים בין שני הפריסות, מקיפים את ה-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 ) } } }
מוסיפים את
Modifier.sharedElement()
לשרשרת של משנים שאפשר להרכיב בשני רכיבי ה-Composable התואמים. יוצרים אובייקט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
.
התוצאה היא האנימציה האוטומטית הבאה:

יכול להיות שתשימו לב שצבע הרקע והגודל של כל המסגרת עדיין מוגדרים לפי הגדרות ברירת המחדל 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() ) // ... ) { // ... } } }
הסבר על היקפים
כדי להשתמש ב-Modifier.sharedElement()
, רכיב ה-Composable צריך להיות ב-SharedTransitionScope
. הקומפוזבל SharedTransitionLayout
מספק את SharedTransitionScope
. חשוב להקפיד למקם את התג באותה נקודה ברמה העליונה בהיררכיית ממשק המשתמש שמכילה את הרכיבים שרוצים לשתף.
באופן כללי, גם את הקומפוזיציות צריך למקם בתוך AnimatedVisibilityScope
. הערך הזה מסופק בדרך כלל באמצעות AnimatedContent
כדי לעבור בין רכיבי composable או כשמשתמשים ישירות ב-AnimatedVisibility
, או באמצעות
פונקציית ה-composable 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 } ) }
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 יש את הרכיבים המשותפים הבאים:

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