Przejścia elementów wspólnych to płynny sposób przechodzenia między elementami kompozycyjnymi, które zawierają spójne treści. Często służą do nawigacji, umożliwiając wizualne łączenie różnych ekranów, gdy użytkownik przechodzi 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ę z informacjami.
W Compose dostępnych jest kilka interfejsów API wysokiego poziomu, które pomagają tworzyć elementy wspólne:
SharedTransitionLayout: układ zewnętrzny wymagany do implementacji przejść elementów wspólnych. ZapewniaSharedTransitionScope. Aby można było używać modyfikatorów elementów udostępnionych, funkcje kompozycyjne muszą znajdować się wSharedTransitionScope.Modifier.sharedElement(): modyfikator, który wskazujeSharedTransitionScopeelement kompozycyjny, który powinien być dopasowany do innego elementu kompozycyjnego.Modifier.sharedBounds(): modyfikator, który informujeSharedTransitionScope, że granice tego elementu kompozycyjnego powinny być używane jako granice kontenera, w którym powinno nastąpić przejście. W przeciwieństwie dosharedElement(),sharedBounds()jest przeznaczony do treści, które różnią się wizualnie.
Ważną kwestią podczas tworzenia elementów wspólnych 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:
Najlepiej używać Modifier.sharedElement() w połączeniu z AnimatedContent, AnimatedVisibility lub NavHost, ponieważ automatycznie zarządza to przejściem między elementami kompozycyjnymi.
Punktem wyjścia jest istniejący podstawowy element AnimatedContent, który zawiera elementy kompozycyjne MainContent i DetailsContent, zanim dodasz elementy wspólne:
AnimatedContent bez przejść elementów wspólnych.Aby animować elementy udostępnione między 2 układami, otocz element kompozycyjny
AnimatedContentelementemSharedTransitionLayout. 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 funkcji kompozycyjnych w przypadku 2 pasujących funkcji kompozycyjnych. Utwórz obiektSharedContentStatei zapamiętaj go za pomocąrememberSharedContentState(). ObiektSharedContentStateprzechowuje unikalny klucz, który określa udostępniane elementy. Podaj unikalny klucz identyfikujący treść i użyjrememberSharedContentState(), aby element został zapamiętany. Do modyfikatora przekazywany jest obiektAnimatedContentScope, 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 wystąpiło dopasowanie elementu udostępnionego, wyodrębnij rememberSharedContentState() do zmiennej i wykonaj zapytanie isMatchFound.
W ten sposób powstanie taka automatyczna animacja:
Możesz zauważyć, że kolor tła i rozmiar całego kontenera nadal korzystają z ustawień domyślnych AnimatedContent.
Wspólne granice a wspólny element
Modifier.sharedBounds() jest podobny do Modifier.sharedElement().
Modyfikatory różnią się jednak w następujący sposób:
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ść pojawiająca się na ekranie i z niego znikająca jest widoczna podczas przejścia między tymi 2 stanami, natomiast w przypadkusharedElement()w przekształcanych granicach renderowana jest tylko treść docelowa.Modifier.sharedBounds()ma parametryenteriexit, które określają, jak powinna przebiegać zmiana treści, podobnie jak w przypadkuAnimatedContent. - Najczęstszym przypadkiem użycia
sharedBounds()jest wzorzec przekształcania kontenera, natomiast w przypadkusharedElement()przykładowym przypadkiem użycia jest przejście hero. - Jeśli używasz funkcji kompozycyjnych
Text, zalecamy użyciesharedBounds(), aby obsługiwać 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 tych elementów i wykonać animację przejścia, dzięki czemu będą mogły się powiększać:
@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 można było używać Modifier.sharedElement(), funkcja kompozycyjna musi znajdować się w SharedTransitionScope. Komponent SharedTransitionLayout udostępnia SharedTransitionScope. Pamiętaj, aby umieścić go w tym samym punkcie najwyższego poziomu w hierarchii interfejsu, który zawiera elementy, które chcesz udostępnić.
Zasadniczo elementy kompozycyjne powinny być też umieszczone w elemencie AnimatedVisibilityScope. Zwykle jest to zapewniane przez użycie AnimatedContent do przełączania się między funkcjami typu „composable” lub przez bezpośrednie użycie AnimatedVisibility albo przez funkcję typu „composable” NavHost, chyba że ręcznie zarządzasz widocznością. 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żyj CompositionLocals, gdy masz wiele zakresów do śledzenia lub głęboko zagnieżdżoną hierarchię. CompositionLocal pozwala wybrać dokładne zakresy, które chcesz zapisać i używać. Z drugiej strony, gdy używasz odbiorników kontekstu, inne układy w hierarchii mogą przypadkowo zastąpić podane zakresy.
Jeśli na przykład masz kilka zagnieżdżonych elementó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 ) { }
Udostępnione elementy: 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 opakowany w AnimatedVisibility. Po kliknięciu elementu zawartość jest wizualnie wyciągana z interfejsu do komponentu przypominającego okno.
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ść modyfikatorów w łańcuchu ma znaczenie, podobnie jak w pozostałych elementach Compose. Nieprawidłowe umieszczenie modyfikatorów wpływających na rozmiar może powodować nieoczekiwane przeskoki wizualne podczas dopasowywania elementów wspólnych.
Jeśli na przykład umieścisz modyfikator dopełnienia w różnych miejscach na 2 udostępnionych elementach, 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 udostępnionego elementu wygląda nieco dziwnie, ponieważ musi zmienić rozmiar, aby dopasować się do nieprawidłowych granic. |
|---|---|
Modyfikatory używane przed modyfikatorami elementów wspólnych nakładają ograniczenia na modyfikatory elementów wspólnych, które są następnie używane do określania granic początkowych i docelowych, a później animacji granic.
Modyfikatory używane po modyfikatorach elementów wspólnych używają ograniczeń z wcześniejszego etapu do pomiaru i obliczania docelowego rozmiaru elementu podrzędnego. Modyfikatory elementów wspólnych 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 funkcji kompozycyjnej. W takim przypadku Compose rozmieszcza element podrzędny przy użyciu ograniczeń docelowych i używa współczynnika skalowania do wykonania animacji zamiast zmiany rozmiaru układu.
Unikalne klucze
Podczas pracy ze złożonymi elementami współdzielonymi warto utworzyć klucz, który nie jest ciągiem znaków, ponieważ ciągi znaków mogą być trudne do dopasowania. Aby dopasowanie było możliwe, każdy klucz musi być unikalny. Na przykład w Jetsnack mamy te elementy wspólne:
Możesz utworzyć typ wyliczeniowy, który będzie reprezentować typ udostępnionego elementu. W tym przykładzie cała karta przekąski może też pojawiać się w różnych miejscach na ekranie głównym, np. w sekcjach „Popularne” i „Polecane”. Możesz utworzyć klucz, który zawiera snackId, origin („Popularne” / „Polecane”) i type udostępnianego elementu:
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 klasy danych, ponieważ implementują one metody hashCode() i equals().
Ręczne zarządzanie widocznością udostępnionych elementów
Jeśli nie używasz AnimatedVisibility ani AnimatedContent, możesz samodzielnie zarządzać widocznością udostępnionego elementu. Użyj Modifier.sharedElementWithCallerManagedVisibility() i podaj własny warunek, który określa, kiedy element powinien 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) } }
Obecne ograniczenia
Te interfejsy API mają kilka ograniczeń. W szczególności:
- Interoperacyjność między widokami a Compose nie jest obsługiwana. Obejmuje to dowolny element kompozycyjny, który zawiera
AndroidView, np.DialoglubModalBottomSheet. - Automatyczne animacje nie są obsługiwane w przypadku:
- Komponenty typu Shared Image:
ContentScalenie jest domyślnie animowany. Zostanie przyciągnięty do ustawionego końca.ContentScale
- Przycinanie kształtów – nie ma wbudowanej obsługi automatycznej animacji między kształtami, np. animacji od kwadratu do koła podczas przejścia elementu.
- W przypadkach, które nie są obsługiwane, użyj
Modifier.sharedBounds()zamiastsharedElement()i dodajModifier.animateEnterExit()do elementów.
- Komponenty typu Shared Image: