Transitions d'éléments partagés dans Compose

Les transitions d'éléments partagés permettent de passer facilement entre des composables dont le contenu est cohérent. Ils sont souvent utilisés pour la navigation, ce qui vous permet de connecter visuellement différents écrans lorsque l'utilisateur passe de l'un à l'autre.

Par exemple, dans la vidéo suivante, vous pouvez voir que l'image et le titre de l'en-cas sont partagés entre la page de fiche et la page d'informations.

Figure 1 : Démo de l'élément partagé Jetsnack

Dans Compose, quelques API de haut niveau vous aident à créer des éléments partagés :

  • SharedTransitionLayout : mise en page la plus externe requise pour implémenter des transitions d'éléments partagés. Elle fournit un SharedTransitionScope. Les composables doivent se trouver dans un SharedTransitionScope pour utiliser les modificateurs d'élément partagé.
  • Modifier.sharedElement() : modificateur qui indique à SharedTransitionScope le composable à mettre en correspondance avec un autre composable.
  • Modifier.sharedBounds(): modificateur qui indique à SharedTransitionScope que les limites de ce composable doivent être utilisées comme limites de conteneur pour l'emplacement de la transition. Contrairement à sharedElement(), sharedBounds() est conçu pour les contenus visuellement différents.

Un concept important lors de la création d'éléments partagés dans Compose est la façon dont ils fonctionnent avec les superpositions et l'écrêtage. Consultez la section sur l'écrêtage et les superpositions pour en savoir plus sur ce sujet important.

Utilisation de base

La transition suivante sera intégrée dans cette section, en passant de l'élément de liste le plus petit à l'élément détaillé plus grand:

Figure 2 Exemple de base de transition d'éléments partagés entre deux composables.

La meilleure façon d'utiliser Modifier.sharedElement() est d'utiliser AnimatedContent, AnimatedVisibility ou NavHost, car la transition entre les composables est automatiquement gérée pour vous.

Le point de départ est un AnimatedContent de base existant qui comporte un composable MainContent et DetailsContent avant d'ajouter des éléments partagés :

Figure 3. Démarrage de AnimatedContent sans aucune transition d'élément partagé.

  1. Pour que les éléments partagés s'animent entre les deux mises en page, entourez le composable AnimatedContent avec SharedTransitionLayout. Les portées de SharedTransitionLayout et AnimatedContent sont transmises à MainContent et 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. Ajoutez Modifier.sharedElement() à votre chaîne de modificateur de composable sur les deux composables correspondants. Créez un objet SharedContentState et mémorisez-le avec rememberSharedContentState(). L'objet SharedContentState stocke la clé unique qui détermine les éléments partagés. Fournissez une clé unique pour identifier le contenu et utilisez rememberSharedContentState() pour que l'élément soit mémorisé. Le AnimatedContentScope est transmis au modificateur, qui permet de coordonner l'animation.

    @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
                )
                // ...
            }
        }
    }

Pour savoir si une correspondance d'élément partagé a eu lieu, extrayez rememberSharedContentState() dans une variable, puis interrogez isMatchFound.

L'animation automatique suivante est alors générée:

Figure 4. Exemple de base de transition d'éléments partagés entre deux composables.

Vous remarquerez peut-être que la couleur et la taille de l'arrière-plan de l'ensemble du conteneur utilisent toujours les paramètres AnimatedContent par défaut.

Limites partagées et élément partagé

Modifier.sharedBounds() est semblable à Modifier.sharedElement(). Cependant, les modificateurs présentent les différences suivantes:

  • sharedBounds() est destiné au contenu visuellement différent, mais doit partager la même zone entre les états, tandis que sharedElement() s'attend à ce que le contenu soit identique.
  • Avec sharedBounds(), le contenu entrant et sortant de l'écran est visible pendant la transition entre les deux états, tandis que seul le contenu cible est affiché dans les limites de transformation avec sharedElement(). Modifier.sharedBounds() comporte les paramètres enter et exit pour spécifier comment le contenu doit passer d'un état à un autre, comme AnimatedContent.
  • Le cas d'utilisation le plus courant de sharedBounds() est le modèle de transformation de conteneur, tandis que pour sharedElement(), le cas d'utilisation est une transition héros.
  • Lorsque vous utilisez des composables Text, sharedBounds() est préférable pour prendre en charge les modifications de police, telles que la transition entre l'italique et le gras ou les changements de couleur.

Dans l'exemple précédent, ajouter Modifier.sharedBounds() à Row et Column dans les deux scénarios différents nous permettra de partager les limites des deux et d'effectuer l'animation de transition, ce qui leur permettra de croître entre eux :

@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()
                )
                // ...

        ) {
            // ...
        }
    }
}

Figure 5 : Limites partagées entre deux composables.

Comprendre les portées

Pour utiliser Modifier.sharedElement(), le composable doit se trouver dans un SharedTransitionScope. Le composable SharedTransitionLayout fournit SharedTransitionScope. Veillez à placer les éléments que vous souhaitez partager au même niveau racine de la hiérarchie de l'UI.

