Animation Jetpack Compose

1. Présentation

ea1442f28b3c3b39.png

Dernière mise à jour : 25/01/2021

Dans cet atelier de programmation, vous allez apprendre à utiliser des API d'animation dans Jetpack Compose.

Jetpack Compose est un kit d'interface utilisateur moderne conçu pour simplifier le développement de l'interface utilisateur. Si vous n'avez jamais utilisé Jetpack Compose, nous vous recommandons d'essayer plusieurs ateliers de programmation avant celui-ci.

Points abordés

  • Utilisation de plusieurs API Animation de base
  • Quand utiliser une API ?

Prerequisites

Prérequis

2. Configuration

Téléchargez le code de l'atelier de programmation. Vous pouvez cloner le dépôt comme suit:

$ git clone git@github.com:googlecodelabs/android-compose-codelabs.git

Vous pouvez également télécharger le fichier ZIP.

Importez le projet AnimationCodelab dans Android Studio.

7a7c10526864d5c2.png

Le projet comporte plusieurs modules:

  • start est l'état initial de l'atelier de programmation.
  • finished correspond à l'état final de l'application une fois cet atelier de programmation terminé.

Assurez-vous que la règle start

est sélectionné dans le menu déroulant de la configuration d'exécution.

39b7acb33706a9b.png

Nous commencerons à travailler sur plusieurs scénarios d'animation dans le chapitre suivant. Chaque extrait de code sur lequel nous travaillons dans cet atelier de programmation est marqué par un commentaire // TODO. Une bonne astuce consiste à ouvrir la fenêtre de l'outil TODO dans Android Studio et à accéder à chacun des commentaires TODO du chapitre.

C4a2180b956cad9f.png

3. Animer un changement de valeur simple

Commençons par l'API Animation la plus simple dans Compose.

Exécutez la configuration start et essayez de changer d'onglet en cliquant sur les boutons "Home" (Accueil) et "Work&quot" (Travail) en haut de la page. Le contenu de l'onglet n'est pas vraiment modifié, mais vous pouvez constater que la couleur d'arrière-plan du contenu change.

Cliquez sur TODO 1 (TODO) : dans la fenêtre de l'outil TODO, découvrez comment cela fonctionne. Il est composable dans Home.

val backgroundColor = if (tabPage == TabPage.Home) Purple100 else Green300

Ici, tabPage est un Int reposant sur un objet State. Selon sa valeur, l'arrière-plan passe du violet au vert. Nous voulons animer ce changement de valeur.

Afin d'animer un tel changement de valeur, nous pouvons utiliser les API animate*AsState. Vous pouvez créer une valeur d'animation en encapsulant simplement la valeur qui change avec la variante correspondante des animate*AsState éléments composables (animateColorAsState dans ce cas). La valeur renvoyée est un objet State<T>. Nous pouvons donc utiliser une propriété déléguée locale avec une déclaration by pour la traiter comme une variable normale.

val backgroundColor by animateColorAsState(if (tabPage == TabPage.Home) Purple100 else Green300)

Exécutez à nouveau l'application et essayez de changer d'onglet. Le changement de couleur est animé.

6946feb45acc2cc6.gif

4. Visibilité de l'animation

Si vous faites défiler le contenu de l'application, vous remarquerez que le bouton d'action flottant se développe et réduit en fonction de la direction de votre défilement.

Consultez la section TODO 2-1 (À FAIRE 2-1) et découvrez comment cela fonctionne. Il est composable dans HomeFloatingActionButton. Le texte indiquant "EDIT&quot" est affiché ou masqué à l'aide d'une instruction if.

if (extended) {
    Text(
        text = stringResource(R.string.edit),
        modifier = Modifier
            .padding(start = 8.dp, top = 3.dp)
    )
}

Pour animer ce changement de visibilité, il suffit de remplacer if par un composable AnimatedVisibility.

AnimatedVisibility(extended) {
    Text(
        text = stringResource(R.string.edit),
        modifier = Modifier
            .padding(start = 8.dp, top = 3.dp)
    )
}

