Animacje oparte na wartości

Na tej stronie opisujemy, jak tworzyć animacje oparte na wartościach w Jetpack Compose, skupiając się na interfejsach API, które animują wartości na podstawie ich bieżącego i docelowego stanu.

Animowanie pojedynczej wartości za pomocą animate*AsState

Funkcje animate*AsState to proste interfejsy Animation API w Compose do animowania pojedynczej wartości. Podajesz tylko wartość docelową (lub końcową), a interfejs API rozpoczyna animację od bieżącej wartości do określonej wartości.

Poniższy przykład pokazuje, jak animować kanał alfa za pomocą tego interfejsu API. Dzięki umieszczeniu wartości docelowej w animateFloatAsState wartość alfa jest teraz wartością animacji między podanymi wartościami (w tym przypadku 1f lub 0.5f).

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

Nie musisz tworzyć instancji żadnej klasy animacji ani obsługiwać przerwania. W tle zostanie utworzony obiekt animacji (czyli instancja Animatable), który zostanie zapamiętany w miejscu wywołania, a jego wartość początkowa będzie pierwszą wartością docelową. Od tego momentu za każdym razem, gdy podasz temu elementowi kompozycyjnemu inną wartość docelową, automatycznie rozpocznie się animacja dążąca do tej wartości. Jeśli animacja jest już w toku, rozpoczyna się od bieżącej wartości (i szybkości) i animuje w kierunku wartości docelowej. Podczas animacji ten element kompozycyjny jest ponownie komponowany i zwraca zaktualizowaną wartość animacji w każdej klatce.

Domyślnie Compose udostępnia funkcje animate*AsState dla Float, Color, Dp, Size, Offset, Rect, Int, IntOffset i IntSize. Możesz dodać obsługę innych typów danych, podając TwoWayConverter do animateValueAsState, który przyjmuje typ ogólny.

Możesz dostosować specyfikacje animacji, podając AnimationSpec. Więcej informacji znajdziesz na stronie AnimationSpec.

Animowanie wielu właściwości jednocześnie za pomocą przejścia

Transition zarządza co najmniej 1 animacją jako elementem podrzędnym i uruchamia je jednocześnie między wieloma stanami.

Stany mogą być dowolnego typu danych. W wielu przypadkach możesz użyć niestandardowego typu enum, aby sprawdzić zabezpieczenia wpisywania, jak w tym przykładzie:

enum class BoxState {
    Collapsed,
    Expanded
}

updateTransition tworzy i zapamiętuje instancję Transition oraz aktualizuje jej stan.

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

Następnie możesz użyć jednej z funkcji rozszerzenia animate*, aby zdefiniować animację podrzędną w tym przejściu. Określ wartości docelowe dla każdego stanu. Te funkcje animate* zwracają wartość animacji, która jest aktualizowana w każdej klatce podczas animacji, gdy stan przejścia jest aktualizowany za pomocą 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
    }
}

Opcjonalnie możesz przekazać parametr transitionSpec, aby określić inną wartość AnimationSpec dla każdej kombinacji zmian stanu przejścia. Więcej informacji znajdziesz w artykule 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
    }
}

Gdy przejście osiągnie stan docelowy, Transition.currentState jest takie samo jak Transition.targetState. Możesz użyć tego jako sygnału, że przejście zostało zakończone.

Czasami możesz chcieć, aby stan początkowy był inny niż pierwszy stan docelowy. Aby to zrobić, możesz użyć rememberTransition z MutableTransitionState. Możesz na przykład rozpocząć animację, gdy tylko kod wejdzie do kompozycji.

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

W przypadku bardziej złożonego przejścia obejmującego wiele funkcji typu „composable” możesz użyć createChildTransition, aby utworzyć przejście podrzędne. Ta technika jest przydatna do rozdzielania zadań między wiele podkomponentów w złożonym elemencie kompozycyjnym. Przejście nadrzędne zna wszystkie wartości animacji w przejściach podrzędnych.

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

