Animazioni basate sul valore

Questa pagina descrive come creare animazioni basate sul valore in Jetpack Compose, concentrandosi sulle API che animano i valori in base al loro stato attuale e di destinazione.

Animare un singolo valore con animate*AsState

Le funzioni animate*AsState sono API di animazione semplici in Compose per animare un singolo valore. Fornisci solo il valore target (o valore finale) e l'API avvia l'animazione dal valore corrente al valore specificato.

L'esempio seguente anima l'alpha utilizzando questa API. Se racchiudi il valore target in animateFloatAsState, il valore alfa diventa un valore di animazione tra i valori forniti (1f o 0.5f in questo caso).

var enabled by remember { mutableStateOf(true) }

val animatedAlpha: Float by animateFloatAsState(if (enabled) 1f else 0.5f, label = "alpha")
Box(
    Modifier
        .fillMaxSize()
        .graphicsLayer { alpha = animatedAlpha }
        .background(Color.Red)
)

Non è necessario creare un'istanza di una classe di animazione o gestire l'interruzione. Sotto il cofano, verrà creato e memorizzato un oggetto animazione (ovvero un'istanza Animatable) nel sito di chiamata, con il primo valore di destinazione come valore iniziale. Da quel momento in poi, ogni volta che fornisci a questo elemento componibile un valore di destinazione diverso, viene avviata automaticamente un'animazione verso quel valore. Se è già in corso un'animazione, questa inizia dal suo valore (e velocità) attuale e si anima verso il valore di destinazione. Durante l'animazione, questo componibile viene ricomposto e restituisce un valore di animazione aggiornato ogni fotogramma.

Per impostazione predefinita, Compose fornisce animate*AsState funzioni per Float, Color, Dp, Size, Offset, Rect, Int, IntOffset e IntSize. Puoi aggiungere il supporto per altri tipi di dati fornendo un TwoWayConverter a animateValueAsState che accetta un tipo generico.

Puoi personalizzare le specifiche dell'animazione fornendo un AnimationSpec. Per saperne di più, consulta AnimationSpec.

Animare più proprietà contemporaneamente con una transizione

Transition gestisce una o più animazioni come elementi secondari e le esegue contemporaneamente tra più stati.

Gli stati possono essere di qualsiasi tipo di dati. In molti casi, puoi utilizzare un tipo enum personalizzato per verificare la sicurezza dei tipi, come in questo esempio:

enum class BoxState {
    Collapsed,
    Expanded
}

updateTransition crea e memorizza un'istanza di Transition e aggiorna il relativo stato.

var currentState by remember { mutableStateOf(BoxState.Collapsed) }
val transition = updateTransition(currentState, label = "box state")

Puoi quindi utilizzare una delle funzioni di estensione animate* per definire un'animazione secondaria in questa transizione. Specifica i valori target per ciascuno degli stati. Queste funzioni animate* restituiscono un valore di animazione aggiornato a ogni fotogramma durante l'animazione quando lo stato di transizione viene aggiornato con 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
    }
}

Se vuoi, puoi trasmettere un parametro transitionSpec per specificare un AnimationSpec diverso per ciascuna delle combinazioni di modifiche dello stato di transizione. Per saperne di più, consulta 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
    }
}

Una volta che una transizione ha raggiunto lo stato di destinazione, Transition.currentState è uguale a Transition.targetState. Puoi utilizzare questo valore come indicatore per verificare se la transizione è terminata.

A volte, potresti voler impostare uno stato iniziale diverso dal primo stato di destinazione. Per farlo, puoi utilizzare updateTransition con MutableTransitionState. Ad esempio, ti consente di avviare l'animazione non appena il codice entra nella composizione.

// Start in collapsed state and immediately animate to expanded
var currentState = remember { MutableTransitionState(BoxState.Collapsed) }
currentState.targetState = BoxState.Expanded
val transition = rememberTransition(currentState, label = "box state")
// ……

Per una transizione più complessa che coinvolge più funzioni componibili, puoi utilizzare createChildTransition per creare una transizione secondaria. Questa tecnica è utile per separare le responsabilità tra più sottocomponenti in un composable complesso. La transizione principale è a conoscenza di tutti i valori di animazione nelle transizioni secondarie.

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
            }
        )
    }
}

Utilizzare la transizione con AnimatedVisibility e AnimatedContent

AnimatedVisibility e AnimatedContent sono disponibili come funzioni di estensione di Transition. Il targetState per Transition.AnimatedVisibility e Transition.AnimatedContent deriva da Transition e attiva le animazioni di entrata, uscita e sizeTransform in base alle esigenze quando il targetState di Transition cambia. Queste funzioni di estensione consentono di sollevare tutte le animazioni di entrata, uscita e sizeTransform che altrimenti sarebbero interne a AnimatedVisibility/AnimatedContent in Transition. Con queste funzioni di estensione, puoi osservare il cambio di stato di AnimatedVisibility/AnimatedContent dall'esterno. Anziché un parametro booleano visible, questa versione di AnimatedVisibility accetta una lambda che converte lo stato di destinazione della transizione principale in un valore booleano.

Per i dettagli, consulta AnimatedVisibility e 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),
    shadowElevation = 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")
            }
        }
    }
}

Incapsula una transizione e rendila riutilizzabile

Per i casi d'uso semplici, definire le animazioni di transizione nello stesso componente componibile dell'interfaccia utente è un'opzione valida. Quando lavori su un componente complesso con un numero di valori animati, tuttavia, potresti voler separare l'implementazione dell'animazione dall'interfaccia utente componibile.

