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