Les transitions d'éléments partagés permettent une transition fluide 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 d'un écran à 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 de la page de la fiche vers la page d'informations.
Dans Compose, quelques API de haut niveau vous permettent de 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 unSharedTransitionScope
. Les composables doivent se trouver dans unSharedTransitionScope
pour utiliser les modificateurs d'élément partagé.Modifier.sharedElement()
: modificateur qui signale àSharedTransitionScope
le composable qui doit être mis en correspondance avec un autre composable.Modifier.sharedBounds()
: modificateur qui indique àSharedTransitionScope
que les limites de ce composable doivent être utilisées comme limites du conteneur pour l'endroit où la transition doit avoir lieu. Contrairement àsharedElement()
,sharedBounds()
est conçu pour des contenus visuellement différents.
Lorsque vous créez des éléments partagés dans Compose, il est important de comprendre comment ils fonctionnent avec les superpositions et le rognage. Consultez la section Découpage et superpositions pour en savoir plus sur ce sujet important.
Utilisation de base
La transition suivante sera créée dans cette section, passant de l'élément de liste le plus petit à l'élément détaillé plus grand:
La meilleure façon d'utiliser Modifier.sharedElement()
est d'utiliser 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 comportant un composable MainContent
et DetailsContent
avant d'ajouter des éléments partagés:
Pour animer les éléments partagés entre les deux mises en page, entourez le composable
AnimatedContent
avecSharedTransitionLayout
. Les champs d'application deSharedTransitionLayout
etAnimatedContent
sont transmis à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 ) } } }
Ajoutez
Modifier.sharedElement()
à votre chaîne de modificateurs de composables sur les deux composables correspondants. Créez un objetSharedContentState
et mémorisez-le avecrememberSharedContentState()
. L'objetSharedContentState
stocke 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é. La valeurAnimatedContentScope
est transmise 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é a eu lieu, extrayez rememberSharedContentState()
dans une variable, puis interrogez isMatchFound
.
L'animation automatique suivante se produit alors:
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()
.
Toutefois, les modificateurs présentent plusieurs différences:
sharedBounds()
correspond au 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 entrant et sortant de l'écran est visible pendant la transition entre les deux états, tandis qu'avecsharedElement()
, seul le contenu cible est affiché dans les limites de transformation.Modifier.sharedBounds()
comporte les paramètresenter
etexit
pour spécifier la manière dont le contenu doit effectuer la transition, de la même manière queAnimatedContent
fonctionne. - Le cas d'utilisation le plus courant de
sharedBounds()
est le modèle de transformation du conteneur, tandis que poursharedElement()
, l'exemple de cas d'utilisation est une transition principale. - Lorsque vous utilisez des composables
Text
, il est préférable d'utilisersharedBounds()
pour permettre les changements de police, par exemple pour passer de l'italique au gras ou le changement de couleur.
À partir de 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'exécuter l'animation de transition, ce qui leur permettra d'évoluer l'un entre l'autre:
@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 champs d'application
Pour utiliser Modifier.sharedElement()
, le composable doit se trouver dans un SharedTransitionScope
. Le composable SharedTransitionLayout
fournit le SharedTransitionScope
. Veillez à les placer au même niveau supérieur de la hiérarchie de l'interface utilisateur 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
. Il est généralement fourni en utilisant AnimatedContent
pour basculer entre les composables, ou lorsque vous utilisez directement AnimatedVisibility
, ou par la fonction modulable 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 en Kotlin ou transmettez les champs d'application en tant que paramètres à vos fonctions.
Utilisez CompositionLocals
dans le cas où vous devez effectuer le suivi de plusieurs champs d'application ou d'une hiérarchie profondément imbriquée. Un élément 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 éléments 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 ils 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 a l'effet visuel d'être extrait de l'interface utilisateur dans un composant semblable à une 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 } ) }
Ordre des modificateurs
Avec Modifier.sharedElement()
et Modifier.sharedBounds()
, l'ordre de votre chaîne de modificateur est important, comme pour le reste de Compose. Le placement incorrect des modificateurs ayant une incidence sur 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 à un emplacement différent sur deux éléments partagés, l'animation est différente d'un point de vue visuel.
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 sans correspondance: notez que l'animation de l'élément partagé apparaît légèrement décalée, car elle doit être redimensionnée en fonction des limites incorrectes. |
---|---|
Les modificateurs utilisés avant les modificateurs d'élément partagé fournissent des contraintes aux modificateurs d'élément partagé. Ces contraintes permettent ensuite de déterminer les limites initiales et cibles, puis l'animation des limites.
Les modificateurs utilisés après les modificateurs d'élément partagé se servent des contraintes précédentes pour mesurer et calculer la taille 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'enfant de la taille initiale à la taille cible.
Seule exception : si vous utilisez resizeMode = ScaleToBounds()
pour l'animation ou Modifier.skipToLookaheadSize()
sur un composable. Dans ce cas, Compose dispose l'élément enfant en utilisant les contraintes cibles et utilise à la place un facteur d'échelle pour exécuter 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 les correspondances aient lieu. 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 entière de collations peut également apparaître à différents endroits de l'écran d'accueil, par exemple dans une section "Populaires" et "Recommandations". Vous pouvez créer une clé comportant les valeurs snackId
, origin
("Populaire/Recommandé") et 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 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 quelques limites. En particulier:
- Aucune interopérabilité entre les vues et Compose n'est prise en charge. Cela inclut tout composable qui encapsule
AndroidView
, tel qu'unDialog
. - Les animations automatiques ne sont pas compatibles avec les éléments suivants :
- Composables d'images partagées :
ContentScale
n'est pas animé par défaut. Elle s'ancre à l'extrémité définieContentScale
.
- Découpage des formes : il n'existe pas de compatibilité intégrée avec l'animation automatique entre les formes (par exemple, l'animation d'un carré vers un cercle lorsque l'élément passe à un autre point).
- Pour les cas non pris en charge, utilisez
Modifier.sharedBounds()
au lieu desharedElement()
et ajoutezModifier.animateEnterExit()
aux éléments.
- Composables d'images partagées :