Exécutez l'application et découvrez comment le bouton d'action flottant se développe et se réduit maintenant.

37a613b87156bfbe.gif

AnimatedVisibility exécute son animation chaque fois que la valeur Boolean spécifiée change. Par défaut, AnimatedVisibility affiche l'élément en faisant un fondu et un expansion, et le masque en faisant un fondu et un rétrécissement. Ce comportement est particulièrement efficace dans cet exemple avec le bouton d'action flottant, mais nous pouvons également le personnaliser.

Cliquez sur le bouton d'action flottant pour afficher le message "La fonctionnalité de modification n'est pas disponible". Il utilise également AnimatedVisibility pour animer son apparence et sa disparition. Voyons comment personnaliser l'animation de sorte que l'élément s'affiche en haut et qu'il glisse vers le haut.

11D77a9c6af0309c.png

Recherchez TODO 2-2 (À FAIRE 2-2) et consultez le code dans le composable EditMessage.

AnimatedVisibility(
    visible = shown
) {
    Surface(
        modifier = Modifier.fillMaxWidth(),
        color = MaterialTheme.colors.secondary,
        elevation = 4.dp
    ) {
        Text(
            text = stringResource(R.string.edit_message),
            modifier = Modifier.padding(16.dp)
        )
    }
}

Pour personnaliser l'animation, ajoutez les paramètres enter et exit aux éléments composables AnimatedVisibility.

Le paramètre enter doit être une instance de EnterTransition. Dans cet exemple, nous pouvons utiliser la fonction slideInVertically pour créer un EnterTransition. Cette fonction permet de personnaliser davantage les paramètres initialOffsetY et animationSpec. Le initialOffsetY doit être un lambda renvoyant la position initiale. Le lambda reçoit un argument, la hauteur de l'élément, que nous pouvons donc simplement renvoyer sa valeur négative. Lorsque vous utilisez slideInVertically, le décalage cible pour une diapositive après la diapositive est toujours de 0 (pixel). initialOffsetY peut être spécifié en tant que valeur absolue ou pourcentage de la hauteur totale de l'élément via une fonction lambda.

animationSpec est un paramètre courant pour de nombreuses API d'animation, y compris EnterTransition et ExitTransition. Nous pouvons transmettre l'un des différents types AnimationSpec pour indiquer l'évolution de la valeur d'animation au fil du temps. Dans cet exemple, nous allons utiliser un élément AnimationSpec simple basé sur la durée. Vous pouvez le créer avec la fonction tween. La durée est de 150 ms et le lissage de vitesse est de LinearOutSlowInEasing.

De même, nous pouvons utiliser la fonction slideOutVertically pour le paramètre exit. slideOutVertically suppose que le décalage initial est de 0. Il suffit donc de spécifier targetOffsetY. Utilisons la même fonction tween pour le paramètre animationSpec, mais avec une durée de 250 ms et un lissage de vitesse de FastOutLinearInEasing.

Le code obtenu doit se présenter comme suit :

AnimatedVisibility(
    visible = shown,
    enter = slideInVertically(
        // Enters by sliding down from offset -fullHeight to 0.
        initialOffsetY = { fullHeight -> -fullHeight },
        animationSpec = tween(durationMillis = 150, easing = LinearOutSlowInEasing)
    ),
    exit = slideOutVertically(
        // Exits by sliding up from offset 0 to -fullHeight.
        targetOffsetY = { fullHeight -> -fullHeight },
        animationSpec = tween(durationMillis = 250, easing = FastOutLinearInEasing)
    )
) {
    Surface(
        modifier = Modifier.fillMaxWidth(),
        color = MaterialTheme.colors.secondary,
        elevation = 4.dp
    ) {
        Text(
            text = stringResource(R.string.edit_message),
            modifier = Modifier.padding(16.dp)
        )
    }
}

Exécutez l'application et cliquez de nouveau sur le bouton d'action flottant. Comme vous pouvez le constater, le message s'affiche maintenant en haut et à droite.

76895615b43b9263.gif

5. Animation de la modification de la taille du contenu

L'application affiche plusieurs sujets dans le contenu. Cliquez sur l'une d'elles pour afficher le corps du texte correspondant à ce thème. La fiche qui contient le texte se développe et réduit lorsque le corps est affiché ou masqué.

Consultez le code de TODO 3 dans le composant TopicRow.

Column(
    modifier = Modifier
        .fillMaxWidth()
        .padding(16.dp)
) {
    // ... the title and the body
}

L'élément Column composable change de taille à mesure que son contenu est modifié. Nous pouvons animer le changement de taille en ajoutant le modificateur animateContentSize.

Column(
    modifier = Modifier
        .fillMaxWidth()
        .padding(16.dp)
        .animateContentSize()
) {
    // ... the title and the body
}

Exécutez l'application, puis cliquez sur l'un des sujets. L'animation se réduit et se réduit sous forme d'animation.

C0ad7381779fcb09.gif

6. Animation de plusieurs valeurs

Maintenant que nous connaissons quelques API de base pour l'animation, examinons l'API Transition qui permet de créer des animations plus complexes. Dans cet exemple, nous personnalisons l'indicateur d'onglet. Il s'agit d'un rectangle affiché dans l'onglet sélectionné.

Recherchez TODO 4 dans le composant Possibilité de lire HomeTabIndicator et observez comment l'indicateur d'onglet est implémenté.

val indicatorLeft = tabPositions[tabPage.ordinal].left
val indicatorRight = tabPositions[tabPage.ordinal].right
val color = if (tabPage == TabPage.Home) Purple700 else Green800

Ici, indicatorLeft correspond à la position horizontale du bord gauche de l'indicateur sur la ligne d'onglet. indicatorRight est la position horizontale du bord droit de l'indicateur. La couleur passe également du violet au vert.

Pour animer ces différentes valeurs simultanément, nous pouvons utiliser une Transition. Vous pouvez créer un Transition avec la fonction updateTransition. Transmettez l'index de l'onglet actuellement sélectionné en tant que paramètre targetState.

Chaque valeur d'animation peut être déclarée à l'aide des fonctions d'extension animate* de Transition. Dans cet exemple, nous utilisons animateDp et animateColor. Un bloc lambda est spécifié, et nous pouvons indiquer la valeur cible pour chacun des États. Nous savons déjà à quoi doivent ressembler les valeurs cibles. Il est donc possible d'encapsuler les valeurs comme indiqué ci-dessous. Notez que vous pouvez utiliser une déclaration by pour la convertir en propriété locale, car les fonctions animate* renvoient un objet State.

val transition = updateTransition(tabPage)
val indicatorLeft by transition.animateDp { page ->
    tabPositions[page.ordinal].left
}
val indicatorRight by transition.animateDp { page ->
    tabPositions[page.ordinal].right
}
val color by transition.animateColor { page ->
    if (page == TabPage.Home) Purple700 else Green800
}

Exécutez l'application et vous constaterez que le changement d'onglet est désormais beaucoup plus intéressant. Lorsque l'utilisateur clique sur l'onglet, il modifie la valeur de l'état tabPage. Par conséquent, toutes les valeurs d'animation associées à transition commencent à s'animer pour la valeur spécifiée pour l'état cible.

3262270d174e77bf.gif

Vous pouvez également spécifier le paramètre transitionSpec pour personnaliser le comportement de l'animation. Par exemple, pour obtenir un effet élastique pour l'indicateur, le bord se rapproche plus rapidement de l'autre bord. Nous pouvons utiliser la fonction infix isTransitioningTo dans les lambdas transitionSpec pour déterminer le sens du changement d'état.

val transition = updateTransition(
    tabPage,
    label = "Tab indicator"
)
val indicatorLeft by transition.animateDp(
    transitionSpec = {
        if (TabPage.Home isTransitioningTo TabPage.Work) {
            // Indicator moves to the right.
            // The left edge moves slower than the right edge.
            spring(stiffness = Spring.StiffnessVeryLow)
        } else {
            // Indicator moves to the left.
            // The left edge moves faster than the right edge.
            spring(stiffness = Spring.StiffnessMedium)
        }
    },
    label = "Indicator left"
) { page ->
    tabPositions[page.ordinal].left
}
val indicatorRight by transition.animateDp(
    transitionSpec = {
        if (TabPage.Home isTransitioningTo TabPage.Work) {
            // Indicator moves to the right
            // The right edge moves faster than the left edge.
            spring(stiffness = Spring.StiffnessMedium)
        } else {
            // Indicator moves to the left.
            // The right edge moves slower than the left edge.
            spring(stiffness = Spring.StiffnessVeryLow)
        }
    },
    label = "Indicator right"
) { page ->
    tabPositions[page.ordinal].right
}
val color by transition.animateColor(
    label = "Border color"
) { page ->
    if (page == TabPage.Home) Purple700 else Green800
}

Exécutez à nouveau l'application et essayez de changer d'onglet.

2ad4adbefce04ae2.gif

Android Studio permet d'inspecter la transition dans la fonctionnalité Rédaction d'aperçu. Pour utiliser l'aperçu de l'animation, démarrez le mode interactif en cliquant sur l'icône "Démarrer le mode interactif" dans l'angle supérieur droit d'un aperçu composite. Activez-la dans les paramètres du test en suivant ces instructions si vous ne la trouvez pas. Essayez de cliquer sur l'icône de l'élément PreviewHomeTabBar. Cliquez ensuite sur l'icône Démarrer l'inspection de l'animation en haut à droite du mode interactif. Une nouvelle fenêtre s'ouvre.

Pour lancer l'animation, cliquez sur l'icône de lecture. Vous pouvez également faire glisser la barre de recherche pour afficher chacune des images d'animation. Pour une meilleure description des valeurs d'animation, vous pouvez spécifier le paramètre label dans updateTransition et les méthodes animate*.

2D3C5020ae28120b.png

7. Animation répétée

Essayez de cliquer sur le bouton d'actualisation situé à côté de la température actuelle. L'application commence à charger les informations météo les plus récentes (elle se fait passer). Tant que le chargement n'est pas terminé, un indicateur de chargement s'affiche (un cercle gris et une barre). Nous allons animer la valeur alpha de cet indicateur pour clarifier que le processus est en cours.

C2912ddc2d73bdfc.png

Recherchez TODO 5 dans le composable LoadingRow.

val alpha = 1f

Nous souhaitons que cette valeur soit animée entre 0f et 1f à plusieurs reprises. Nous pouvons utiliser InfiniteTransition à cette fin. Cette API est semblable à l'API Transition de la section précédente. Elles permettent toutes les deux d'animer plusieurs valeurs, mais tandis que Transition anime les valeurs en fonction des changements d'état, InfiniteTransition anime les valeurs indéfiniment.

Pour créer une InfiniteTransition, utilisez la fonction rememberInfiniteTransition. Chaque modification de valeur d'animation peut ensuite être déclarée à l'aide de l'une des fonctions animate*d'extension InfiniteTransition. Dans le cas présent, nous animons une valeur alpha. Nous allons donc utiliser animatedFloat. Le paramètre initialValue doit être 0f et targetValue 1f. Nous pouvons également spécifier un AnimationSpec pour cette animation, mais cette API n'accepte qu'un InfiniteRepeatableSpec. Utilisez la fonction infiniteRepeatable pour en créer un. Ce AnimationSpec encapsule tous les AnimationSpec basés sur la durée et les rend reproductibles. Par exemple, le code obtenu doit se présenter comme suit.

val infiniteTransition = rememberInfiniteTransition()
val alpha by infiniteTransition.animateFloat(
    initialValue = 0f,
    targetValue = 1f,
    animationSpec = infiniteRepeatable(
        animation = keyframes {
            durationMillis = 1000
            0.7f at 500
        },
        repeatMode = RepeatMode.Reverse
    )
)

Exécutez l'application et essayez de cliquer sur le bouton "Actualiser". L'indicateur de chargement s'anime.

