Przejścia między elementami udostępnionymi to płynny sposób przechodzenia między komponentami, które mają spójną zawartość. Są one często używane do nawigacji, umożliwiając wizualne połączenie różnych ekranów podczas przechodzenia między nimi.
Na przykład w tym filmie widać, że obraz i tytuł przekąski są udostępniane ze strony z listą na stronę szczegółów.
W Compose jest kilka interfejsów API wysokiego poziomu, które pomagają tworzyć elementy udostępnione:
SharedTransitionLayout: najbardziej zewnętrzny układ wymagany do implementacji przejść między elementami udostępnionymi. Zapewnia onSharedTransitionScope. Aby używać modyfikatorów elementów udostępnionych, komponenty muszą znajdować się wSharedTransitionScope.Modifier.sharedElement(): modyfikator, który informujeSharedTransitionScope, że komponent powinien być dopasowany do innego komponentu.Modifier.sharedBounds(): modyfikator, który informujeSharedTransitionScope, że granice tego komponentu powinny być używane jako granice kontenera, w którym ma nastąpić przejście. W przeciwieństwie dosharedElement()modyfikatorsharedBounds()jest przeznaczony do treści, które różnią się wizualnie.
Ważną kwestią podczas tworzenia elementów udostępnionych w Compose jest to, jak działają one z nakładkami i przycinaniem. Więcej informacji na ten ważny temat znajdziesz w sekcji Przycinanie i nakładki.
Podstawowe użycie
W tej sekcji utworzymy przejście od mniejszego elementu „listy” do większego elementu szczegółowego:
Najlepszym sposobem użycia Modifier.sharedElement() jest połączenie go z
AnimatedContent, AnimatedVisibility lub NavHost, ponieważ automatycznie zarządza
on przejściem między komponentami.
Punktem początkowym jest istniejący podstawowy komponent AnimatedContent, który zawiera komponenty MainContent i DetailsContent przed dodaniem elementów udostępnionych:
AnimatedContent bez przejść między elementami udostępnionymi.Aby elementy udostępnione były animowane między 2 układami, otocz komponent
AnimatedContentkomponentemSharedTransitionLayout. Zakresy zSharedTransitionLayoutiAnimatedContentsą przekazywane doMainContentiDetailsContent: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 ) } } }
Dodaj
Modifier.sharedElement()do łańcucha modyfikatorów komponentu w 2 pasujących komponentach. Utwórz obiektSharedContentStatei zapamiętaj go za pomocąrememberSharedContentState(). ObiektSharedContentStateprzechowuje unikalny klucz, który określa elementy udostępnione. Podaj unikalny klucz identyfikujący treść i użyjrememberSharedContentState(), aby zapamiętać element. Do modyfikatora przekazywany jestAnimatedContentScope, który służy do koordynowania animacji.@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 ) // ... } } }
Aby uzyskać informacje o tym, czy doszło do dopasowania elementu udostępnionego, wyodrębnij rememberSharedContentState() do zmiennej i wyślij zapytanie isMatchFound.
Spowoduje to automatyczną animację:
Możesz zauważyć, że kolor tła i rozmiar całego kontenera nadal używają domyślnych ustawień AnimatedContent.
Granice udostępnione a element udostępniony
Modifier.sharedBounds() jest podobny do Modifier.sharedElement().
Modyfikatory różnią się jednak w tych kwestiach:
sharedBounds()jest przeznaczony do treści, które różnią się wizualnie, ale powinny mieć ten sam obszar między stanami, natomiastsharedElement()oczekuje, że treść będzie taka sama.- W przypadku
sharedBounds()treść wchodząca i wychodząca z ekranu jest widoczna podczas przejścia między 2 stanami, natomiast w przypadkusharedElement()w przekształcanych granicach renderowana jest tylko treść docelowa.Modifier.sharedBounds()ma parametryenteriexit, które określają, jak treść ma się zmieniać, podobnie jak w przypadkuAnimatedContent. - Najczęstszym przypadkiem użycia
sharedBounds()jest wzorzec przekształcenia kontenera, natomiast w przypadkusharedElement()przykładem jest przejście główne. - W przypadku używania komponentów
Textpreferowany jest modyfikatorsharedBounds(), który obsługuje zmiany czcionki, takie jak przejście między kursywą a pogrubieniem, lub zmiany koloru.
W poprzednim przykładzie dodanie Modifier.sharedBounds() do Row i Column w 2 różnych scenariuszach pozwoli nam udostępnić granice obu komponentów i wykonać animację przejścia, umożliwiając im powiększanie się względem siebie:
@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() ) // ... ) { // ... } } }
Omówienie zakresów
Aby używać Modifier.sharedElement(), komponent musi znajdować się w SharedTransitionScope. Komponent SharedTransitionLayout zapewnia SharedTransitionScope. Umieść go w tym samym punkcie najwyższego poziomu w hierarchii interfejsu, który zawiera elementy, które chcesz udostępnić.
Ogólnie rzecz biorąc, komponenty powinny być też umieszczone w AnimatedVisibilityScope. Zwykle jest to zapewniane przez użycie AnimatedContent
do przełączania się między funkcjami typu „composable” lub bezpośrednio przez AnimatedVisibility albo przez
funkcję typu „composable” NavHost, chyba że zarządzasz widocznością
ręcznie. Aby używać wielu zakresów, zapisz wymagane zakresy w
CompositionLocal, użyj odbiorników kontekstu w Kotlinie lub przekaż
zakresy jako parametry do funkcji.
Używaj CompositionLocals w sytuacji, gdy masz wiele zakresów do śledzenia lub głęboko zagnieżdżoną hierarchię. CompositionLocal umożliwia wybranie dokładnych zakresów do zapisania i użycia. Z drugiej strony, gdy używasz odbiorników kontekstu, inne układy w hierarchii mogą przypadkowo zastąpić podane zakresy.
Na przykład, jeśli masz wiele zagnieżdżonych komponentów AnimatedContent, zakresy mogą zostać zastąpione.
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") } // ... } } } }
Jeśli hierarchia nie jest głęboko zagnieżdżona, możesz też przekazać zakresy jako parametry:
@Composable fun MainContent( animatedVisibilityScope: AnimatedVisibilityScope, sharedTransitionScope: SharedTransitionScope ) { } @Composable fun Details( animatedVisibilityScope: AnimatedVisibilityScope, sharedTransitionScope: SharedTransitionScope ) { }
Elementy udostępnione z AnimatedVisibility
Poprzednie przykłady pokazywały, jak używać elementów udostępnionych z AnimatedContent, ale elementy udostępnione działają też z AnimatedVisibility.
Na przykład w tym przykładzie leniwej siatki każdy element jest otoczony komponentem AnimatedVisibility. Gdy klikniesz element, treść będzie wyglądać tak, jakby została wyciągnięta z interfejsu do komponentu przypominającego okno dialogowe.
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.Kolejność modyfikatorów
W przypadku Modifier.sharedElement() i Modifier.sharedBounds() kolejność łańcucha
modyfikatorów ma znaczenie,
podobnie jak w przypadku pozostałych komponentów. Nieprawidłowe umieszczenie modyfikatorów wpływających na rozmiar może powodować nieoczekiwane skoki wizualne podczas dopasowywania elementów udostępnionych.
Jeśli na przykład umieścisz modyfikator dopełnienia w innym miejscu w 2 elementach udostępnionych, animacja będzie się różnić wizualnie.
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 ) } } } }
Dopasowane granice |
Niedopasowane granice: zwróć uwagę, że animacja elementu udostępnionego wygląda nieco inaczej, ponieważ musi zmienić rozmiar do nieprawidłowych granic. |
|---|---|
Modyfikatory używane przed modyfikatorami elementów udostępnionych zapewniają ograniczenia dla modyfikatorów elementów udostępnionych, które są następnie używane do określania początkowych i docelowych granic, a następnie animacji granic.
Modyfikatory używane po modyfikatorach elementów udostępnionych używają ograniczeń z poprzednich modyfikatorów do pomiaru i obliczania docelowego rozmiaru elementu podrzędnego. Modyfikatory elementów udostępnionych tworzą serię animowanych ograniczeń, które stopniowo przekształcają element podrzędny z rozmiaru początkowego do docelowego.
Wyjątkiem jest sytuacja, gdy używasz resizeMode = ScaleToBounds() do animacji lub Modifier.skipToLookaheadSize() w komponencie. W takim przypadku Compose układa element podrzędny za pomocą ograniczeń docelowych i zamiast zmieniać rozmiar układu, używa współczynnika skalowania do wykonania animacji.
Unikalne klucze
Podczas pracy ze złożonymi elementami udostępnionymi warto utworzyć klucz, który nie jest ciągiem znaków, ponieważ dopasowywanie ciągów znaków może być podatne na błędy. Aby doszło do dopasowania, każdy klucz musi być unikalny. Na przykład w Jetsnack mamy te elementy udostępnione:
Możesz utworzyć wyliczenie reprezentujące typ elementu udostępnionego. W tym przykładzie cała karta przekąski może też pojawiać się w różnych miejscach na ekranie głównym, np. w sekcji „Popularne” i „Polecane”. Możesz utworzyć klucz, który zawiera snackId, origin („Popularne” / „Polecane”) i type elementu udostępnionego, który będzie udostępniany:
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 ) ) // ... }
W przypadku kluczy zalecamy używanie klas danych, ponieważ implementują one hashCode() i equals().
Ręczne zarządzanie widocznością elementów udostępnionych
W przypadkach, gdy nie używasz AnimatedVisibility ani AnimatedContent, możesz samodzielnie zarządzać widocznością elementu udostępnionego. Użyj Modifier.sharedElementWithCallerManagedVisibility() i podaj własny warunek, który określa, kiedy element ma być widoczny.
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) } }
Aktualne ograniczenia
Te interfejsy API mają kilka ograniczeń. Najważniejsze z nich:
- Nie jest obsługiwana interoperacyjność między widokami a Compose. Obejmuje to wszystkie komponenty, które otaczają
AndroidView, takie jakDialogczyModalBottomSheet. - Nie ma automatycznej obsługi animacji w tych przypadkach:
- Komponenty udostępnionego obrazu:
ContentScalenie jest domyślnie animowany. Przeskakuje do ustawionego końcaContentScale.
- Przycinanie kształtu – nie ma wbudowanej obsługi automatycznej animacji między kształtami, np. animowania przejścia z kwadratu do koła podczas przechodzenia elementu.
- W nieobsługiwanych przypadkach użyj
Modifier.sharedBounds()zamiastsharedElement()i dodajModifier.animateEnterExit()do elementów.
- Komponenty udostępnionego obrazu: