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 o produkcie na stronę z szczegółami.

Rysunek 1. Demonstracja elementu udostępnionego w Jetsnackie

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

  • SharedTransitionLayout: układ zewnętrzny wymagany do implementowania przejść elementów współdzielonych. 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 dopasować go do innego komponentu.
  • Modifier.sharedBounds(): modyfikator, który wskazuje SharedTransitionScope, że zasięg tego komponentu powinien być używany jako zasięg kontenera, w którym ma 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 elementy te współdziałają z nakładkami i przycinaniem. Aby dowiedzieć się więcej na ten temat, zapoznaj się z sekcją Odcięcie i nakładki.

Podstawowe zastosowanie

W tej sekcji zostanie utworzone przejście z mniejszego elementu „lista” do większego elementu z informacjami szczegółowymi:

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 wyjścia jest podstawowy element AnimatedContent, który zawiera komponenty MainContent i DetailsContent, zanim dodasz elementy wspólne:

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

  1. Aby animować wspólne elementy między tymi dwoma układami, otacz kompozycję AnimatedContent elementem 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 w komponowalnych, 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 wartości rememberSharedContentState(), aby zapamiętać element. Wartość AnimatedContentScope jest przekazywana 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 automatyczną animację:

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

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

  • sharedBounds() jest przeznaczony do treści, które różnią się wizualnie, ale powinny dotyczyć tego samego obszaru w różnych stanach, podczas gdy sharedElement() wymaga, aby treści były takie same.
  • W przypadku sharedBounds() treści wchodzące i wychodzące z ekranu są widoczne podczas przejścia między 2 stanami, natomiast 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 między treściami, podobnie jak w przypadku 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.

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, która pozwoli im się powiększać naprzemiennie:

@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.

Omówienie zakresów

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 komponenty powinny być umieszczane w elementach AnimatedVisibilityScope. Zwykle jest to realizowane za pomocą funkcji AnimatedContent do przełączania się między elementami składanymi lub bezpośrednio za pomocą funkcji AnimatedVisibility lub funkcji składanej 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

W poprzednich przykładach pokazaliśmy, jak używać elementów współdzielonych w komponencie AnimatedContent, ale elementy współdzielone działają też w komponencie 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 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(
                            state = 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 użyciem funkcji AnimatedVisibility.

Kolejność modyfikatorów

W przypadku funkcji Modifier.sharedElement()Modifier.sharedBounds() istotna jest kolejność modyfikatorów, podobnie jak w przypadku reszty funkcji w edytorze. Nieprawidłowe umieszczenie modyfikatorów wpływających na rozmiar może powodować nieoczekiwane przeskakiwanie elementów podczas dopasowywania elementów współdzielonych.

Jeśli na przykład umieścisz modyfikator wypełnienia w innej pozycji w przypadku 2 wspólnych elementów, animacja będzie się wizualnie różnić.

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

Niezgodne granice: zwróć uwagę, że animacja udostępnionego elementu wygląda nieco dziwnie, 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 elementu wspólnego tworzą serię animowanych ograniczeń, aby stopniowo przekształcać dziecko z początkowego rozmiaru do rozmiaru docelowego.

Wyjątkiem jest użycie resizeMode = ScaleToBounds() do animacji lub Modifier.skipToLookaheadSize() do komponentu. 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

Podczas pracy z zaawansowanymi współdzielonymi elementami warto utworzyć klucz, który nie jest ciągiem znaków, ponieważ ciągi znaków mogą być podatne na błędy podczas dopasowywania. 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 części 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, który zawiera snackId, origin („Popularne” lub „Polecane”) oraz type udostępnianego elementu, 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()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:
    • Komponenty udostępnionego obrazu:
      • ContentScale nie jest domyślnie animowany. Przyciąga się 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 przypadku nieobsługiwanych przypadków użyj wartości Modifier.sharedBounds() zamiast sharedElement() i dodaj do elementów wartość Modifier.animateEnterExit().