מעברים של רכיבים משותפים הם דרך חלקה לעבור בין רכיבים קומפוזביליים שיש להם תוכן עקבי. לרוב משתמשים בהם לניווט, והם מאפשרים לחבר באופן חזותי בין מסכים שונים כשהמשתמש מנווט ביניהם.
לדוגמה, בסרטון הבא אפשר לראות שהתמונה והשם של החטיף משותפים מדף המוצר לדף הפרטים.
ב-Compose, יש כמה ממשקי API ברמה גבוהה שעוזרים ליצור רכיבים משותפים:
-
SharedTransitionLayout: הפריסה החיצונית ביותר שנדרשת כדי להטמיע מעברים של רכיבים משותפים. הוא מספקSharedTransitionScope. כדי להשתמש בשינויי הרכיבים המשותפים, הרכיבים הקומפוזביליים צריכים להיות ב-SharedTransitionScope. -
Modifier.sharedElement(): מגדיר השינוי שמסמן ל-SharedTransitionScopeאת הפונקציה הקומפוזבילית שצריך להתאים לפונקציה קומפוזבילית אחרת. -
Modifier.sharedBounds(): ה-modifier שמסמן ל-SharedTransitionScopeשצריך להשתמש בגבולות של ה-composable הזה כגבולות של הקונטיינר שבו המעבר אמור להתבצע. בניגוד ל-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 בשני ה-composables התואמים. יוצרים אובייקטSharedContentStateוזוכרים אותו באמצעותrememberSharedContentState(). האובייקטSharedContentStateמאחסן את המפתח הייחודי שקובע אילו רכיבים משותפים. צריך לספק מפתח ייחודי לזיהוי התוכן, ולהשתמש ב-rememberSharedContentState()כדי שהפריט יישמר בזיכרון. הערך שלAnimatedContentScopeמועבר ל-modifier, שמשמש לתיאום האנימציה.@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. הפונקציה הקומפוזבילית 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( 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(), הסדר של שרשרת ה-modifier חשוב, כמו בשאר חלקי 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 ) } } } }
גבולות תואמים |
גבולות לא תואמים: שימו לב שהאנימציה של הרכיב המשותף נראית קצת מוזרה כי צריך לשנות את הגודל שלה לגבולות השגויים |
|---|---|
המשתנים שמשתמשים בהם לפני המשתנים של הרכיב המשותף מספקים אילוצים למשתנים של הרכיב המשותף, שמשמשים לאחר מכן להפקת הגבולות הראשוניים וגבולות היעד, ובהמשך לאנימציית הגבולות.
המשתנים שמשתמשים בהם אחרי המשתנים של הרכיב המשותף משתמשים באילוצים מלפני כן כדי למדוד ולחשב את גודל היעד של הצאצא. המשתנים של הרכיב המשותף יוצרים סדרה של אילוצים מונפשים כדי לשנות בהדרגה את ה-child מהגודל הראשוני לגודל היעד.
החריג היחיד הוא אם משתמשים ב-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() ו-equals().
ניהול ידני של הרכיבים המשותפים שמוצגים
אם אתם לא משתמשים ב-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. זה כולל כל רכיב Composable שעוטף את
AndroidView, כמוDialogאוModalBottomSheet. - אין תמיכה באנימציה אוטומטית במקרים הבאים:
- רכיבי תמונה משותפים שניתנים ליצירה:
- כברירת מחדל,
ContentScaleלא מונפש. הוא נצמד לסוף שהוגדרContentScale.
- כברירת מחדל,
- חיתוך צורות – אין תמיכה מובנית באנימציה אוטומטית בין צורות, למשל אנימציה מריבוע לעיגול כשהפריט עובר בין מצבים.
- במקרים שלא נתמכים, צריך להשתמש ב-
Modifier.sharedBounds()במקום ב-sharedElement()ולהוסיףModifier.animateEnterExit()לפריטים.
- רכיבי תמונה משותפים שניתנים ליצירה: