Przechodzenie między elementami współdzielonymi w edytorze

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.

Rysunek 1. Prezentacja elementu udostępnionego w Jetsnack.

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 on SharedTransitionScope. Aby używać modyfikatorów elementów udostępnionych, komponenty muszą znajdować się w SharedTransitionScope.
  • Modifier.sharedElement(): modyfikator, który informuje SharedTransitionScope, że komponent powinien być dopasowany do innego komponentu.
  • Modifier.sharedBounds(): modyfikator, który informuje SharedTransitionScope, że granice tego komponentu powinny być używane jako granice kontenera, w którym ma nastąpić przejście. W przeciwieństwie do sharedElement() modyfikator sharedBounds() 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:

Rysunek 2. Podstawowy przykład przejścia między elementami udostępnionymi w 2 komponentach.

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:

Rysunek 3. Początkowy komponent AnimatedContent bez przejść między elementami udostępnionymi.

  1. Aby elementy udostępnione były animowane między 2 układami, otocz komponent AnimatedContent komponentem SharedTransitionLayout. Zakresy z SharedTransitionLayout i AnimatedContent są przekazywane do MainContent i 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
                )
            }
        }
    }

  2. Dodaj Modifier.sharedElement() do łańcucha modyfikatorów komponentu w 2 pasujących komponentach. Utwórz obiekt SharedContentState i zapamiętaj go za pomocą rememberSharedContentState(). Obiekt SharedContentState przechowuje unikalny klucz, który określa elementy udostępnione. Podaj unikalny klucz identyfikujący treść i użyj rememberSharedContentState(), aby zapamiętać element. Do modyfikatora przekazywany jest AnimatedContentScope, 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ę:

Rysunek 4. Podstawowy przykład przejścia między elementami udostępnionymi w 2 komponentach.

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, natomiast sharedElement() 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 przypadku sharedElement() w przekształcanych granicach renderowana jest tylko treść docelowa. Modifier.sharedBounds() ma parametry enter i exit, które określają, jak treść ma się zmieniać, podobnie jak w przypadku AnimatedContent.
  • Najczęstszym przypadkiem użycia sharedBounds() jest wzorzec przekształcenia kontenera, natomiast w przypadku sharedElement() przykładem jest przejście główne.
  • W przypadku używania komponentów Text preferowany jest modyfikator sharedBounds(), 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()
                )
                // ...

        ) {
            // ...
        }
    }
}

Rysunek 5. Granice udostępnione między 2 komponentami.

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
        }
    )
}

Rysunek 6. Elementy udostępnione z 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:

Rysunek 7. Obraz przedstawiający Jetsnack z adnotacjami dotyczącymi każdej części interfejsu.

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 jak Dialog czy ModalBottomSheet.
  • Nie ma automatycznej obsługi animacji w tych przypadkach:
    • Komponenty udostępnionego obrazu:
      • ContentScale nie jest domyślnie animowany. Przeskakuje do ustawionego końca ContentScale.
    • 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() zamiast sharedElement() i dodaj Modifier.animateEnterExit() do elementów.