Przejścia udostępnionych elementów w oknie tworzenia

Przejścia między elementami współdzielonymi to bezproblemowy sposób na przechodzenie między komponentami, które mają spójne treści. Często są one używane do nawigacji, umożliwiając wizualne połączenie różnych ekranów podczas przechodzenia przez nie przez użytkownika.

Na przykład w tym filmie widać, jak obraz i tytuł przekąski są udostępniane z strony z informacjami na stronę z szczegółami.

Rysunek 1. Demonstracja udostępnionego elementu Jetsnack

W usłudze Compose jest kilka interfejsów API na najwyższym poziomie, które ułatwiają tworzenie współdzielonych elementów:

  • SharedTransitionLayout: najbardziej zewnętrzny układ wymagany do implementacji przejść elementów wspólnych. Dostarcza on SharedTransitionScope. Aby móc używać modyfikatorów elementu współdzielonego, komponenty muszą znajdować się w elementach SharedTransitionScope.
  • Modifier.sharedElement(): modyfikator, który wskazuje komponentowi SharedTransitionScope, że należy go dopasować do innego komponentu.
  • Modifier.sharedBounds(): modyfikator wskazujący wartość SharedTransitionScope, że granice tej funkcji kompozycyjnej powinny być używane jako granice kontenera, w którym powinno nastąpić przejście. W przeciwieństwie do sharedElement(), sharedBounds() jest przeznaczony do treści wizualnie różniących się od siebie.

Podczas tworzenia elementów współdzielonych w Compose ważne jest to, jak działają one w połączeniu z przekryciami i przycinaniem. Zapoznaj się z sekcją o klipach i nakładkach, aby dowiedzieć się więcej o tym ważnym zagadnieniu.

Podstawowe zastosowanie

W tej sekcji zostanie wykonane poniższe przejście, przechodząc z mniejszego elementu na liście do większego elementu szczegółowego:

Rysunek 2. Podstawowy przykład przejścia elementu współdzielonego między dwoma komponentami składającymi się z elementów.

Najlepiej używać komponentu Modifier.sharedElement() w połączeniu z komponentem AnimatedContent, AnimatedVisibility lub NavHost, ponieważ komponenty te automatycznie zarządzają przejściami między komponentami.

Punktem początkowym jest istniejący podstawowy element AnimatedContent, który zawiera elementy kompozycyjne MainContent i DetailsContent, zanim dodasz udostępnione elementy:

Rysunek 3. Rozpoczynanie AnimatedContent bez żadnych przejść elementów współdzielonych.

  1. Aby wspólne elementy były animowane między 2 układami, otocz kompozycyjny AnimatedContent element SharedTransitionLayout. Zakresy z SharedTransitionLayout i AnimatedContent są przekazywane do metod 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 w komponentach, które pasują do siebie. 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, aby zidentyfikować treści, i użyj rememberSharedContentState() dla elementu, który ma zostać zapamiętany. Element AnimatedContentScope jest przekazywany do modyfikatora, 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 elementów wspólnych, wyodrębnij zmienną rememberSharedContentState() i utwórz zapytanie isMatchFound.

Powoduje to utworzenie takiej automatycznej animacji:

Rysunek 4. Podstawowy przykład przejścia ze wspólnego elementu między dwoma elementami kompozycyjnymi.

Możesz zauważyć, że kolor tła i rozmiar całego kontenera nadal korzystają z domyślnych ustawień AnimatedContent.

Różnica między wspólnymi zasięgami a wspólnymi elementami

Modifier.sharedBounds() jest podobne do Modifier.sharedElement(). Modyfikatory różnią się jednak pod kilkoma względami:

  • sharedBounds() dotyczy treści, które różnią się wizualnie, ale powinny mieć ten sam obszar w poszczególnych stanach, a sharedElement() oczekuje, że treści będą takie same.
  • W przypadku sharedBounds() treści wchodzące i wychodzące z ekranu są widoczne podczas przejścia między 2 stanami, podczas gdy w przypadku sharedElement() renderowane są tylko treści docelowe w ramach przekształcania. Modifier.sharedBounds() ma parametry enter i exit, które określają sposób przejścia treści (podobnie jak w przypadku elementu AnimatedContent).
  • Najczęstszym przypadkiem użycia funkcji sharedBounds() jest przekształcenie kontenera, natomiast w przypadku funkcji sharedElement() przykładowym przypadkiem użycia jest przejście główne.
  • Jeśli używasz komponentów Text, zalecamy użycie atrybutu sharedBounds(), aby obsługiwać zmiany czcionki, takie jak przejście między kursywą a pogrubieniem lub zmiany koloru.

Z poprzedniego przykładu dodanie Modifier.sharedBounds() do pól Row i Column w 2 różnych scenariuszach pozwoli nam wyznaczyć granice tych 2 aspektów i wykonać animację przejścia, dzięki czemu będą się one 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()
                )
                // ...

        ) {
            // ...
        }
    }
}

Rysunek 5. Współdzielone granice między 2 komponowanymi komponentami.

Informacje o zakresach

