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ą funkcji animate*AsState

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

Poniższy przykład animuje wartość alfa za pomocą tego interfejsu API. Umieszczając wartość docelową w animateFloatAsState, wartość alfa staje się wartością animacji pomię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ć przerwań. W tle zostanie utworzony i zapamiętany w miejscu wywołania obiekt animacji (czyli instancja Animatable), a jego wartością początkową będzie pierwsza wartość docelowa. Od tego momentu za każdym razem, gdy podasz temu komponentowi inną wartość docelową, automatycznie rozpocznie się animacja w kierunku tej wartości. Jeśli animacja jest już w toku, zaczyna się od bieżącej wartości (i prędkości) i przechodzi do wartości docelowej. Podczas animacji ten komponent 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, IntOffsetIntSize. Możesz dodać obsługę innych typów danych, podając TwoWayConverter do animateValueAsState, która przyjmuje typ ogólny.

Możesz dostosować specyfikacje animacji, podając AnimationSpec. Więcej informacji znajdziesz w sekcji 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 enum typu, aby sprawdzić bezpieczeństwo typów, 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 animate* funkcje 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ć inny element AnimationSpec dla każdej kombinacji zmian stanu przejścia. Więcej informacji znajdziesz w sekcji 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 będzie takie samo jak Transition.targetState. Możesz użyć tego jako sygnału, czy przejście zostało zakończone.

Czasami możesz chcieć, aby stan początkowy różnił się od pierwszego stanu docelowego. Możesz to zrobić za pomocą updateTransitionMutableTransitionState. Umożliwia na przykład rozpoczęcie animacji od razu po wejściu kodu 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 kompozycyjnych możesz użyć createChildTransition, aby utworzyć przejście podrzędne. Ta technika jest przydatna do rozdzielania zadań między wieloma podkomponentami w złożonym komponencie. 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żywanie przejścia z AnimatedVisibility i AnimatedContent

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

Szczegółowe informacje znajdziesz w AnimatedVisibilityAnimatedContent.

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

Enkapsulacja przejścia i możliwość ponownego użycia

W prostych przypadkach użycia zdefiniowanie animacji przejścia w tym samym komponencie, w którym znajduje się interfejs, jest prawidłową opcją. Podczas pracy nad złożonym komponentem z wieloma animowanymi wartościami możesz jednak chcieć oddzielić implementację animacji od komponentu interfejsu.

Możesz to zrobić, tworząc klasę, która zawiera wszystkie wartości animacji, oraz funkcję, która zwraca instancję tej klasy.update Możesz wyodrębnić implementację przejścia do nowej, osobnej funkcji. Ten wzorzec jest przydatny, gdy chcesz scentralizować logikę animacji lub sprawić, aby złożone animacje były wielokrotnego użytku.

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

Tworzenie animacji powtarzanej w nieskończoność za pomocą rememberInfiniteTransition

InfiniteTransition zawiera co najmniej jedną animację podrzędną, np. Transition, ale animacje zaczynają się odtwarzać od razu po wejściu 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, animatedFloat lub animatedValue. 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, interfejs API oparty na korutynach, który służy do animowania pojedynczej wartości.

updateTransition tworzy obiekt przejścia, który może zarządzać wieloma animowanymi wartościami 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 w nieskończoność. Wszystkie te interfejsy API są funkcjami 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 Animation API. Chociaż większość aplikacji nie będzie wchodzić w bezpośrednią interakcję z Animation, możesz uzyskać dostęp do niektórych funkcji dostosowywania za pomocą interfejsów API wyższego poziomu. Więcej informacji o AnimationVectorAnimationSpec znajdziesz w sekcji Dostosowywanie animacji.

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

Animatable: Animacja pojedynczej wartości oparta na korutynach

Animatable to obiekt przechowujący wartość, który może animować wartość w miarę jej zmiany za pomocą animateTo. Jest to interfejs API, który obsługuje wdrożenie funkcji animate*AsState. Zapewnia to ciągłość i wzajemną wyłączność, 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 zawieszania. Oznacza to, że musisz umieścić je w odpowiednim zakresie coroutine. Możesz na przykład użyć funkcji 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 powyższym przykładzie tworzysz instancję Animatable i zapamiętujesz ją z wartością początkową Color.Gray. W zależności od wartości flagi logicznej ok kolor animuje się do Color.Green lub Color.Red. Każda kolejna zmiana wartości logicznej rozpoczyna animację do drugiego koloru. Jeśli w momencie zmiany wartości trwa animacja, Compose anuluje animację, a nowa animacja rozpoczyna się od bieżącej wartości migawki z bieżącą prędkością.

Ten interfejs API Animatable jest podstawową implementacją interfejsu animate*AsState wspomnianego w poprzedniej sekcji. Bezpośrednie korzystanie z usługi Animatable zapewnia większą 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 widać szare pole, które natychmiast zmienia kolor na zielony lub czerwony.
  • Po drugie, Animatable udostępnia więcej operacji na wartości treści, w szczególności snapToanimateDecay.
    • snapTo natychmiast ustawia bieżącą wartość na wartość docelową. Jest to przydatne, gdy animacja nie jest jedynym źródłem informacji i musi synchronizować się z innymi stanami, np. ze zdarzeniami dotykowymi.
    • animateDecay rozpoczyna animację, która zwalnia od podanej prędkości. Jest to przydatne do implementowania zachowania związanego z szybkim przesuwaniem.

Więcej informacji znajdziesz w sekcji Gest i animacja.

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 w sekcji AnimationSpec.

Animation: animacja sterowana ręcznie

Animation to interfejs API animacji najniższego poziomu. Wiele animacji, które do tej pory widzieliśmy, opiera się na Animation. Istnieją 2 Animationpodtypy: TargetBasedAnimationDecayAnimation.

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

TargetBasedAnimation

Większość przypadków użycia jest obsługiwana przez inne interfejsy API, ale bezpośrednie użycie TargetBasedAnimation pozwala kontrolować czas odtwarzania animacji. W tym przykładzie ręcznie kontrolujesz czas odtwarzania elementu TargetAnimation na podstawie czasu klatki podanego przez element 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 atrybutu TargetBasedAnimation atrybut DecayAnimation nie wymaga podania atrybutu targetValue. Zamiast tego oblicza targetValue na podstawie warunków początkowych określonych przez initialVelocityinitialValue oraz podanego DecayAnimationSpec.

Animacje zanikania są często używane po wykonaniu gestu szybkiego przesunięcia, aby spowolnić elementy i je zatrzymać. Prędkość animacji zaczyna się od wartości ustawionej przez initialVelocityVector i zwalnia z upływem czasu.