Animation

Jetpack Compose fournit des API puissantes et extensibles qui facilitent l'implémentation de diverses animations dans l'interface utilisateur de votre application. Ce document explique comment utiliser ces API et laquelle choisir en fonction de votre scénario d'animation.

Présentation

Dans une application mobile moderne, les animations sont essentielles pour offrir une expérience utilisateur fluide et compréhensible. De nombreuses API Animation Jetpack Compose sont disponibles en tant que fonctions modulables, tout comme les mises en page et d'autres éléments d'interface. Elles reposent sur des API de niveau inférieur intégrant des fonctions de suspension de coroutines Kotlin. Ce guide présente d'abord les API de niveau supérieur utiles dans de nombreux cas pratiques, puis décrit les API de niveau inférieur qui vous offrent davantage de contrôle et une personnalisation plus avancée.

Le diagramme ci-dessous vous aide à décider quelle API utiliser pour implémenter votre animation.

Organigramme décrivant l'arbre de décision pour choisir l'API d'animation appropriée

  • Si vous animez un changement de contenu dans la mise en page :
  • Si l'animation est basée sur l'état :
  • Si vous souhaitez contrôler précisément le timing de l'animation :
    • Utilisez Animation, par exemple TargetBasedAnimation ou DecayAnimation.
  • Si l'animation est la seule source fiable :
  • Sinon, utilisez AnimationState ou animate.

API d'animation de niveau supérieur

Compose propose des API d'animation de niveau supérieur pour plusieurs modèles d'animation couramment utilisés dans de nombreuses applications. Ces API sont conçues pour s'aligner sur les bonnes pratiques du système Material Design Motion.

AnimatedVisibility

Le composable AnimatedVisibility anime l'apparition et la disparition de son contenu.

var editable by remember { mutableStateOf(true) }
AnimatedVisibility(visible = editable) {
    Text(text = "Edit")
}

Par défaut, le contenu apparaît en fondu et en s'agrandissant, et disparaît en fondu et en se rétrécissant. Vous pouvez personnaliser la transition en spécifiant EnterTransition et ExitTransition.

var visible by remember { mutableStateOf(true) }
val density = LocalDensity.current
AnimatedVisibility(
    visible = visible,
    enter = slideInVertically {
        // Slide in from 40 dp from the top.
        with(density) { -40.dp.roundToPx() }
    } + expandVertically(
        // Expand from the top.
        expandFrom = Alignment.Top
    ) + fadeIn(
        // Fade in with the initial alpha of 0.3f.
        initialAlpha = 0.3f
    ),
    exit = slideOutVertically() + shrinkVertically() + fadeOut()
) {
    Text("Hello", Modifier.fillMaxWidth().height(200.dp))
}

Comme vous pouvez le voir dans l'exemple ci-dessus, il est possible de combiner plusieurs objets EnterTransition ou ExitTransition avec un opérateur +. Chaque objet accepte des paramètres facultatifs permettant de personnaliser son comportement. Consultez la documentation de référence pour en savoir plus.

Exemples d'objets EnterTransition et ExitTransition

EnterTransition ExitTransition
fadeIn
animation fadeIn
fadeOut
animation fadeOut
slideIn
animation slideIn
slideOut
animation slideOut
slideInHorizontally
animation slideInHorizontally
slideOutHorizontally
animation slideOutHorizontally
slideInVertically
animation slideInVertically
slideOutVertically
animation slideOutVertically
scaleIn
animation scaleIn
scaleOut
animation scaleOut
expandIn
animation expandIn
shrinkOut
animation shrinkOut
expandHorizontally
animation expandHorizontally
shrinkHorizontally
animation shrinkHorizontally
expandVertically
animation expandVertically
shrinkVertically
animation shrinkVertically

AnimatedVisibility propose également une variante qui accepte un MutableTransitionState. Cela vous permet de déclencher une animation dès que AnimatedVisibility est ajouté à l'arborescence de composition, et aussi d'observer l'état de l'animation.

// Create a MutableTransitionState<Boolean> for the AnimatedVisibility.
val state = remember {
    MutableTransitionState(false).apply {
        // Start the animation immediately.
        targetState = true
    }
}
Column {
    AnimatedVisibility(visibleState = state) {
        Text(text = "Hello, world!")
    }

    // Use the MutableTransitionState to know the current animation state
    // of the AnimatedVisibility.
    Text(
        text = when {
            state.isIdle && state.currentState -> "Visible"
            !state.isIdle && state.currentState -> "Disappearing"
            state.isIdle && !state.currentState -> "Invisible"
            else -> "Appearing"
        }
    )
}

Animer l'entrée et la sortie des enfants

Le contenu dans AnimatedVisibility (enfants directs ou indirects) peut utiliser le modificateur animateEnterExit afin de spécifier un comportement d'animation différent pour chacun d'eux. L'effet visuel pour chacun de ces enfants combine les animations définies dans le composable AnimatedVisibility et les animations d'entrée et de sortie de l'enfant.

AnimatedVisibility(
    visible = visible,
    enter = fadeIn(),
    exit = fadeOut()
) {
    // Fade in/out the background and the foreground.
    Box(Modifier.fillMaxSize().background(Color.DarkGray)) {
        Box(
            Modifier
                .align(Alignment.Center)
                .animateEnterExit(
                    // Slide in/out the inner box.
                    enter = slideInVertically(),
                    exit = slideOutVertically()
                )
                .sizeIn(minWidth = 256.dp, minHeight = 64.dp)
                .background(Color.Red)
        ) {
            // Content of the notification…
        }
    }
}

