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