Aby używać Modifier.sharedElement(), komponent musi znajdować się w SharedTransitionScope. Komponent SharedTransitionLayout zapewnia SharedTransitionScope. Upewnij się, że umieszczasz je na tym samym najwyższym poziomie hierarchii interfejsu użytkownika, który zawiera elementy, które chcesz udostępniać.

Ogólnie funkcje kompozycyjne powinny być też umieszczone w elemencie AnimatedVisibilityScope. Zwykle dzieje się tak, gdy używasz funkcji AnimatedContent do przełączania się między elementami kompozycyjnymi lub używasz bezpośrednio AnimatedVisibility albo funkcji kompozycyjnej 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 swoich funkcji.

Użyj CompositionLocals, gdy masz wiele zakresów, które chcesz śledzić, lub głęboko zagnieżdżoną hierarchię. CompositionLocal pozwala wybrać dokładne zakresy 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 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

Poprzednie przykłady pokazują, jak używać udostępnionych elementów w AnimatedContent, ale elementy udostępnione działają też w AnimatedVisibility.

Na przykład w tym przykładzie siatki leniwej każdy element jest zawijany w element AnimatedVisibility. Gdy użytkownik kliknie element, treść zostanie wyciągnięta 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(
                            state = rememberSharedContentState(key = snack.name),
                            animatedVisibilityScope = this@AnimatedVisibility
                        ),
                        onClick = {
                            selectedSnack = snack
                        }
                    )
                }
            }
        }
    }
    // Contains matching AnimatedContent with sharedBounds modifiers.
    SnackEditDetails(
        snack = selectedSnack,
        onConfirmClick = {
            selectedSnack = null
        }
    )
}

Rys. 6. Elementy udostępnione AnimatedVisibility.

Kolejność modyfikatorów

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

Jeśli np. umieścisz modyfikator dopełnienia w innym miejscu w przypadku 2 udostępnionych elementów, animacja będzie się różnić pod względem wizualnym.

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

Pasujące granice

Niedopasowane granice: zwróć uwagę, że animacja udostępnionego elementu wygląda na odrobinę bliższą, ponieważ musi dostosować rozmiar do nieprawidłowych granic.

Modyfikatory używane przed modyfikatorami elementu udostępnionego zapewniają ograniczenia dla modyfikatorów elementu udostępnionego, które są następnie wykorzystywane do wyprowadzenia początkowych i docelowych granic oraz animacji granic.

Modyfikatory używane po modyfikatorach elementu współdzielonego korzystają z ograniczeń z poprzedniego etapu, aby zmierzyć i obliczyć rozmiar docelowy elementu podrzędnego. Modyfikatory elementów udostępnionych tworzą serię animowanych ograniczeń, aby stopniowo przekształcić element podrzędny z rozmiaru początkowego na docelowy.

Wyjątkiem od tej reguły jest użycie polecenia resizeMode = ScaleToBounds() dla animacji lub Modifier.skipToLookaheadSize() w przypadku funkcji kompozycyjnej. W takim przypadku kompozytor rozmieszcza element potomny, korzystając z ograniczeń docelowych, a do wykonania animacji używa współczynnika skali zamiast zmieniać rozmiar samego układu.

Unikalne klucze

W przypadku złożonych wspólnych elementów dobrze jest tworzyć klucz, który nie jest ciągiem znaków, ponieważ wtedy ciągi tekstowe mogą być podatne na błędy. Aby dopasowania mogły wystąpić, każdy klucz musi być niepowtarzalny. Na przykład w Jetsnack mamy te elementy wspólne:

Rysunek 7. Obraz przedstawiający Jetsnacka z adnotacjami do poszczególnych elementów interfejsu użytkownika.

Możesz utworzyć enum, aby reprezentować typ elementu współdzielonego. W tym przykładzie karta może się wyświetlać w różnych miejscach na ekranie głównym, np. w sekcji „Popularne” i „Polecane”. Możesz utworzyć klucz zawierający snackId, origin („Popularne”/„Zalecane”) oraz 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 używanie klas danych, ponieważ implementują one hashCode()isEquals().

Ręczne zarządzanie widocznością udostępnionych elementów

Jeśli nie używasz elementów AnimatedVisibility ani AnimatedContent, widoczności udostępnionych elementów możesz zarządzać samodzielnie. Użyj elementu Modifier.sharedElementWithCallerManagedVisibility() i utwórz własne wyrażenie warunkowe, które 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)
    }
}

Obecne ograniczenia

Te interfejsy API mają kilka ograniczeń. Najważniejsze:

  • Nieobsługiwana jest interoperacyjność między widokami a edytorem. Dotyczy to każdego kompozytu, który zawiera AndroidView, np. Dialog.
  • Automatyczna animacja nie jest obsługiwana w przypadku tych elementów:
    • Elementy kompozycyjne udostępnianych obrazów:
      • ContentScale nie jest domyślnie animowany. Ustawienie zostanie przyciągnięte do ustawionego końca ContentScale.
    • Wycinanie kształtów – nie ma wbudowanego wsparcia dla automatycznej animacji między kształtami, np. animacji od kwadratu do koła podczas przejścia między elementami.
    • W nieobsługiwanych przypadkach użyj Modifier.sharedBounds() zamiast sharedElement() i dodaj do produktów Modifier.animateEnterExit().