Użyj przejścia z AnimatedVisibility i AnimatedContent

Funkcje AnimatedVisibility i AnimatedContent są dostępne jako funkcje rozszerzenia Transition. Wartość targetState dla Transition.AnimatedVisibility i Transition.AnimatedContent jest określana na podstawie Transition i wywołuje animacje wejścia, wyjścia i sizeTransform, gdy zmienia się targetState elementu Transition. Te funkcje rozszerzeń umożliwiają przeniesienie wszystkich animacji wejścia, wyjścia i sizeTransform, które w przeciwnym razie byłyby wewnętrzne dla AnimatedVisibility/AnimatedContent, do Transition. Dzięki tym funkcjom rozszerzenia możesz obserwować zmiany stanu AnimatedVisibility/AnimatedContent z zewnątrz. Zamiast parametru logicznego visible ta wersja AnimatedVisibility przyjmuje funkcję lambda, która konwertuje stan docelowy przejścia nadrzędnego na wartość logiczną.

Szczegółowe informacje znajdziesz w AnimatedVisibility i 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")
            }
        }
    }
}

Hermetyzacja przejścia i umożliwienie jego ponownego użycia

W prostych przypadkach użycia definiowanie animacji przejścia w tym samym elemencie kompozycyjnym co interfejs jest prawidłową opcją. Jeśli jednak pracujesz nad złożonym komponentem z wieloma animowanymi wartościami, możesz oddzielić implementację animacji od interfejsu funkcji kompozycyjnej.

Możesz to zrobić, tworząc klasę, która zawiera wszystkie wartości animacji, oraz funkcję update, która zwraca instancję tej klasy. Możesz wyodrębnić implementację przejścia do nowej, osobnej funkcji. Ten wzorzec jest przydatny, gdy chcesz scentralizować logikę animacji lub umożliwić wielokrotne używanie złożonych animacji.

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

Utwórz nieskończenie powtarzającą się animację z rememberInfiniteTransition

InfiniteTransition zawiera co najmniej 1 animację podrzędną, np. Transition, ale animacje zaczynają działać, gdy tylko wejdą do kompozycji, i nie zatrzymują się, dopóki nie zostaną usunięte. Możesz utworzyć instancję InfiniteTransition za pomocą rememberInfiniteTransition i dodać animacje podrzędne za pomocą animateColor, animateFloat lub animateValue. Musisz też podać infiniteRepeatable, aby określić specyfikacje animacji.

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

Interfejsy API animacji niskiego poziomu

Wszystkie interfejsy API animacji wysokiego poziomu wymienione w poprzedniej sekcji są oparte na interfejsach API animacji niskiego poziomu.

Funkcje animate*AsState to proste interfejsy API, które renderują natychmiastową zmianę wartości jako wartość animacji. Ta funkcja jest obsługiwana przez Animatable, czyli interfejs API oparty na współprogramach, który służy do animowania pojedynczej wartości.

updateTransition tworzy obiekt przejścia, który może zarządzać wieloma wartościami animacji i uruchamiać je, gdy zmienia się stan. rememberInfiniteTransition jest podobny, ale tworzy nieskończone przejście, które może zarządzać wieloma animacjami trwającymi bez końca. Wszystkie te interfejsy API są elementami kompozycyjnymi z wyjątkiem Animatable, co oznacza, że możesz tworzyć te animacje poza kompozycją.

Wszystkie te interfejsy API są oparte na bardziej podstawowym interfejsie API Animation. Chociaż większość aplikacji nie będzie bezpośrednio korzystać z Animation, możesz uzyskać dostęp do niektórych funkcji dostosowywania za pomocą interfejsów API wyższego poziomu. Więcej informacji o AnimationVector i AnimationSpec znajdziesz w artykule Dostosowywanie animacji.

Relacja między interfejsami Animation API niskiego poziomu
Rysunek 1. Zależności między interfejsami Animation API niskiego poziomu.

Animatable: animacja pojedynczej wartości oparta na współprogramie

Animatable to obiekt przechowujący wartość, który może animować wartość podczas jej zmiany za pomocą animateTo. To jest interfejs API, który obsługuje implementację animate*AsState. Zapewnia to spójną kontynuację i wzajemne wykluczanie, co oznacza, że zmiana wartości jest zawsze ciągła, a Compose anuluje każdą trwającą animację.

Wiele funkcji Animatable, w tym animateTo, to funkcje zawieszenia. Oznacza to, że musisz je umieścić w odpowiednim zakresie współprogramu. Możesz na przykład użyć funkcji kompozycyjnej LaunchedEffect, aby utworzyć zakres tylko na czas trwania określonej wartości klucza.

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

W poprzednim przykładzie tworzysz i zapamiętujesz instancję Animatable z wartością początkową Color.Gray. W zależności od wartości flagi logicznej ok kolor zmienia się na Color.Green lub Color.Red. Każda kolejna zmiana wartości logicznej rozpoczyna animację do innego koloru. Jeśli podczas zmiany wartości trwa animacja, Compose anuluje ją i rozpocznie nową animację od bieżącej wartości migawki z bieżącą szybkością.

Ten interfejs API Animatable jest podstawową implementacją interfejsu animate*AsState wspomnianego w poprzedniej sekcji. Korzystanie bezpośrednio z Animatable zapewnia bardziej szczegółową kontrolę na kilka sposobów:

  • Po pierwsze, Animatable może mieć wartość początkową inną niż pierwsza wartość docelowa. Na przykład w poprzednim przykładzie kodu najpierw pojawia się szare pole, które natychmiast zmienia się w zielone lub czerwone.
  • Po drugie, Animatable udostępnia więcej operacji na wartości treści, zwłaszcza snapTo i animateDecay.
    • snapTo natychmiast ustawia bieżącą wartość na wartość docelową. Jest to przydatne, gdy animacja nie jest jedynym źródłem informacji i musi być synchronizowana z innymi stanami, takimi jak zdarzenia dotknięcia.
    • animateDecay rozpoczyna animację, która zwalnia od podanej prędkości. Jest to przydatne do implementowania zachowania rzutu.

Więcej informacji znajdziesz w sekcji Gesty i animacje.

Domyślnie Animatable obsługuje Float i Color, ale możesz użyć dowolnego typu danych, podając TwoWayConverter. Więcej informacji znajdziesz w sekcji AnimationVector.

Możesz dostosować specyfikacje animacji, podając AnimationSpec. Więcej informacji znajdziesz na stronie AnimationSpec.

Animation: animacja sterowana ręcznie

Animation to interfejs Animation API najniższego poziomu. Wiele animacji, które do tej pory widzieliśmy, bazuje na Animation. Istnieją 2 podtypy Animation: TargetBasedAnimation i DecayAnimation.

Używaj Animation tylko do ręcznego sterowania czasem animacji. Animation jest bezstanowy i nie ma żadnej koncepcji cyklu życia. Służy jako mechanizm obliczania animacji dla interfejsów API wyższego poziomu.

TargetBasedAnimation

Inne interfejsy API obejmują większość przypadków użycia, ale bezpośrednie użycie TargetBasedAnimation pozwala kontrolować czas odtwarzania animacji. W tym przykładzie ręcznie kontrolujesz czas odtwarzania TargetBasedAnimation na podstawie czasu renderowania klatki podanego przez 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

W przeciwieństwie do TargetBasedAnimation, DecayAnimation nie wymaga podania targetValue. Zamiast tego oblicza targetValue na podstawie warunków początkowych ustawionych przez initialVelocity i initialValue oraz podanego DecayAnimationSpec.

Animacje zanikania są często używane po geście rzutu, aby spowolnić elementy do zatrzymania. Szybkość animacji zaczyna się od wartości ustawionej przez initialVelocityVector i z czasem maleje.