Dans certains cas, vous souhaiterez peut-être que AnimatedVisibility n'applique aucune animation afin que les enfants puissent avoir leurs propres animations avec animateEnterExit. Pour ce faire, spécifiez EnterTransition.None et ExitTransition.None dans le composable AnimatedVisibility.

Ajouter une animation personnalisée

Si vous souhaitez ajouter des effets d'animation personnalisés en plus des animations d'entrée et de sortie intégrées, accédez à l'instance Transition sous-jacente via la propriété transition dans le lambda de contenu de AnimatedVisibility. Tous les états d'animation ajoutés à l'instance Transition s'exécuteront simultanément avec les animations d'entrée et de sortie de AnimatedVisibility. AnimatedVisibility attend que toutes les animations de Transition soient terminées avant de supprimer son contenu. Si des animations de sortie sont créées indépendamment de Transition (par exemple, à l'aide de animate*AsState), AnimatedVisibility ne pourra pas les prendre en compte. Il risque donc de supprimer le composable de contenu avant la fin de ces animations.

AnimatedVisibility(
    visible = visible,
    enter = fadeIn(),
    exit = fadeOut()
) { // this: AnimatedVisibilityScope
    // Use AnimatedVisibilityScope#transition to add a custom animation
    // to the AnimatedVisibility.
    val background by transition.animateColor { state ->
        if (state == EnterExitState.Visible) Color.Blue else Color.Gray
    }
    Box(modifier = Modifier.size(128.dp).background(background))
}

Pour en savoir plus sur Transition, consultez la section updateTransition.

animate*AsState

Les fonctions animate*AsState comptent parmi les API d'animation les plus simples dans Compose. Elles permettent d'animer une seule valeur. Vous fournissez uniquement la valeur finale (ou la valeur cible), et l'API lance une animation menant de la valeur actuelle à la valeur spécifiée.

L'exemple ci-dessous montre comment animer le niveau alpha avec cette API. En encapsulant simplement la valeur cible dans animateFloatAsState, la valeur alpha devient une valeur d'animation entre les valeurs fournies (1f ou 0.5f dans cet exemple).

val alpha: Float by animateFloatAsState(if (enabled) 1f else 0.5f)
Box(
    Modifier.fillMaxSize()
        .graphicsLayer(alpha = alpha)
        .background(Color.Red)
)

Notez qu'il n'est pas nécessaire de créer une instance de classe d'animation ni de gérer l'interruption. En arrière-plan, un objet d'animation (à savoir, une instance Animatable) sera créé et mémorisé sur le site d'appel, avec comme valeur initiale la première valeur cible. À partir de là, chaque fois que vous fournissez une valeur cible différente à ce composable, une animation est automatiquement lancée vers cette valeur. Si une autre animation est déjà en cours, la nouvelle animation démarre à partir de la valeur actuelle (et avec la vitesse actuelle) et se poursuit vers la valeur cible. Pendant l'animation, ce composable est recomposé et renvoie une valeur d'animation mise à jour pour chaque frame.

Compose fournit directement des fonctions animate*AsState pour Float, Color, Dp, Size, Offset, Rect, Int, IntOffset et IntSize. Vous pouvez facilement prendre en charge d'autres types de données en fournissant un TwoWayConverter à animateValueAsState, qui accepte un type générique.

Il est possible de personnaliser les spécifications de l'animation en fournissant un AnimationSpec. Pour en savoir plus, consultez la section AnimationSpec.

AnimatedContent (expérimental)

Le composable AnimatedContent anime son contenu à mesure qu'il change en fonction d'un état cible.

Row {
    var count by remember { mutableStateOf(0) }
    Button(onClick = { count++ }) {
        Text("Add")
    }
    AnimatedContent(targetState = count) { targetCount ->
        // Make sure to use `targetCount`, not `count`.
        Text(text = "Count: $targetCount")
    }
}

Notez que vous devez toujours utiliser le paramètre lambda et l'appliquer au contenu. L'API utilise cette valeur comme clé pour identifier le contenu actuellement affiché.

Par défaut, le contenu initial disparaît en fondu, puis le contenu cible apparaît en fondu (ce comportement est appelé fondu total). Vous pouvez personnaliser ce comportement d'animation en spécifiant un objet ContentTransform pour le paramètre transitionSpec. Vous pouvez créer ContentTransform en associant un EnterTransition à un ExitTransition à l'aide de la fonction infixe with. Vous pouvez appliquer SizeTransform au ContentTransform en l'associant avec la fonction infixe using.

AnimatedContent(
    targetState = count,
    transitionSpec = {
        // Compare the incoming number with the previous number.
        if (targetState > initialState) {
            // If the target number is larger, it slides up and fades in
            // while the initial (smaller) number slides up and fades out.
            slideInVertically { height -> height } + fadeIn() with
                slideOutVertically { height -> -height } + fadeOut()
        } else {
            // If the target number is smaller, it slides down and fades in
            // while the initial number slides down and fades out.
            slideInVertically { height -> -height } + fadeIn() with
                slideOutVertically { height -> height } + fadeOut()
        }.using(
            // Disable clipping since the faded slide-in/out should
            // be displayed out of bounds.
            SizeTransform(clip = false)
        )
    }
) { targetCount ->
    Text(text = "$targetCount")
}

EnterTransition définit la façon dont le contenu cible doit apparaître, et ExitTransition la manière dont le contenu initial doit disparaître. En plus de toutes les fonctions EnterTransition et ExitTransition disponibles pour AnimatedVisibility, AnimatedContent propose slideIntoContainer et slideOutOfContainer. Il s'agit d'alternatives pratiques à slideInHorizontally/Vertically et slideOutHorizontally/Vertically pour calculer la distance de glissement en fonction de la taille du contenu initial et du contenu cible de AnimatedContent.

SizeTransform définit la manière dont la taille doit être animée entre le contenu initial et le contenu cible. Vous avez accès à la taille initiale et à la taille cible lorsque vous créez l'animation. SizeTransform contrôle également si le contenu doit être rogné et adapté à la taille du composant durant les animations.

var expanded by remember { mutableStateOf(false) }
Surface(
    color = MaterialTheme.colors.primary,
    onClick = { expanded = !expanded }
) {
    AnimatedContent(
        targetState = expanded,
        transitionSpec = {
            fadeIn(animationSpec = tween(150, 150)) with
                fadeOut(animationSpec = tween(150)) using
                SizeTransform { initialSize, targetSize ->
                    if (targetState) {
                        keyframes {
                            // Expand horizontally first.
                            IntSize(targetSize.width, initialSize.height) at 150
                            durationMillis = 300
                        }
                    } else {
                        keyframes {
                            // Shrink vertically first.
                            IntSize(initialSize.width, targetSize.height) at 150
                            durationMillis = 300
                        }
                    }
                }
        }
    ) { targetExpanded ->
        if (targetExpanded) {
            Expanded()
        } else {
            ContentIcon()
        }
    }
}

Animer l'entrée et la sortie des enfants

Tout comme AnimatedVisibility, le modificateur animateEnterExit est disponible dans le lambda de contenu de AnimatedContent. Il vous permet d'appliquer séparément EnterAnimation et ExitAnimation à chaque enfant direct ou indirect.

Ajouter une animation personnalisée

Tout comme AnimatedVisibility, le champ transition est disponible dans le lambda de contenu de AnimatedContent. Il permet de créer un effet d'animation personnalisé qui s'exécute simultanément avec la transition AnimatedContent. Pour en savoir plus, consultez la section updateTransition.

animateContentSize

Le modificateur animateContentSize anime un changement de taille.

var message by remember { mutableStateOf("Hello") }
Box(
    modifier = Modifier.background(Color.Blue).animateContentSize()
) {
    Text(text = message)
}

Crossfade

Crossfade exécute une animation de fondu enchaîné entre deux mises en page. Il permet de remplacer le contenu avec une animation de fondu enchaîné en basculant la valeur transmise au paramètre current.

var currentPage by remember { mutableStateOf("A") }
Crossfade(targetState = currentPage) { screen ->
    when (screen) {
        "A" -> Text("Page A")
        "B" -> Text("Page B")
    }
}

updateTransition

Transition gère une ou plusieurs animations en tant qu'enfants et les exécute simultanément entre plusieurs états.

Tous les types de données sont acceptés pour les états. Bien souvent, vous pouvez utiliser un type enum personnalisé pour assurer la sûreté du typage, comme dans cet exemple :

enum class BoxState {
    Collapsed,
    Expanded
}

updateTransition crée et mémorise une instance de Transition et met à jour son état.

var currentState by remember { mutableStateOf(BoxState.Collapsed) }
val transition = updateTransition(currentState)

Vous pouvez ensuite utiliser une fonction d'extension animate* pour définir une animation enfant dans cette transition. Spécifiez les valeurs cibles pour chaque état. Ces fonctions animate* renvoient une valeur d'animation qui est mise à jour pour chaque frame de l'animation lorsque l'état de transition est actualisé avec updateTransition.

val rect by transition.animateRect { state ->
    when (state) {
        BoxState.Collapsed -> Rect(0f, 0f, 100f, 100f)
        BoxState.Expanded -> Rect(100f, 100f, 300f, 300f)
    }
}
val borderWidth by transition.animateDp { state ->
    when (state) {
        BoxState.Collapsed -> 1.dp
        BoxState.Expanded -> 0.dp
    }
}

Vous pouvez éventuellement transmettre un paramètre transitionSpec afin de spécifier un AnimationSpec différent pour chaque combinaison de changements d'état de transition. Pour en savoir plus, consultez la section AnimationSpec.

val color by transition.animateColor(
    transitionSpec = {
        when {
            BoxState.Expanded isTransitioningTo BoxState.Collapsed ->
                spring(stiffness = 50f)
            else ->
                tween(durationMillis = 500)
        }
    }
) { state ->
    when (state) {
        BoxState.Collapsed -> MaterialTheme.colors.primary
        BoxState.Expanded -> MaterialTheme.colors.background
    }
}

Lorsqu'une transition atteint l'état cible, Transition.currentState est identique à Transition.targetState. Cela permet de déterminer si la transition est terminée.

Parfois, nous avons besoin que l'état initial soit différent du premier état cible. Pour cela, nous pouvons utiliser updateTransition avec MutableTransitionState. Cela nous permet par exemple de démarrer l'animation dès que le code entre dans la composition.

// Start in collapsed state and immediately animate to expanded
var currentState = remember { MutableTransitionState(BoxState.Collapsed) }
currentState.targetState = BoxState.Expanded
val transition = updateTransition(currentState)
// ...

Pour une transition plus complexe impliquant plusieurs fonctions modulables, vous pouvez créer une transition enfant en utilisant createChildTransition. Cette technique permet de séparer les problèmes entre plusieurs sous-composants dans un composable complexe. La transition parente connaît alors toutes les valeurs d'animation des transitions enfants.

enum class DialerState { DialerMinimized, NumberPad }

@Composable
fun DialerButton(isVisibleTransition: Transition<Boolean>) {
    // `isVisibleTransition` spares the need for the content to know
    // about other DialerStates. Instead, the content can focus on
    // animating the state change between visible and not visible.
}

@Composable
fun NumberPad(isVisibleTransition: Transition<Boolean>) {
    // `isVisibleTransition` spares the need for the content to know
    // about other DialerStates. Instead, the content can focus on
    // animating the state change between visible and not visible.
}

@Composable
fun Dialer(dialerState: DialerState) {
    val transition = updateTransition(dialerState)
    Box {
        // Creates separate child transitions of Boolean type for NumberPad
        // and DialerButton for any content animation between visible and
        // not visible
        NumberPad(
            transition.createChildTransition {
                it == DialerState.NumberPad
            }
        )
        DialerButton(
            transition.createChildTransition {
                it == DialerState.DialerMinimized
            }
        )
    }
}

Utiliser AnimatedVisibility et AnimatedContent pour une transition

AnimatedVisibility et AnimatedContent sont disponibles en tant que fonctions d'extension de Transition. Le targetState de Transition.AnimatedVisibility et Transition.AnimatedContent, dérivé de Transition, déclenche au besoin des transitions d'entrée et de sortie lorsque le targetState de Transition change. Ces fonctions d'extension permettent de hisser dans Transition toutes les animations enter/exit/sizeTransform qui seraient autrement contenues dans AnimatedVisibility/AnimatedContent. Grâce à ces fonctions d'extension, le changement d'état de AnimatedVisibility/AnimatedContent peut être observé de l'extérieur. Au lieu d'un paramètre visible booléen, cette version de AnimatedVisibility accepte un lambda qui convertit l'état cible de la transition parente en booléen.

Pour en savoir plus, consultez les sections AnimatedVisibility et AnimatedContent.

var selected by remember { mutableStateOf(false) }
// Animates changes when `selected` is changed.
val transition = updateTransition(selected)
val borderColor by transition.animateColor { isSelected ->
    if (isSelected) Color.Magenta else Color.White
}
val elevation by transition.animateDp { isSelected ->
    if (isSelected) 10.dp else 2.dp
}
Surface(
    onClick = { selected = !selected },
    shape = RoundedCornerShape(8.dp),
    border = BorderStroke(2.dp, borderColor),
    elevation = elevation
) {
    Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
        Text(text = "Hello, world!")
        // AnimatedVisibility as a part of the transition.
        transition.AnimatedVisibility(
            visible = { targetSelected -> targetSelected },
            enter = expandVertically(),
            exit = shrinkVertically()
        ) {
            Text(text = "It is fine today.")
        }
        // AnimatedContent as a part of the transition.
        transition.AnimatedContent { targetState ->
            if (targetState) {
                Text(text = "Selected")
            } else {
                Icon(imageVector = Icons.Default.Phone, contentDescription = "Phone")
            }
        }
    }
}

Encapsuler une transition et la rendre réutilisable

Pour les cas d'utilisation simples, définir des animations de transition dans le même composable que l'interface utilisateur est une option parfaitement valide. Toutefois, lorsque vous travaillez sur un composant complexe comportant plusieurs valeurs animées, il peut être utile de séparer l'implémentation des animations du composable de l'UI.

Pour ce faire, vous devez créer une classe contenant toutes les valeurs d'animation et une fonction "update" renvoyant une instance de cette classe. L'implémentation de la transition peut être extraite dans cette nouvelle fonction. Ce modèle est utile lorsqu'il est nécessaire de centraliser la logique de l'animation ou de rendre des animations complexes réutilisables.

enum class BoxState { Collapsed, Expanded }

@Composable
fun AnimatingBox(boxState: BoxState) {
    val transitionData = updateTransitionData(boxState)
    // UI tree
    Box(
        modifier = Modifier
            .background(transitionData.color)
            .size(transitionData.size)
    )
}

// Holds the animation values.
private class TransitionData(
    color: State<Color>,
    size: State<Dp>
) {
    val color by color
    val size by size
}

// Create a Transition and return its animation values.
@Composable
private fun updateTransitionData(boxState: BoxState): TransitionData {
    val transition = updateTransition(boxState)
    val color = transition.animateColor { state ->
        when (state) {
            BoxState.Collapsed -> Color.Gray
            BoxState.Expanded -> Color.Red
        }
    }
    val size = transition.animateDp { state ->
        when (state) {
            BoxState.Collapsed -> 64.dp
            BoxState.Expanded -> 128.dp
        }
    }
    return remember(transition) { TransitionData(color, size) }
}

rememberInfiniteTransition

InfiniteTransition contient une ou plusieurs animations enfants, telles que Transition, mais elles s'exécutent dès qu'elles entrent dans la composition et ne s'arrêtent que si elles sont supprimées. Vous pouvez créer une instance de InfiniteTransition avec rememberInfiniteTransition. Les animations enfants peuvent être ajoutées avec animateColor, animatedFloat ou animatedValue. Vous devez également ajouter un infiniteRepeatable pour définir les spécifications de l'animation.

val infiniteTransition = rememberInfiniteTransition()
val color by infiniteTransition.animateColor(
    initialValue = Color.Red,
    targetValue = Color.Green,
    animationSpec = infiniteRepeatable(
        animation = tween(1000, easing = LinearEasing),
        repeatMode = RepeatMode.Reverse
    )
)

Box(Modifier.fillMaxSize().background(color))

API d'animation de niveau inférieur

Toutes les API d'animation de niveau supérieur mentionnées dans la section précédente reposent sur les API d'animation de niveau inférieur.

Les fonctions animate*AsState comptent parmi les API les plus simples. Elles permettent de rendre un changement de valeur instantané sous la forme d'une valeur d'animation. Elles reposent sur Animatable, une API basée sur des coroutines permettant d'animer une seule valeur. updateTransition crée un objet de transition capable de gérer plusieurs valeurs d'animation et de les exécuter en fonction d'un changement d'état. La fonction rememberInfiniteTransition est similaire, mais elle crée une transition infinie qui peut gérer plusieurs animations s'exécutant indéfiniment. Ces API sont toutes des composables (sauf Animatable), ce qui signifie que ces animations peuvent être créées en dehors de la composition.

Toutes ces API reposent sur l'API Animation, plus basique. Bien que la plupart des applications n'interagissent pas directement avec Animation, certaines fonctionnalités de personnalisation de Animation sont disponibles via des API de niveau supérieur. Pour en savoir plus sur AnimationVector et AnimationSpec, consultez la section Personnaliser les animations.

Diagramme illustrant la relation entre les différentes API d'animation de niveau inférieur

Animatable

Animatable est un conteneur de valeur qui peut animer la valeur lorsqu'elle est modifiée via animateTo. Cette API sauvegarde l'implémentation de animate*AsState. Cela garantit une continuité cohérente et l'exclusivité mutuelle, ce qui signifie que le changement de valeur est toujours continu et que toute animation en cours sera annulée.

De nombreuses fonctionnalités de Animatable, y compris animateTo, sont fournies en tant que fonctions de suspension. Par conséquent, elles doivent être encapsulées dans une portée de coroutine appropriée. Par exemple, vous pouvez utiliser le composable LaunchedEffect pour créer une portée spécifique pour la durée de la valeur de clé spécifiée.

// Start out gray and animate to green/red based on `ok`
val color = remember { Animatable(Color.Gray) }
LaunchedEffect(ok) {
    color.animateTo(if (ok) Color.Green else Color.Red)
}
Box(Modifier.fillMaxSize().background(color.value))

Dans l'exemple ci-dessus, nous créons et mémorisons une instance de Animatable avec la valeur initiale Color.Gray. En fonction de la valeur de l'indicateur booléen ok, la couleur s'anime pour devenir Color.Green ou Color.Red. Toute modification ultérieure de la valeur booléenne lance l'animation vers l'autre couleur. Si une animation est en cours au moment du changement de valeur, elle est annulée et la nouvelle animation démarre à partir de la valeur d'instantané actuelle, avec la vitesse actuelle.

Cette implémentation de l'animation constitue la base de l'API animate*AsState, comme mentionné dans la section précédente. Comparé à animate*AsState, en utilisant directement Animatable, nous pouvons contrôler précisément plusieurs aspects. Tout d'abord, Animatable peut avoir une valeur initiale différente de sa première valeur cible. Par exemple, l'exemple de code ci-dessus affiche une zone grise au début, qui commence immédiatement à s'animer en vert ou en rouge. Ensuite, Animatable permet de réaliser des opérations supplémentaires sur la valeur du contenu, à savoir snapTo et animateDecay. snapTo définit immédiatement la valeur actuelle sur la valeur cible. Cette option est utile lorsque l'animation elle-même n'est pas la seule source fiable et doit être synchronisée avec d'autres états, tels que des événements tactiles. animateDecay démarre une animation qui ralentit à partir d'une vitesse donnée. Cette opération permet d'implémenter le comportement de glissement d'un geste vif. Pour en savoir plus, consultez la section Gestes et animations.

Animatable est directement compatible avec Float et Color, mais vous pouvez utiliser n'importe quel type de données en fournissant un TwoWayConverter. Pour en savoir plus, consultez la section AnimationVector.

Il est possible de personnaliser les spécifications de l'animation en fournissant un AnimationSpec. Pour en savoir plus, consultez la section AnimationSpec.

Animation

Animation est l'API d'animation de niveau le plus bas. La plupart des animations que nous avons vues jusqu'à présent s'appuient sur Animation. Animation propose deux sous-types : TargetBasedAnimation et DecayAnimation.

Animation doit uniquement servir à contrôler manuellement le timing de l'animation. Animation est sans état et n'est associé à aucun cycle de vie. Il sert de moteur de calcul des animations pour les API de niveau supérieur.

TargetBasedAnimation

Les autres API couvrent la plupart des cas d'utilisation, mais en utilisant directement TargetBasedAnimation, vous pouvez contrôler vous-même le temps de lecture de l'animation. Dans l'exemple ci-dessous, le temps de lecture de TargetAnimation est contrôlé manuellement en fonction du temps de rendu fourni par withFrameNanos.

val anim = remember {
    TargetBasedAnimation(
        animationSpec = tween(200),
        typeConverter = Float.VectorConverter,
        initialValue = 200f,
        targetValue = 1000f
    )
}
var playTime by remember { mutableStateOf(0L) }

LaunchedEffect(anim) {
    val startTime = withFrameNanos { it }

    do {
        playTime = withFrameNanos { it } - startTime
        val animationValue = anim.getValueFromNanos(playTime)
    } while (someCustomCondition())
}

DecayAnimation