ca4d1d5bfe87b2a9.gif

8. Animation par gestes

Dans cette dernière section, vous allez découvrir comment exécuter des animations à partir de commandes tactiles. Il existe plusieurs éléments uniques à prendre en compte dans ce scénario. D'abord, toute animation en cours peut être interceptée par un événement tactile. Ensuite, la valeur de l'animation n'est peut-être pas la seule source d'informations fiables. En d'autres termes, nous devrons peut-être synchroniser la valeur de l'animation avec les valeurs provenant des événements tactiles.

Recherchez TODO 6-1 dans le modificateur swipeToDismiss. Ici, nous essayons de créer un modificateur qui permet de balayer l'élément avec le toucher. Lorsque l'élément est jeté vers le bord de l'écran, nous appelons le rappel onDismissed pour qu'il puisse être supprimé.

Animatable est l'API de niveau inférieur que nous avons vue jusqu'à présent. Cet outil présente plusieurs fonctionnalités utiles qui permettent de créer des instances de Animatable et de les utiliser pour représenter le décalage horizontal de l'élément à faire glisser.

val offsetX = remember { Animatable(0f) } // Add this line
pointerInput {
    // Used to calculate a settling position of a fling animation.
    val decay = splineBasedDecay<Float>(this)
    // Wrap in a coroutine scope to use suspend functions for touch events and animation.
    coroutineScope {
        while (true) {
            // ...

C'est là que nous venons de recevoir un événement Retoucher 6-2. Nous devons intercepter l'animation si elle est en cours d'exécution. Pour ce faire, appelez stop sur Animatable. Notez que l'appel est ignoré si l'animation n'est pas en cours d'exécution.

// Wait for a touch down event.
val pointerId = awaitPointerEventScope { awaitFirstDown().id }
offsetX.stop() // Add this line
// Prepare for drag events and record velocity of a fling.
val velocityTracker = VelocityTracker()
// Wait for drag events.
awaitPointerEventScope {

Nous recevons continuellement des événements de déplacement dans TODO 6-3. Nous devons synchroniser la position de l'événement tactile dans la valeur de l'animation. Nous pouvons utiliser snapTo sur Animatable.

horizontalDrag(pointerId) { change ->
    // Add these 4 lines
    val horizontalDragOffset = offsetX.value + change.positionChange().x
    launch {
        offsetX.snapTo(horizontalDragOffset)
    }
    // Record the velocity of the drag.
    velocityTracker.addPosition(change.uptimeMillis, change.position)
    // Consume the gesture event, not passed to external
    change.consumePositionChange()
}

L'expression TODO 6-4 désigne l'endroit où l'élément vient d'être libéré et projeté. Nous devons calculer la position finale de l'inclinaison afin de déterminer si nous devons faire glisser l'élément vers la position d'origine, ou le faire glisser et appeler le rappel.

// Dragging finished. Calculate the velocity of the fling.
val velocity = velocityTracker.calculateVelocity().x
// Add this line
val targetOffsetX = decay.calculateTargetValue(offsetX.value, velocity)

À TODO 6-5, nous allons lancer l'animation. Avant cela, nous voulons définir les limites de valeur supérieure et inférieure sur le Animatable afin qu'il s'arrête dès qu'il atteint ces limites. Le modificateur pointerInput nous permet d'accéder à la taille de l'élément par la propriété size. Utilisez donc cette valeur pour obtenir nos limites.

offsetX.updateBounds(
    lowerBound = -size.width.toFloat(),
    upperBound = size.width.toFloat()
)

Enfin, c'est au niveau du commentaire TODO 6-6 que nous pouvons commencer notre animation. Nous allons d'abord comparer la position du terrain de l'élément calculé précédemment et la taille de l'élément. Si la position du règlement est inférieure à la taille, cela signifie que la vitesse de chargement n'était pas suffisante. Nous pouvons utiliser animateTo pour animer la valeur sur 0f. Sinon, nous utilisons animateDecay pour lancer l'animation de type Fling. Une fois l'animation terminée (généralement selon les limites définies précédemment), nous pouvons appeler le rappel.

launch {
    if (targetOffsetX.absoluteValue <= size.width) {
        // Not enough velocity; Slide back.
        offsetX.animateTo(targetValue = 0f, initialVelocity = velocity)
    } else {
        // Enough velocity to slide away the element to the edge.
        offsetX.animateDecay(velocity, decay)
        // The element was swiped away.
        onDismissed()
    }
}

Enfin, consultez TODO 6-7. Toutes les animations et tous les gestes sont configurés. N'oubliez pas d'appliquer le décalage à l'élément.

.offset { IntOffset(offsetX.value.roundToInt(), 0) }

Après cette section, vous vous retrouverez avec le code suivant:

private fun Modifier.swipeToDismiss(
    onDismissed: () -> Unit
): Modifier = composed {
    // This `Animatable` stores the horizontal offset for the element.
    val offsetX = remember { Animatable(0f) }
    pointerInput(Unit) {
        // Used to calculate a settling position of a fling animation.
        val decay = splineBasedDecay<Float>(this)
        // Wrap in a coroutine scope to use suspend functions for touch events and animation.
        coroutineScope {
            while (true) {
                // Wait for a touch down event.
                val pointerId = awaitPointerEventScope { awaitFirstDown().id }
                // Interrupt any ongoing animation.
                offsetX.stop()
                // Prepare for drag events and record velocity of a fling.
                val velocityTracker = VelocityTracker()
                // Wait for drag events.
                awaitPointerEventScope {
                    horizontalDrag(pointerId) { change ->
                        // Record the position after offset
                        val horizontalDragOffset = offsetX.value + change.positionChange().x
                        launch {
                            // Overwrite the `Animatable` value while the element is dragged.
                            offsetX.snapTo(horizontalDragOffset)
                        }
                        // Record the velocity of the drag.
                        velocityTracker.addPosition(change.uptimeMillis, change.position)
                        // Consume the gesture event, not passed to external
                        change.consumePositionChange()
                    }
                }
                // Dragging finished. Calculate the velocity of the fling.
                val velocity = velocityTracker.calculateVelocity().x
                // Calculate where the element eventually settles after the fling animation.
                val targetOffsetX = decay.calculateTargetValue(offsetX.value, velocity)
                // The animation should end as soon as it reaches these bounds.
                offsetX.updateBounds(
                    lowerBound = -size.width.toFloat(),
                    upperBound = size.width.toFloat()
                )
                launch {
                    if (targetOffsetX.absoluteValue <= size.width) {
                        // Not enough velocity; Slide back to the default position.
                        offsetX.animateTo(targetValue = 0f, initialVelocity = velocity)
                    } else {
                        // Enough velocity to slide away the element to the edge.
                        offsetX.animateDecay(velocity, decay)
                        // The element was swiped away.
                        onDismissed()
                    }
                }
            }
        }
    }
        // Apply the horizontal offset to the element.
        .offset { IntOffset(offsetX.value.roundToInt(), 0) }
}

Exécutez l'application et essayez de balayer l'un des éléments de la tâche. Vous pouvez constater que l'élément glisse à nouveau vers la position par défaut ou disparaît en fonction de la vitesse de chargement. Vous pouvez également attraper l'élément pendant l'animation.

7cdefce823f6b9bd.png

9. Félicitations !

Félicitations ! Vous connaissez maintenant les API Compose Animation de base.

Nous avons appris à créer plusieurs formats d'animation courants à l'aide d'API d'animation de haut niveau, telles que animateContentSize et AnimatedVisibility. Nous avons également appris que nous pouvons utiliser animate*AsState pour animer une seule valeur, updateTransition pour animer plusieurs valeurs et infiniteTransition pour animer des valeurs indéfiniment. Nous avons également utilisé Animatable pour créer une animation personnalisée en combinaison avec des gestes tactiles.

Étapes suivantes

Découvrez les autres ateliers de programmation dans le parcours de rédaction.

Pour en savoir plus, consultez la section Composer des animations et consultez ces documents de référence: