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.

Ten 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 początkową wartością będzie pierwsza wartość docelowa. 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 trakcie, rozpoczyna się od bieżącej wartości (i szybkości) i animuje w kierunku wartości docelowej. Podczas animacji ten element kompozycyjny jest rekomponowany 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 jedną animacją jako elementem podrzędnym i uruchamia je jednocześnie w różnych stanach.

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 taki sam 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 różnił się od pierwszego stanu docelowego. Aby to zrobić, możesz użyć updateTransition 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

AnimatedVisibility i AnimatedContent są dostępne jako funkcje rozszerzenia Transition. Wartość targetState dla Transition.AnimatedVisibility i Transition.AnimatedContent jest uzyskiwana z Transition i w razie potrzeby aktywuje animacje wejścia, wyjścia i sizeTransform, gdy zmienia się targetState elementu Transition. Te funkcje rozszerzenia umożliwiają przeniesienie wszystkich animacji wejścia, wyjścia i sizeTransform, które w innym przypadku 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 Animation API wysokiego poziomu wymienione w poprzedniej sekcji są oparte na interfejsach Animation API 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, interfejs API oparty na współprogramach 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. Większość aplikacji nie będzie bezpośrednio korzystać z Animation, ale niektóre z jego funkcji dostosowywania są dostępne 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ść, gdy jest ona zmieniana 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 opakować w odpowiedni zakres 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 początkową wartością 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 w momencie zmiany wartości trwa animacja, Compose ją anuluje, a nowa animacja rozpocznie się 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 powyższym przykładowym kodzie 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, w szczególności 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 uruchamia animację, która zwalnia od podanej prędkości. Jest to przydatne przy implementacji 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, opiera się 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 żadnego 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 użycie bezpośrednio TargetBasedAnimation pozwala kontrolować czas odtwarzania animacji. W tym przykładzie ręcznie kontrolujesz czas odtwarzania TargetAnimation 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.