Contrairement à TargetBasedAnimation, DecayAnimation ne nécessite pas de targetValue. Il calcule en effet sa valeur targetValue en fonction des conditions de départ, telles que définies par initialVelocity et initialValue et par la valeur DecayAnimationSpec fournie.

Les animations de décomposition sont souvent utilisées après un glissement d'un geste vif afin de ralentir le mouvement des éléments jusqu'à leur arrêt. La vitesse d'animation commence à la valeur définie par initialVelocityVector et ralentit au fil du temps.

Personnaliser les animations

Bon nombre des API Animation acceptent des paramètres qui permettent de personnaliser leur comportement.

AnimationSpec

La plupart des API d'animation permettent aux développeurs de personnaliser les spécifications d'animation à l'aide d'un paramètre AnimationSpec facultatif.

val alpha: Float by animateFloatAsState(
    targetValue = if (enabled) 1f else 0.5f,
    // Configure the animation duration and easing.
    animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing)
)

Il existe plusieurs types de AnimationSpec, qui permettent de créer autant d'animations différentes.

spring

spring crée une animation basée sur des mécanismes physiques entre les valeurs de début et de fin. Deux paramètres sont acceptés : dampingRatio et stiffness.

dampingRatio définit la force du rebond. La valeur par défaut est Spring.DampingRatioNoBouncy.

Graphique animé montrant le comportement des différents niveaux d'amortissement

stiffness définit la vitesse à laquelle le ressort doit se déplacer vers la valeur de fin. La valeur par défaut est Spring.StiffnessMedium.

val value by animateFloatAsState(
    targetValue = 1f,
    animationSpec = spring(
        dampingRatio = Spring.DampingRatioHighBouncy,
        stiffness = Spring.StiffnessMedium
    )
)

spring permet de gérer les interruptions plus en douceur par rapport aux types AnimationSpec basés sur la durée, car il maintient la vitesse lorsque la valeur cible change entre les animations. De nombreuses API d'animation, dont animate*AsState et updateTransition, utilisent spring comme AnimationSpec par défaut.

tween

tween crée une animation entre les valeurs de début et de fin durant la période durationMillis spécifiée en suivant une courbe de lissage de vitesse. Pour en savoir plus, consultez la section Easing. Vous pouvez également spécifier delayMillis pour reporter le début de l'animation.

val value by animateFloatAsState(
    targetValue = 1f,
    animationSpec = tween(
        durationMillis = 300,
        delayMillis = 50,
        easing = LinearOutSlowInEasing
    )
)

keyframes

keyframes crée une animation en fonction des valeurs d'instantané spécifiées à différents horodatages pendant la durée de l'animation. À un moment donné, la valeur d'animation est interpolée entre deux valeurs d'image clé. Vous pouvez spécifier la courbe d'interpolation de chacune de ces images clés en utilisant Easing.

De manière facultative, vous pouvez spécifier les valeurs à 0 ms et pour toute la durée. Dans le cas contraire, elles seront définies par défaut sur les valeurs de début et de fin de l'animation, respectivement.

val value by animateFloatAsState(
    targetValue = 1f,
    animationSpec = keyframes {
        durationMillis = 375
        0.0f at 0 with LinearOutSlowInEasing // for 0-15 ms
        0.2f at 15 with FastOutLinearInEasing // for 15-75 ms
        0.4f at 75 // ms
        0.4f at 225 // ms
    }
)

repeatable

repeatable exécute plusieurs fois une animation basée sur la durée (par exemple, tween ou keyframes) jusqu'à atteindre le nombre d'itérations spécifié. Vous pouvez transmettre le paramètre repeatMode pour indiquer si l'animation doit être répétée en commençant par le début (RepeatMode.Restart) ou par la fin (RepeatMode.Reverse).

val value by animateFloatAsState(
    targetValue = 1f,
    animationSpec = repeatable(
        iterations = 3,
        animation = tween(durationMillis = 300),
        repeatMode = RepeatMode.Reverse
    )
)

infiniteRepeatable

infiniteRepeatable est semblable à repeatable, mais l'animation se répète indéfiniment.

val value by animateFloatAsState(
    targetValue = 1f,
    animationSpec = infiniteRepeatable(
        animation = tween(durationMillis = 300),
        repeatMode = RepeatMode.Reverse
    )
)

Dans les tests avec ComposeTestRule, les animations qui utilisent infiniteRepeatable ne sont pas exécutées. La valeur initiale de chaque valeur animée sera utilisée pour afficher le composant.

snap

snap est un AnimationSpec spécial qui remplace immédiatement la valeur par la valeur de fin. Vous pouvez spécifier delayMillis pour retarder le début de l'animation.

val value by animateFloatAsState(
    targetValue = 1f,
    animationSpec = snap(delayMillis = 50)
)

Easing

Les opérations AnimationSpec basées sur la durée (telles que tween ou keyframes) utilisent Easing pour ajuster une fraction d'une animation. Cela permet à la valeur d'animation d'accélérer et de ralentir plutôt que de se déplacer à une vitesse constante. La fraction est une valeur comprise entre 0 (début) et 1.0 (fin). Elle indique le point actuellement atteint dans l'animation.

En réalité, Easing est une fonction qui accepte une valeur de fraction comprise entre 0 et 1.0 et renvoie un float. La valeur renvoyée peut se trouver en dehors des limites pour représenter un dépassement vers le haut ou vers le bas. Vous pouvez créer un Easing personnalisé comme dans le code ci-dessous.

val CustomEasing = Easing { fraction -> fraction * fraction }

@Composable
fun EasingUsage() {
    val value by animateFloatAsState(
        targetValue = 1f,
        animationSpec = tween(
            durationMillis = 300,
            easing = CustomEasing
        )
    )
    // ...
}

Compose intègre plusieurs fonctions Easing qui couvrent la plupart des cas d'utilisation. Pour déterminer quel lissage de vitesse utiliser selon votre scénario, consultez la section sur la vitesse dans Material Design.

  • FastOutSlowInEasing
  • LinearOutSlowInEasing
  • FastOutLinearEasing
  • LinearEasing
  • CubicBezierEasing
  • Autres fonctions

AnimationVector

La plupart des API d'animation Compose acceptent les types Float, Color et Dp, ainsi que d'autres types de données de base en tant que valeurs d'animation. Toutefois, vous devez parfois animer d'autres types de données, y compris des types personnalisés. Pendant l'animation, les valeurs d'animation sont représentées par un AnimationVector. Elles sont converties en AnimationVector et inversement par un TwoWayConverter associé afin que le système d'animation principal puisse les gérer de manière uniforme. Par exemple, Int est représenté par un AnimationVector1D contenant une seule valeur flottante. Le TwoWayConverter d'un Int se présente comme suit :

val IntToVector: TwoWayConverter<Int, AnimationVector1D> =
    TwoWayConverter({ AnimationVector1D(it.toFloat()) }, { it.value.toInt() })

Color est essentiellement un ensemble de quatre valeurs (rouge, vert, bleu et alpha). Color est donc converti en un AnimationVector4D qui contient quatre floats. Ainsi, chaque type de données utilisé dans les animations est converti en AnimationVector1D, AnimationVector2D, AnimationVector3D ou AnimationVector4D en fonction de sa dimensionnalité. Cela permet d'animer indépendamment les différents composants de l'objet, chacun avec son propre suivi de la vitesse. Vous pouvez accéder aux convertisseurs intégrés des types de données de base à l'aide de Color.VectorConverter, Dp.VectorConverter, etc.

Lorsque vous souhaitez proposer un nouveau type de données en tant que valeur d'animation, créez votre TwoWayConverter et fournissez-le à l'API. Par exemple, vous pouvez utiliser animateValueAsState pour animer votre type de données personnalisé comme suit :

data class MySize(val width: Dp, val height: Dp)

@Composable
fun MyAnimation(targetSize: MySize) {
    val animSize: MySize by animateValueAsState<MySize, AnimationVector2D>(
        targetSize,
        TwoWayConverter(
            convertToVector = { size: MySize ->
                // Extract a float value from each of the `Dp` fields.
                AnimationVector2D(size.width.value, size.height.value)
            },
            convertFromVector = { vector: AnimationVector2D ->
                MySize(vector.v1.dp, vector.v2.dp)
            }
        )
    )
}

Ressources de vecteur animé (expérimental)

Pour utiliser une ressource AnimatedVectorDrawable, chargez le fichier drawable à l'aide de animatedVectorResource et transmettez un boolean pour basculer entre les états de début et de fin de votre drawable.

@Composable
fun AnimatedVectorDrawable() {
    val image = AnimatedImageVector.animatedVectorResource(R.drawable.ic_hourglass_animated)
    var atEnd by remember { mutableStateOf(false) }
    Image(
        painter = rememberAnimatedVectorPainter(image, atEnd),
        contentDescription = "Timer",
        modifier = Modifier.clickable {
            atEnd = !atEnd
        },
        contentScale = ContentScale.Crop
    )
}

Pour en savoir plus sur le format de votre fichier drawable, consultez Animer des images Drawable.

Animer des éléments de liste

Si vous souhaitez animer la réorganisation des éléments d'une liste ou d'une grille différée, consultez la documentation sur l'animation des éléments d'une mise en page différée.

Gestes et animations (avancé)

Plusieurs points sont à prendre en compte lorsque nous utilisons des événements tactiles et des animations, et pas seulement des animations. Tout d'abord, nous devrons peut-être interrompre une animation en cours lorsque les événements tactiles commencent, car la priorité doit être donnée à l'interaction de l'utilisateur.

Dans l'exemple ci-dessous, nous utilisons un Animatable pour représenter la position décalée d'un composant de cercle. Les événements tactiles sont traités avec le modificateur pointerInput. Lorsque nous détectons un nouvel événement d'appui, nous appelons animateTo pour animer la valeur de décalage jusqu'à la position de l'appui. Un événement d'appui peut également se produire pendant l'animation. Dans ce cas, animateTo interrompt l'animation en cours et la lance à la nouvelle position cible, tout en maintenant la vitesse de l'animation interrompue.

@Composable
fun Gesture() {
    val offset = remember { Animatable(Offset(0f, 0f), Offset.VectorConverter) }
    Box(
        modifier = Modifier
            .fillMaxSize()
            .pointerInput(Unit) {
                coroutineScope {
                    while (true) {
                        // Detect a tap event and obtain its position.
                        val position = awaitPointerEventScope {
                            awaitFirstDown().position
                        }
                        launch {
                            // Animate to the tap position.
                            offset.animateTo(position)
                        }
                    }
                }
            }
    ) {
        Circle(modifier = Modifier.offset { offset.value.toIntOffset() })
    }
}

private fun Offset.toIntOffset() = IntOffset(x.roundToInt(), y.roundToInt())

Il est aussi courant de devoir synchroniser les valeurs d'animation avec les valeurs issues d'événements tactiles, tels qu'un déplacement. Dans l'exemple ci-dessous, l'animation "Balayer pour fermer la vue" est implémentée en tant que Modifier (au lieu d'utiliser le composable SwipeToDismiss). Le décalage horizontal de l'élément est représenté par un Animatable. Cette API présente une caractéristique utile dans les animations par gestes. Sa valeur peut être modifiée par les événements tactiles et l'animation. Lorsque nous recevons un événement tactile, nous arrêtons Animatable par la méthode stop afin d'interrompre toute animation en cours.

Lors d'un événement de déplacement, nous utilisons snapTo pour mettre à jour la valeur Animatable avec la valeur calculée à partir des événements tactiles. Pour le glissement d'un geste vif, Compose fournit VelocityTracker afin d'enregistrer les événements de déplacement et de calculer la vitesse. La vitesse peut être transmise directement à animateDecay pour cette animation de glissement. Lorsque nous voulons faire glisser à nouveau la valeur décalée vers sa position d'origine, nous spécifions la valeur décalée cible de 0f avec la méthode animateTo.

fun Modifier.swipeToDismiss(
    onDismissed: () -> Unit
): Modifier = composed {
    val offsetX = remember { Animatable(0f) }
    pointerInput(Unit) {
        // Used to calculate fling decay.
        val decay = splineBasedDecay<Float>(this)
        // Use suspend functions for touch events and the Animatable.
        coroutineScope {
            while (true) {
                // Detect a touch down event.
                val pointerId = awaitPointerEventScope { awaitFirstDown().id }
                val velocityTracker = VelocityTracker()
                // Stop any ongoing animation.
                offsetX.stop()
                awaitPointerEventScope {
                    horizontalDrag(pointerId) { change ->
                        // Update the animation value with touch events.
                        launch {
                            offsetX.snapTo(
                                offsetX.value + change.positionChange().x
                            )
                        }
                        velocityTracker.addPosition(
                            change.uptimeMillis,
                            change.position
                        )
                    }
                }
                // No longer receiving touch events. Prepare the animation.
                val velocity = velocityTracker.calculateVelocity().x
                val targetOffsetX = decay.calculateTargetValue(
                    offsetX.value,
                    velocity
                )
                // The animation stops when it reaches the bounds.
                offsetX.updateBounds(
                    lowerBound = -size.width.toFloat(),
                    upperBound = size.width.toFloat()
                )
                launch {
                    if (targetOffsetX.absoluteValue <= size.width) {
                        // Not enough velocity; Slide back.
                        offsetX.animateTo(
                            targetValue = 0f,
                            initialVelocity = velocity
                        )
                    } else {
                        // The element was swiped away.
                        offsetX.animateDecay(velocity, decay)
                        onDismissed()
                    }
                }
            }
        }
    }
        .offset { IntOffset(offsetX.value.roundToInt(), 0) }
}

Tests

Compose propose ComposeTestRule, qui vous permet d'écrire des tests pour les animations de manière déterministe en contrôlant totalement l'horloge de test. Cela vous permet de vérifier les valeurs d'animation intermédiaires. En outre, un test peut s'exécuter plus rapidement que la durée réelle de l'animation.

ComposeTestRule expose son horloge de test en tant que mainClock. Vous pouvez définir la propriété autoAdvance sur "false" pour contrôler l'horloge dans votre code de test. Après avoir lancé l'animation à tester, vous pouvez avancer l'horloge avec advanceTimeBy.

Avec advanceTimeBy, vous n'avancez pas exactement l'horloge selon la durée spécifiée. L'heure est arrondie à la durée la plus proche, qui est un multiplicateur de la durée de l'image.

@get:Rule
val rule = createComposeRule()

@Test
fun testAnimationWithClock() {
    // Pause animations
    rule.mainClock.autoAdvance = false
    var enabled by mutableStateOf(false)
    rule.setContent {
        val color by animateColorAsState(
            targetValue = if (enabled) Color.Red else Color.Green,
            animationSpec = tween(durationMillis = 250)
        )
        Box(Modifier.size(64.dp).background(color))
    }

    // Initiate the animation.
    enabled = true

    // Let the animation proceed.
    rule.mainClock.advanceTimeBy(50L)

    // Compare the result with the image showing the expected result.
    // `assertAgainGolden` needs to be implemented in your code.
    rule.onRoot().captureToImage().assertAgainstGolden()
}

Outils compatibles

Android Studio permet d'inspecter updateTransition et animatedVisibility dans Aperçu de l'animation. Cela vous permet de :

  • Prévisualiser une transition image par image
  • Inspecter les valeurs de toutes les animations de la transition
  • Obtenir un aperçu d'une transition entre les états initiaux et cibles
  • Inspecter et coordonner plusieurs animations à la fois

Lorsque vous lancez l'aperçu de l'animation, le volet "Animations" s'affiche. Vous pouvez y exécuter toutes les transitions incluses dans l'aperçu. Un nom par défaut est attribué à la transition ainsi qu'à chacune de ses valeurs d'animation. Vous pouvez le personnaliser en spécifiant le paramètre label dans updateTransition et dans les fonctions AnimatedVisibility. Pour en savoir plus, consultez la page Aperçu de l'animation.

Panneau d'aperçu de l'animation

En savoir plus

Pour en savoir plus sur les animations dans Jetpack Compose, consultez les ressources supplémentaires suivantes :

Exemples

Articles de blog

Ateliers de programmation

Vidéos