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ępniania elementów w aplikacji Jetsnack.

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

  • SharedTransitionLayout: najbardziej zewnętrzny układ wymagany do wdrożenia przejść między udostępnionymi elementami. Zapewnia SharedTransitionScope. Funkcje kompozycyjne muszą znajdować się w SharedTransitionScope, aby można było używać modyfikatorów elementów udostępnionych.
  • Modifier.sharedElement(): modyfikator, który informuje funkcję kompozycyjnąSharedTransitionScope, że powinna być dopasowana do innej funkcji kompozycyjnej.
  • 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 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 zostanie utworzone przejście od mniejszego elementu „listy” do większego elementu szczegółowego:

Rysunek 2. Podstawowy przykład przejścia elementu udostępnionego 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 początkowym jest istniejący podstawowy AnimatedContent, który ma MainContentDetailsContent, które można komponować przed dodaniem wspólnych elementów:

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

  1. Aby elementy udostępnione były animowane 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 symbolu 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 nastąpiło dopasowanie elementu udostępnionego, wyodrębnij rememberSharedContentState() do zmiennej i wykonaj zapytanie isMatchFound.

Spowoduje to automatyczne utworzenie animacji:

Rysunek 4. Podstawowy przykład przejścia elementu udostępnionego 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() jest przeznaczony dla treści, które różnią się wizualnie, ale powinny zajmować 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łcania kontenera, natomiast w przypadku sharedElement() przykładem jest przejście do elementu głównego.
  • W przypadku funkcji kompozycyjnych Text preferowane jest użycie 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.

Omówienie zakresów

Aby użyć Modifier.sharedElement(), komponent musi znajdować się w SharedTransitionScope. Komponent 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 AnimatedContentdo przełączania się między funkcjami typu „composable” lub bezpośrednio przez użycie AnimatedVisibility albo przez funkcję typu „composable” NavHost, chyba że ręcznie zarządzasz widocznością. 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 przekazywać 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 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 tag AnimatedVisibility. Gdy użytkownik kliknie element, treść zostanie wizualnie wyciągnięta z interfejsu i przeniesiona 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. 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 potem animacji granic.

Modyfikatory użyte po modyfikatorach elementu udostępnionego używają ograniczeń z wcześniejszego etapu do pomiaru i obliczania docelowego rozmiaru 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 przypadku komponentu. W tym przypadku Compose układa element podrzędny przy użyciu docelowych ograniczeń, a 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 elementy wspólne:

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 interfejsy hashCode()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 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 animacja nie jest obsługiwana w przypadku tych elementów:
    • Komponenty kompozycyjne Shared Image:
      • ContentScale nie jest domyślnie animowany. Zostanie ona przyciągnięta do ustawionego końcaContentScale.
    • Przycinanie do 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 zasady Modifier.sharedBounds() zamiast zasady sharedElement() i dodaj do produktów Modifier.animateEnterExit().