Przechodzenie między elementami współdzielonymi w edytorze

Przejścia między elementami współdzielonymi to płynny sposób przechodzenia między komponentami, które mają spójną treść. Często służą do nawigacji, umożliwiając wizualne połą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ę szczegółów.

Rysunek 1. Wersja demonstracyjna udostępnionego elementu Jetsnack.

W Compose jest kilka interfejsów API wysokiego poziomu, które pomagają tworzyć elementy współdzielone:

  • SharedTransitionLayout: najbardziej zewnętrzny układ wymagany do wdrożenia przejść między udostępnionymi elementami. Zapewnia SharedTransitionScope. Funkcje kompozycyjne muszą znajdować się w elemencie SharedTransitionScope, aby można było używać modyfikatorów elementów udostępnionych.
  • Modifier.sharedElement(): modyfikator, który informuje SharedTransitionScope o kompozycji, która powinna być dopasowana do innej kompozycji.
  • Modifier.sharedBounds(): modyfikator, który informuje SharedTransitionScope, że granice tego komponentu kompozycyjnego powinny być używane jako granice kontenera, w którym ma nastąpić przejście. W przeciwieństwie do sharedElement(), sharedBounds() jest przeznaczony do treści, które różnią się wizualnie.

Ważną kwestią podczas tworzenia elementów wspólnych w Compose jest sposób, w jaki 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 zostanie utworzone przejście od mniejszego elementu „listy” do większego elementu szczegółowego:

Rysunek 2. Podstawowy przykład przejścia udostępnionego elementu między 2 kompozycjami.

Najlepiej używać Modifier.sharedElement() w połączeniu z AnimatedContent, AnimatedVisibility lub NavHost, ponieważ automatycznie zarządza przejściem między funkcjami kompozycyjnymi.

Punktem wyjścia jest istniejący podstawowy element AnimatedContent, który ma elementy MainContentDetailsContent, które można łączyć przed dodaniem elementów wspólnych:

Rysunek 3. Rozpoczęcie AnimatedContent bez przejść między elementami współdzielonymi.

  1. Aby animować udostępnione elementy między dwoma układami, otocz funkcję kompozycyjną AnimatedContent funkcją SharedTransitionLayout. Zakresy z SharedTransitionLayoutAnimatedContent są przekazywane do MainContentDetailsContent:

    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 kompozycyjnych w 2 kompozycjach, które pasują. Utwórz obiekt SharedContentState i zapamiętaj go za pomocą rememberSharedContentState(). Obiekt SharedContentState przechowuje unikalny klucz, który określa udostępniane elementy. Podaj unikalny klucz identyfikujący treść i użyj rememberSharedContentState(), aby zapamiętać element. W modyfikatorze przekazywany jest parametr 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 wystąpiło dopasowanie elementu udostępnionego, wyodrębnij rememberSharedContentState() do zmiennej i wykonaj zapytanie isMatchFound.

Spowoduje to automatyczną animację:

Rysunek 4. Podstawowy przykład przejścia udostępnionego elementu między 2 kompozycjami.

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 podobne do Modifier.sharedElement(). Modyfikatory różnią się jednak w tych aspektach:

  • sharedBounds() dotyczy treści, które różnią się wizualnie, ale powinny obejmować 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 dwoma stanami, natomiast w przypadku sharedElement() w przekształcanych granicach renderowana jest tylko treść docelowa. Modifier.sharedBounds() ma parametry enterexit, które określają sposób przejścia treści, podobnie jak w przypadku funkcji AnimatedContent.
  • Najczęstszym przypadkiem użycia sharedBounds() jest wzorzec przekształcenia kontenera, natomiast w przypadku sharedElement() przykładem jest przejście do elementu głównego.
  • W przypadku komponentów kompozycyjnych Text zaleca się używanie sharedBounds(), aby obsługiwać zmiany czcionki, takie jak przejścia między kursywą a pogrubieniem lub zmiany koloru.

W poprzednim przykładzie dodanie Modifier.sharedBounds() do RowColumn w 2 różnych scenariuszach pozwoli nam udostępnić granice tych 2 elementó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. Wspólne granice między dwoma komponentami kompozycyjnymi.

Omówienie zakresów

Aby użyć Modifier.sharedElement(), funkcja kompozycyjna musi znajdować się w SharedTransitionScope. Funkcja kompozycyjna SharedTransitionLayout udostępnia SharedTransitionScope. Umieść go w tym samym punkcie najwyższego poziomu w hierarchii interfejsu, który zawiera elementy, które chcesz udostępnić.

