Les transitions entre éléments partagés permettent de passer de manière fluide d'un composable à un autre lorsque leur contenu est cohérent. Elles sont souvent utilisées pour la navigation, car elles vous permettent 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 du snack sont partagés entre la page de la liste et la page de détails.
Dans Compose, il existe quelques API de haut niveau qui vous aident à créer des éléments partagés :
SharedTransitionLayout: mise en page la plus externe requise pour implémenter des transitions entre éléments partagés. Elle fournit unSharedTransitionScope. Les composables doivent se trouver dans unSharedTransitionScopepour utiliser les modificateurs d'éléments partagés.Modifier.sharedElement(): modificateur qui signale auSharedTransitionScopele composable qui doit correspondre à un autre composable.Modifier.sharedBounds(): modificateur qui signale auSharedTransitionScopeque les limites de ce composable doivent être utilisées comme limites de conteneur pour l'emplacement où la transition doit avoir lieu. Contrairement àsharedElement(),sharedBounds()est conçu pour un contenu visuellement différent.
Lorsque vous créez des éléments partagés dans Compose, il est important de comprendre comment ils fonctionnent avec les superpositions et le découpage. Pour en savoir plus sur ce sujet important, consultez la section Découpage et superpositions.
Utilisation de base
La transition suivante sera créée dans cette section. Elle passe d'un élément de liste plus petit à un élément détaillé plus grand :
La meilleure façon d'utiliser Modifier.sharedElement() est de l'associer à
AnimatedContent, AnimatedVisibility ou NavHost, car cela gère
automatiquement la transition entre les composables.
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 :
AnimatedContent de départ sans aucune transition entre éléments partagés.Pour que les éléments partagés s'animent entre les deux mises en page, entourez le composable
AnimatedContentavecSharedTransitionLayout. Les champs d'application deSharedTransitionLayoutetAnimatedContentsont transmis àMainContentetDetailsContent: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 ) } } }
Ajoutez
Modifier.sharedElement()à votre chaîne de modificateurs composables sur les deux composables correspondants. Créez unSharedContentStateobjet et mémorisez-le avecrememberSharedContentState(). L'objetSharedContentStatestocke la clé unique qui détermine les éléments partagés. Fournissez une clé unique pour identifier le contenu et utilisezrememberSharedContentState()pour que l'élément soit mémorisé. LeAnimatedContentScopeest transmis au modificateur, qui est utilisé pour 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é s'est produite, extrayez rememberSharedContentState() dans une variable et interrogez isMatchFound.
Cela génère l'animation automatique suivante :
Vous remarquerez peut-être que la couleur d'arrière-plan et la taille de l'ensemble du conteneur utilisent toujours les paramètres AnimatedContent par défaut.
Limites partagées et éléments partagés
Modifier.sharedBounds() est semblable à Modifier.sharedElement().
Toutefois, les modificateurs sont différents :
sharedBounds()est destiné à un contenu visuellement différent, mais qui doit partager la même zone entre les états, tandis quesharedElement()s'attend à ce que le contenu soit identique.- Avec
sharedBounds(), le contenu qui entre et sort de l'écran est visible pendant la transition entre les deux états, tandis qu'avecsharedElement(), seul le contenu cible est rendu dans les limites de transformation.Modifier.sharedBounds()comporte des paramètresenteretexitpour spécifier le mode de transition du contenu, commeAnimatedContent. - Le cas d'utilisation le plus courant pour
sharedBounds()est le schéma de transformation de conteneur, tandis que poursharedElement(), l'exemple d'utilisation est une transition de héros. - Lorsque vous utilisez des composables
Text,sharedBounds()est préférable pour prendre en charge les modifications de police, telles que le passage de l'italique au gras ou les changements de couleur.
À partir de l'exemple précédent, l'ajout de 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 se développer entre elles :
@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() ) // ... ) { // ... } } }
Comprendre les portées
Pour utiliser Modifier.sharedElement(), le composable doit se trouver dans un SharedTransitionScope. Le composable SharedTransitionLayout fournit le SharedTransitionScope. Veillez à placer au même point de niveau supérieur dans votre hiérarchie d'UI que celui qui contient les éléments que vous souhaitez partager.
En règle générale, les composables doivent également être placés dans un AnimatedVisibilityScope. Cela est généralement fourni en utilisant AnimatedContent
pour basculer entre les composables ou lorsque vous utilisez AnimatedVisibility directement, ou par
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
CompositionLocal, utilisez des récepteurs de contexte dans Kotlin ou transmettez les
champs d'application en tant que paramètres à vos fonctions.
Utilisez CompositionLocals dans le cas où vous avez plusieurs champs d'application à suivre ou une hiérarchie profondément imbriquée. Un CompositionLocal vous permet de choisir les champs d'application exacts à enregistrer et à utiliser. En revanche, 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 avez plusieurs AnimatedContent imbriqués, les champs d'application peuvent être remplacés.
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 paresseuse, chaque élément est encapsulé dans AnimatedVisibility. Lorsque l'utilisateur clique sur l'élément, le contenu a l'effet visuel d'être extrait de l'UI 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( sharedContentState = rememberSharedContentState(key = snack.name), animatedVisibilityScope = this@AnimatedVisibility ), onClick = { selectedSnack = snack } ) } } } } // Contains matching AnimatedContent with sharedBounds modifiers. SnackEditDetails( snack = selectedSnack, onConfirmClick = { selectedSnack = null } ) }
AnimatedVisibility.Ordre des modificateurs
Avec Modifier.sharedElement() et Modifier.sharedBounds(), l'ordre de votre
chaîne de modificateurs est important,
comme pour le reste de Compose. Un placement incorrect des modificateurs affectant la taille peut entraîner des sauts visuels inattendus lors de la correspondance des éléments partagés.
Par exemple, si vous placez un modificateur de remplissage à un emplacement différent sur deux éléments partagés, l'animation présente une différence visuelle.
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 : notez que l'animation de l'élément partagé semble un peu décalée, car elle doit être redimensionnée aux limites incorrectes. |
|---|---|
Les modificateurs utilisés avant les modificateurs d'éléments partagés fournissent des contraintes à ces derniers, qui sont ensuite utilisés pour dériver les limites initiales et cibles, puis l'animation des limites.
Les modificateurs utilisés après les modificateurs d'éléments partagés utilisent les contraintes précédentes pour mesurer et calculer la taille cible de l'enfant. Les modificateurs d'éléments partagés créent une série de contraintes animées pour transformer progressivement l'enfant de la taille initiale à la taille cible.
L'exception à cette règle se produit si vous utilisez resizeMode = ScaleToBounds() pour l'animation ou Modifier.skipToLookaheadSize() sur un composable. Dans ce cas, Compose dispose l'enfant à l'aide des contraintes cibles et utilise plutôt un facteur de scaling 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 de correspondance. Chaque clé doit être unique pour que des correspondances se produisent. Par exemple, dans Jetsnack, nous avons les éléments partagés suivants :
Vous pouvez créer une énumération pour représenter le type d'élément partagé. Dans cet exemple, la carte de snack entière peut également apparaître à plusieurs endroits différents de l'écran d'accueil, par exemple dans une section "Populaire" et une section "Recommandé". Vous pouvez créer une clé qui comporte le snackId, l'origin ("Populaire" / "Recommandé") et le 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 ni AnimatedContent, vous pouvez gérer vous-même la visibilité des éléments partagés. Utilisez Modifier.sharedElementWithCallerManagedVisibility() et fournissez votre propre condition qui détermine si 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 quelques limites. Les plus notables sont les suivantes :
- Aucune interopérabilité entre les vues et Compose n'est prise en charge. Cela inclut tout composable qui encapsule
AndroidView, tel qu'unDialogou unModalBottomSheet. - L'animation automatique n'est pas prise en charge pour les éléments suivants :
- Composables d'images partagées:
ContentScalen'est pas animé par défaut. Il s'aligne sur leContentScalede fin défini.
- Découpage de formes : aucune prise en charge intégrée de l'animation automatique entre les formes (par exemple, l'animation d'un carré vers un cercle lors de la transition de l'élément).
- Pour les cas non compatibles, utilisez
Modifier.sharedBounds()au lieu desharedElement()et ajoutezModifier.animateEnterExit()aux éléments.
- Composables d'images partagées: