Animations basées sur la valeur

Animer une seule valeur avec 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).

var enabled by remember { mutableStateOf(true) }

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.

Animer plusieurs propriétés simultanément avec une transition

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, label = "box state")

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(label = "rectangle") { state ->
    when (state) {
        BoxState.Collapsed -> Rect(0f, 0f, 100f, 100f)
        BoxState.Expanded -> Rect(100f, 100f, 300f, 300f)
    }
}
val borderWidth by transition.animateDp(label = "border width") { 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)
        }
    }, label = "color"
) { state ->
    when (state) {
        BoxState.Collapsed -> MaterialTheme.colorScheme.primary
        BoxState.Expanded -> MaterialTheme.colorScheme.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, label = "box state")
// ……

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, label = "dialer state")
    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 la transition avec AnimatedVisibility et AnimatedContent

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, label = "selected state")
val borderColor by transition.animateColor(label = "border color") { isSelected ->
    if (isSelected) Color.Magenta else Color.White
}
val elevation by transition.animateDp(label = "elevation") { 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, label = "box state")
    val color = transition.animateColor(label = "color") { state ->
        when (state) {
            BoxState.Collapsed -> Color.Gray
            BoxState.Expanded -> Color.Red
        }
    }
    val size = transition.animateDp(label = "size") { state ->
        when (state) {
            BoxState.Collapsed -> 64.dp
            BoxState.Expanded -> 128.dp
        }
    }
    return remember(transition) { TransitionData(color, size) }
}

Créer une animation en boucle avec 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&#39;animation de niveau inférieur

Animatable : animation à valeur unique basée sur une coroutine

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 contrôlée manuellement

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.