En règle générale, les composables doivent également être placés dans un AnimatedVisibilityScope. Pour ce faire, vous devez généralement utiliser AnimatedContent pour basculer entre les composables ou AnimatedVisibility directement, ou utiliser la fonction composable NavHost, sauf si vous gérez la visibilité manuellement. Pour utiliser plusieurs champs d'application, enregistrez les champs d'application requis dans un objet CompositionLocal, utilisez des récepteurs de contexte en Kotlin ou transmettez les champs d'application en tant que paramètres à vos fonctions.

Utilisez CompositionLocals si vous devez suivre plusieurs champs d'application ou si vous avez une hiérarchie profondément imbriquée. Un CompositionLocal vous permet de choisir les champs d'application exacts à enregistrer et à utiliser. D'autre part, lorsque vous utilisez des récepteurs de contexte, d'autres mises en page de votre hiérarchie peuvent remplacer accidentellement les champs d'application fournis. Par exemple, si vous disposez de plusieurs AnimatedContent imbriquées, les portées peuvent être remplacées.

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")
                }
                // ...
            }
        }
    }
}

Si votre hiérarchie n'est pas profondément imbriquée, vous pouvez également transmettre les champs d'application en tant que paramètres:

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

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

Éléments partagés avec AnimatedVisibility

Les exemples précédents ont montré comment utiliser des éléments partagés avec AnimatedContent, mais les éléments partagés fonctionnent également avec AnimatedVisibility.

Par exemple, dans cet exemple de grille différée, chaque élément est encapsulé dans AnimatedVisibility. Lorsque l'utilisateur clique sur l'élément, le contenu est extrait de l'interface utilisateur dans un composant de type boîte de dialogue.

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

Figure 6 : Éléments partagés avec AnimatedVisibility

Ordre des modificateurs

Avec Modifier.sharedElement() et Modifier.sharedBounds(), l'ordre de la chaîne de modificateurs est important, comme pour le reste de Compose. Un emplacement incorrect des modificateurs affectant la taille peut entraîner des sauts visuels inattendus lors de la mise en correspondance des éléments partagés.

Par exemple, si vous placez un modificateur de marge intérieure à une autre position sur deux éléments partagés, une différence visuelle apparaît dans l'animation.

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

Limites correspondantes

Limites non correspondantes : remarquez que l'animation de l'élément partagé semble un peu décalée, car elle doit être redimensionnée pour s'adapter aux limites incorrectes.

Les modificateurs utilisés avant les modificateurs d'élément partagé fournissent des contraintes aux modificateurs d'élément partagé, qui sont ensuite utilisées pour déduire les limites initiale et cible, puis l'animation des limites.

Les modificateurs utilisés après les modificateurs d'élément partagé utilisent les contraintes précédentes pour mesurer et calculer la taille de la cible de l'élément enfant. Les modificateurs d'éléments partagés créent une série de contraintes animées pour transformer progressivement l'élément enfant de la taille initiale à la taille cible.

La seule exception est si vous utilisez resizeMode = ScaleToBounds() pour l'animation ou Modifier.skipToLookaheadSize() sur un composable. Dans ce cas, Compose met en page l'enfant à l'aide des contraintes cibles et utilise un facteur de mise à l'échelle pour effectuer l'animation au lieu de modifier la taille de la mise en page elle-même.

Clés uniques

Lorsque vous travaillez avec des éléments partagés complexes, il est recommandé de créer une clé qui n'est pas une chaîne, car les chaînes peuvent être sujettes aux erreurs. Chaque clé doit être unique pour que des correspondances puissent être établies. Par exemple, dans Jetsnack, nous avons les éléments partagés suivants :

Figure 7. Image montrant Jetsnack avec des annotations pour chaque partie de l'interface utilisateur.

Vous pouvez créer une énumération pour représenter le type d'élément partagé. Dans cet exemple, l'intégralité de la fiche d'en-cas peut également apparaître à plusieurs endroits sur l'écran d'accueil, par exemple dans les sections "Populaires" et "Recommandées". Vous pouvez créer une clé comportant le snackId, le origin ("Populaire/Recommandé") et l'type de l'élément partagé qui sera partagé:

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

Les classes de données sont recommandées pour les clés, car elles implémentent hashCode() et isEquals().

Gérer manuellement la visibilité des éléments partagés

Si vous n'utilisez pas AnimatedVisibility ou AnimatedContent, vous pouvez gérer vous-même la visibilité de l'élément partagé. Utilisez Modifier.sharedElementWithCallerManagedVisibility() et fournissez votre propre expression conditionnelle qui détermine quand un élément doit être visible ou non:

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

Limites actuelles

Ces API présentent certaines limites. Plus précisément :

  • L'interopérabilité entre les vues et Compose n'est pas compatible. Cela inclut tout composable qui encapsule AndroidView, comme un Dialog.
  • Les animations automatiques ne sont pas compatibles avec les éléments suivants :
    • Composables d'image partagés :
      • ContentScale n'est pas animé par défaut. Il s'ancre à la fin définie ContentScale.
    • Découpage des formes : il n'existe pas de prise en charge intégrée pour l'animation automatique entre les formes (par exemple, l'animation d'un carré à un cercle lors de la transition de l'élément).
    • Pour les cas non acceptés, utilisez Modifier.sharedBounds() au lieu de sharedElement() et ajoutez Modifier.animateEnterExit() aux éléments.