Puoi farlo creando una classe che contenga tutti i valori di animazione e una funzione update che restituisca un'istanza di quella classe. Puoi estrarre l'implementazione della transizione nella nuova funzione separata. Questo pattern è utile quando devi centralizzare la logica di animazione o rendere riutilizzabili animazioni complesse.

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) }
}

Crea un'animazione che si ripete all'infinito con rememberInfiniteTransition

InfiniteTransition contiene una o più animazioni secondarie come Transition, ma le animazioni iniziano a essere eseguite non appena entrano nella composizione e non si fermano a meno che non vengano rimosse. Puoi creare un'istanza di InfiniteTransition con rememberInfiniteTransition e aggiungere animazioni secondarie con animateColor, animatedFloat o animatedValue. Devi anche specificare un infiniteRepeatable per specificare le specifiche dell'animazione.

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

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

API di animazione di basso livello

Tutte le API di animazione di alto livello menzionate nella sezione precedente si basano su quelle di basso livello.

Le funzioni animate*AsState sono API semplici che eseguono il rendering di una variazione istantanea del valore come valore di animazione. Questa funzionalità è supportata da Animatable, un'API basata su coroutine per animare un singolo valore.

updateTransition crea un oggetto di transizione che può gestire più valori di animazione ed eseguirli quando uno stato cambia. rememberInfiniteTransition è simile, ma crea una transizione infinita che può gestire più animazioni che continuano all'infinito. Tutte queste API sono componibili, ad eccezione di Animatable, il che significa che puoi creare queste animazioni al di fuori della composizione.

Tutte queste API si basano sull'API Animation più fondamentale. Anche se la maggior parte delle app non interagisce direttamente con Animation, puoi accedere ad alcune delle sue funzionalità di personalizzazione tramite API di livello superiore. Per ulteriori informazioni su AnimationVector e AnimationSpec, consulta Personalizzare le animazioni.

Relazione tra le API di animazione di basso livello
Figura 1. Relazione tra le API di animazione di basso livello.

Animatable: Animazione a valore singolo basata su coroutine

Animatable è un segnaposto che può animare il valore man mano che viene modificato utilizzando animateTo. Questa è l'API che supporta l'implementazione di animate*AsState. Garantisce la continuità e l'esclusività reciproca, il che significa che la variazione di valore è sempre continua e Compose annulla qualsiasi animazione in corso.

Molte funzionalità di Animatable, tra cui animateTo, sono funzioni di sospensione. Ciò significa che devi racchiuderli in un ambito di coroutine appropriato. Ad esempio, puoi utilizzare il composable LaunchedEffect per creare un ambito solo per la durata del valore chiave specificato.

// 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)
)

Nell'esempio precedente, crei e memorizzi un'istanza di Animatable con il valore iniziale di Color.Gray. A seconda del valore del flag booleano ok, il colore viene animato in Color.Green o Color.Red. Qualsiasi modifica successiva del valore booleano avvia un'animazione verso l'altro colore. Se un'animazione è in corso quando il valore cambia, Compose la annulla e la nuova animazione inizia dal valore dello snapshot corrente con la velocità attuale.

Questa API Animatable è l'implementazione sottostante per animate*AsState menzionata nella sezione precedente. L'utilizzo diretto di Animatable offre un controllo più granulare in diversi modi:

  • Innanzitutto, Animatable può avere un valore iniziale diverso dal primo valore target. Ad esempio, l'esempio di codice precedente mostra inizialmente una casella grigia, che viene animata immediatamente in verde o rosso.
  • In secondo luogo, Animatable fornisce più operazioni sul valore dei contenuti, in particolare snapTo e animateDecay.
    • snapTo imposta immediatamente il valore corrente sul valore target. Questa è utile quando l'animazione non è l'unica fonte di verità e deve sincronizzarsi con altri stati, come gli eventi tocco.
    • animateDecay avvia un'animazione che rallenta dalla velocità indicata. Ciò è utile per implementare il comportamento di scorrimento rapido.

Per saperne di più, consulta Gesti e animazioni.

Per impostazione predefinita, Animatable supporta Float e Color, ma puoi utilizzare qualsiasi tipo di dati fornendo un TwoWayConverter. Per saperne di più, consulta AnimationVector.

Puoi personalizzare le specifiche dell'animazione fornendo un AnimationSpec. Per saperne di più, consulta AnimationSpec.

Animation: animazione controllata manualmente

Animation è l'API Animation di livello più basso disponibile. Molte delle animazioni che abbiamo visto finora si basano su Animation. Esistono due sottotipi di Animation: TargetBasedAnimation e DecayAnimation.

Utilizza Animation solo per controllare manualmente la durata dell'animazione. Animation è senza stato e non ha alcun concetto di ciclo di vita. Funge da motore di calcolo dell'animazione per le API di livello superiore.

TargetBasedAnimation

Altre API coprono la maggior parte dei casi d'uso, ma l'utilizzo diretto di TargetBasedAnimation ti consente di controllare la durata di riproduzione dell'animazione. Nell'esempio seguente, controlli manualmente il tempo di riproduzione di TargetAnimation in base al tempo di frame fornito da withFrameNanos.

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

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

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

DecayAnimation

A differenza di TargetBasedAnimation, DecayAnimation non richiede la fornitura di un targetValue. Al contrario, calcola il targetValue in base alle condizioni iniziali, impostate da initialVelocity e initialValue e dal DecayAnimationSpec fornito.

Le animazioni di decadimento vengono spesso utilizzate dopo un gesto di scorrimento rapido per rallentare gli elementi fino all'arresto. La velocità dell'animazione inizia con il valore impostato da initialVelocityVector e rallenta nel tempo.