Zwykle elementy kompozycyjne powinny być umieszczane w obrębie elementu AnimatedVisibilityScope. Zwykle jest to zapewniane przez użycie AnimatedContent do przełączania się między funkcjami kompozycyjnymi lub bezpośrednio przez AnimatedVisibility albo przez funkcję kompozycyjną NavHost, chyba że widoczność jest zarządzana ręcznie. Aby użyć 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, jeśli masz wiele zakresów do śledzenia lub głęboko zagnieżdżoną hierarchię. CompositionLocal umożliwia wybór 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. Jeśli na przykład masz wiele 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 przekazać zakresy jako parametry:

@Composable
fun MainContent(
    animatedVisibilityScope: AnimatedVisibilityScope,
    sharedTransitionScope: SharedTransitionScope
) {
}

@Composable
fun Details(
    animatedVisibilityScope: AnimatedVisibilityScope,
    sharedTransitionScope: SharedTransitionScope
) {
}

Udostępnione elementy użytkownikowi AnimatedVisibility

W poprzednich przykładach pokazaliśmy, jak używać elementów udostępnionych z elementem AnimatedContent, ale elementy udostępnione działają też z elementem AnimatedVisibility.

Na przykład w tym przykładzie leniwej siatki każdy element jest opakowany w tag AnimatedVisibility. Gdy użytkownik kliknie element, treść będzie miała efekt wizualny wyciągania 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
        }
    )
}

Rysunek 6. Udostępnione elementy: AnimatedVisibility.

Kolejność modyfikatorów

W przypadku funkcji Modifier.sharedElement()Modifier.sharedBounds() kolejność łańcucha modyfikatorów ma znaczenie, podobnie jak w przypadku pozostałych elementów Compose. Nieprawidłowe umieszczenie modyfikatorów wpływających na rozmiar może powodować nieoczekiwane skoki wizualne podczas dopasowywania wspólnych elementów.

Jeśli na przykład umieścisz modyfikator dopełnienia w różnych miejscach w 2 wspólnych elementach, animacja będzie wyglądać inaczej.

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 wspólnego elementu wygląda nieco dziwnie, ponieważ musi zmienić rozmiar do nieprawidłowych granic.

Modyfikatory użyte przed modyfikatorami elementu udostępnionego określają ograniczenia modyfikatorów elementu udostępnionego, które są następnie używane do określania początkowych i docelowych granic, a w konsekwencji animacji granic.

Modyfikatory użyte po modyfikatorach elementu udostępnionego używają ograniczeń z wcześniejszego etapu do pomiaru i obliczania rozmiaru docelowego elementu podrzędnego. Modyfikatory udostępnionego elementu tworzą serię animowanych ograniczeń, które stopniowo przekształcają element podrzędny z rozmiaru początkowego w rozmiar docelowy.

Wyjątkiem jest sytuacja, w której używasz resizeMode = ScaleToBounds() w przypadku animacji lub Modifier.skipToLookaheadSize() w funkcji kompozycyjnej. W tym przypadku Compose układa element podrzędny zgodnie z ograniczeniami docelowymi i zamiast zmieniać rozmiar układu, używa współczynnika skali do wykonania animacji.

Klucze unikalne

W przypadku złożonych elementów wspólnych warto utworzyć klucz, który nie jest ciągiem tekstowym, ponieważ dopasowywanie ciągów tekstowych może być podatne na błędy. Aby doszło do dopasowania, każdy klucz musi być niepowtarzalny. Na przykład w aplikacji Jetsnack mamy te wspólne elementy:

Rysunek 7. Obraz przedstawiający aplikację Jetsnack z adnotacjami dotyczącymi poszczególnych elementów interfejsu.

Możesz utworzyć wyliczenie, aby reprezentować typ udostępnionego elementu. W tym przykładzie cała karta z przekąską 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 będzie zawierać snackId, origin („Popularne” / „Rekomendowane”) 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 stosowanie klas danych, ponieważ implementują one hashCode()isEquals().

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 produkt 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:

  • Nie jest obsługiwana interoperacyjność między widokami a Compose. Obejmuje to wszystkie funkcje kompozycyjne, które opakowują AndroidView, np. Dialog lub ModalBottomSheet.
  • Automatyczna obsługa animacji nie jest dostępna w przypadku tych elementów:
    • Komponenty kompozycyjne Shared Image:
      • ContentScale nie jest domyślnie animowany. Zostanie przyciągnięty do ustawionego końcaContentScale.
    • Przycinanie kształtu – nie ma wbudowanej obsługi automatycznej animacji między kształtami, np. animacji przejścia z kwadratu do koła podczas przejścia elementu.
    • W przypadku nieobsługiwanych przypadków użyj Modifier.sharedBounds() zamiast sharedElement() i dodaj Modifier.animateEnterExit() do produktów.