動畫

Jetpack Compose 提供功能強大且可擴充的 API,可讓您輕鬆在應用程式的使用者介面中執行多種動畫。本文件說明如何使用這些 API 以及根據您的動畫情況決定要使用的 API。

總覽

動畫是現代行動應用程式所必不可少的,藉由動畫的協助,能夠提供流暢且可理解的使用者體驗。許多 Jetpack Compose Animation API 的作用 如同可組合函式, 就像版面配置和其他 UI 元素一樣,它們是由 使用 Kotlin coroutine suspend 函式建立的較低階 API 提供支援。 本指南從在許多實際情況都極為有用的高階 API 開始介紹,隨後說明可為您提供進一步的控制和自訂功能的低階 API。

下面的這張圖表可協助您決定要使用哪個 API 執行動畫。

  • 如果您要在版面配置中進行動畫內容的變更:
    • 如果您有動畫顯示和動畫消失的效果:
      • 使用 AnimatedVisibility
    • 根據狀態切換內容:
      • 如果您正在進行內容的交叉漸變:
        • 使用 Crossfade
      • 否則,請使用 AnimatedContent
    • 否則,請使用 Modifier.animateContentSize
  • 如果動畫是基於狀態:
    • 如果動畫是在組合過程中發生:
      • 如果動畫是無限進行的:
        • 使用 rememberInfiniteTransition
      • 如果您正在同時製作有多個值的動畫:
        • 使用 updateTransition
      • 否則,請使用 animate*AsState
  • 如果想要精確控制動畫時間:
    • 使用 Animation
  • 如果動畫是唯一真理的來源
    • 使用 Animatable
  • 否則,請使用 AnimationStateanimate

在選擇適當動畫 API 時,用於描述決策樹狀圖的流程圖

高階動畫 API

Compose 提供高階動畫 API 以便用於在眾多應用程式中常用的數種動畫模式。這些 API 的設計符合質感設計動態的最佳做法。

AnimatedVisibility (實驗性)

AnimatedVisibility 可組合元件以動畫方式呈現內容的顯示與消失。

var editable by remember { mutableStateOf(true) }
AnimatedVisibility(visible = editable) {
    Text(text = "Edit")
}

根據預設,內容會以淡入與展開方式出現,並且以淡出和縮小方式消失。藉由指定 EnterTransitionExitTransition 可以自訂轉換。

var visible by remember { mutableStateOf(true) }
val density = LocalDensity.current
AnimatedVisibility(
    visible = visible,
    enter = slideInVertically {
        // Slide in from 40 dp from the top.
        with(density) { -40.dp.roundToPx() }
    } + expandVertically(
        // Expand from the top.
        expandFrom = Alignment.Top
    ) + fadeIn(
        // Fade in with the initial alpha of 0.3f.
        initialAlpha = 0.3f
    ),
    exit = slideOutVertically() + shrinkVertically() + fadeOut()
) {
    Text("Hello", Modifier.fillMaxWidth().height(200.dp))
}

如上例所示,您可以將多個 EnterTransitionExitTransition 物件與 + 運算子合併,並個別接受選擇性參數以自訂其行為。詳情請參閱參考資料。

EnterTransition 樣本

fadeIn

slideIn

slideInHorizontally

slideInVertically

scaleIn

expandIn

expandHorizontally

expandVertically

ExitTransition 樣本

fadeOut

slideOut

slideOutHorizontally

slideOutVertically

scaleOut

shrinkOut

shrinkHorizontally

shrinkVertically

AnimatedVisibility 也會提供需要 MutableTransitionState 的變化版本。如此一來,當 AnimatedVisibility 加入撰寫樹狀結構時,就會觸發動畫。它也可以用於觀察動畫狀態。

// Create a MutableTransitionState<Boolean> for the AnimatedVisibility.
val state = remember {
    MutableTransitionState(false).apply {
        // Start the animation immediately.
        targetState = true
    }
}
Column {
    AnimatedVisibility(visibleState = state) {
        Text(text = "Hello, world!")
    }

    // Use the MutableTransitionState to know the current animation state
    // of the AnimatedVisibility.
    Text(
        text = when {
            state.isIdle && state.currentState -> "Visible"
            !state.isIdle && state.currentState -> "Disappearing"
            state.isIdle && !state.currentState -> "Invisible"
            else -> "Appearing"
        }
    )
}

子項的進入與結束動畫

AnimatedVisibility (直接或間接子項) 中的內容可使用 animateEnterExit 輔助鍵為每個子項指定不同的動畫行為。每個子項的視覺效果都是由 AnimatedVisibility 可組合元件所指定的動畫和子項本身的進入和結束動畫組合而成。

AnimatedVisibility(
    visible = visible,
    enter = fadeIn(),
    exit = fadeOut()
) {
    // Fade in/out the background and the foreground.
    Box(Modifier.fillMaxSize().background(Color.DarkGray)) {
        Box(
            Modifier
                .align(Alignment.Center)
                .animateEnterExit(
                    // Slide in/out the inner box.
                    enter = slideInVertically(),
                    exit = slideOutVertically()
                )
                .sizeIn(minWidth = 256.dp, minHeight = 64.dp)
                .background(Color.Red)
        ) {
            // Content of the notification…
        }
    }
}

在某些情況下,您可能需要讓 AnimatedVisibility 完全不套用動畫,以便讓每個子項在 animateEnterExit 上擁有其本身獨特的動畫。為達成此目標,請在 AnimatedVisibility 可組合元件中指定 EnterTransition.NoneExitTransition.None

新增自訂動畫

除了內建的進入與結束動畫以外,如要新增自訂動畫效果,請透過 AnimatedVisibility 內容 lambda 中的 transition 屬性存取基礎 Transition 例項。新增至「轉換」例項的任何動畫狀態將會與 AnimatedVisibility 的進入與結束動畫同時執行。AnimatedVisibility 會等到 Transition 中所有動畫都結束然後再移除其內容。如果是與 Transition 分開建立 (例如使用 animate*AsState) 的結束動畫,AnimatedVisibility 將無法將其納入考量,因此可以在其完成前移除內容可組合元件。

AnimatedVisibility(
    visible = visible,
    enter = fadeIn(),
    exit = fadeOut()
) { // this: AnimatedVisibilityScope
    // Use AnimatedVisibilityScope#transition to add a custom animation
    // to the AnimatedVisibility.
    val background by transition.animateColor { state ->
        if (state == EnterExitState.Visible) Color.Blue else Color.Gray
    }
    Box(modifier = Modifier.size(128.dp).background(background))
}

如要進一步瞭解有關 Transition 的詳情,請參閱 updateConversion

AnimatedContent (實驗性)

AnimatedContent 可組合項會根據目標狀態變更其動畫顯示內容。

Row {
    var count by remember { mutableStateOf(0) }
    Button(onClick = { count++ }) {
        Text("Add")
    }
    AnimatedContent(targetState = count) { targetCount ->
        // Make sure to use `targetCount`, not `count`.
        Text(text = "Count: $targetCount")
    }
}

請注意,您應一律使用 lambda 參數,並將其反映至內容當中。API 會利用此值作為關鍵以識別目前顯示的內容。

根據預設,初始內容會淡出,然後目標內容淡入 (這個行為稱為淡出過程)。只要將 ContentTransform 物件指定至 transitionSpec 參數,即可自訂這個動畫的行為。您可以使用 with infix 函式結合 EnterTransitionExitTransition 建立 ContentTransform。可以將 SizeTransform 套用至 ContentTransform,做法是將它加至 using infix 函式裡。

AnimatedContent(
    targetState = count,
    transitionSpec = {
        // Compare the incoming number with the previous number.
        if (targetState > initialState) {
            // If the target number is larger, it slides up and fades in
            // while the initial (smaller) number slides up and fades out.
            slideInVertically { height -> height } + fadeIn() with
                slideOutVertically { height -> -height } + fadeOut()
        } else {
            // If the target number is smaller, it slides down and fades in
            // while the initial number slides down and fades out.
            slideInVertically { height -> -height } + fadeIn() with
                slideOutVertically { height -> height } + fadeOut()
        }.using(
            // Disable clipping since the faded slide-in/out should
            // be displayed out of bounds.
            SizeTransform(clip = false)
        )
    }
) { targetCount ->
    Text(text = "$targetCount")
}

EnterTransition 會定義目標內容的顯示方式,而 ExitTransition 則會定義初始內容的消失方式。除了 AnimatedVisibility 可用的所有 EnterTransitionExitTransition 函式外,AnimatedContent 也提供 slideIntoContainerslideOutOfContainer。 這些為 slideInHorizontally/VerticallyslideOutHorizontally/Vertically 的替代方案十分方便,可根據初始內容的大小和 AnimatedContent 內容的目標內容來計算投影片距離。

SizeTransform 定義大小 在初始內容和目標內容之間如何產生動畫動作。建立動畫時,您可以存取初始大小和目標大小。SizeTransform 也會控制在動畫期間是否應該將內容裁剪為元件大小。

var expanded by remember { mutableStateOf(false) }
Surface(
    color = MaterialTheme.colors.primary,
    onClick = { expanded = !expanded }
) {
    AnimatedContent(
        targetState = expanded,
        transitionSpec = {
            fadeIn(animationSpec = tween(150, 150)) with
                fadeOut(animationSpec = tween(150)) using
                SizeTransform { initialSize, targetSize ->
                    if (targetState) {
                        keyframes {
                            // Expand horizontally first.
                            IntSize(targetSize.width, initialSize.height) at 150
                            durationMillis = 300
                        }
                    } else {
                        keyframes {
                            // Shrink vertically first.
                            IntSize(initialSize.width, targetSize.height) at 150
                            durationMillis = 300
                        }
                    }
                }
        }
    ) { targetExpanded ->
        if (targetExpanded) {
            Expanded()
        } else {
            ContentIcon()
        }
    }
}

子項的進入/結束動畫

AnimatedVisibility 一樣,AnimatedContent 的內容 lambda 中具有animateEnterExit 修飾元。使用此方法將 EnterAnimationExitAnimation 分別套用至每個直接或間接的子項中。

新增自訂動畫

AnimatedVisibility 一樣,transition 欄位位於 AnimatedContent 的內容 lambda 中。使用這個方法建立可與 AnimatedContent 轉換同時執行的自訂動畫效果。詳情請參閱 updateTransition

animateContentSize

animateContentSize 輔助鍵會為大小變化建立動畫。

var message by remember { mutableStateOf("Hello") }
Box(
    modifier = Modifier.background(Color.Blue).animateContentSize()
) {
    Text(text = message)
}

交叉漸變

Crossfade 可在有交叉漸變動畫的兩個版面配置之間建立動畫。透過切換傳遞至 current 參數的值後,系統會以交叉漸變動畫切換內容。

var currentPage by remember { mutableStateOf("A") }
Crossfade(targetState = currentPage) { screen ->
    when (screen) {
        "A" -> Text("Page A")
        "B" -> Text("Page B")
    }
}

低階動畫 API

上一節提及的所有高階動畫 API 都是以低層級動畫 API 為基礎建立在其最頂層。

animate*AsState 函式是最簡單的 API,可使得即時值變更成為動畫值。其獲得屬於協同程式基礎 API 的 Animatable 支援,可繪製單一值動畫。updateTransition 可建立轉換物件,該物件可以管理多個動畫值,並根據狀態變更加以執行。rememberInfiniteTransition 也很類似,但建立了無限轉換作業,可管理多個無限期持續執行的動畫。所有這些 API 除 Animatable 外都是可組合元件,這意味這些動畫可以建立在作品之外。

所有這些 API 均根據更基礎的 Animation API 所建立。雖然多數應用程式不會直接與 Animation 互動,但是可以透過較高階 API 使用 Animation 的某些自訂功能。如要進一步瞭解有關 AnimationVectorAnimationSpec 的資訊,請參閱自訂動畫

顯示各種低階動畫 API 之間關係的圖表

animate*AsState

您可以透過 animate*AsState 函式是在 Compose 中為單一值製作動畫時最簡單的動畫 API。您只需提供結束值 (或目標值),API 就會開始從目前的值轉為指定值。

以下是使用這個 API 建立 Alpha 版動畫的範例。只要將 animateFloatAsState 中的目標值納入,Alpha 值就會是提供值 (在本例中為 1f0.5f) 之間的動畫值。

val alpha: Float by animateFloatAsState(if (enabled) 1f else 0.5f)
Box(
    Modifier.fillMaxSize()
        .graphicsLayer(alpha = alpha)
        .background(Color.Red)
)

請注意,您不需要建立任何動畫類別的例項,或處理干擾。在實際情況下,系統會在呼叫網站上建立並記住動畫物件 (亦即 Animatable 例項),並且將第一個目標值設為初始值。此後,只要您為此可組合元件提供不同的目標值,系統就會根據該值自動開始播放動畫。如果檔期中已有動畫,動畫會從目前值 (並且以目前速率) 開始,並根據目標值製作動畫。在動畫 期間,此可組合元件會重新整理,並針對每個頁框傳回更新後的 動畫值。

Compose 會為 animate*AsState 函式提供立即可用的 FloatColorDpSizeOffsetRectIntIntOffsetIntSize。只要將 TwoWayConverter 提供給使用一般類型的 animateValueAsState,即可輕易地新增其他資料類型的支援。

提供 AnimationSpec 即可自訂動畫規格。 詳情請參閱 AnimationSpec

Animatable

Animatable 是數值持有者,可在透過 animateTo 變更數值時為此值建立動畫。這是用來備份 animate*AsState 實作的 API。如此能確保一致的持續性和互斥性,因此數值變更會持續進行,並取消任何進行中的動畫。

Animatable 的許多功能 (包括 animateTo) 都會以暫停函式提供。這表示這些功能需要在適當的協同程式範圍內納入。例如,您可以使用 LaunchedEffect 可組合元件在指定鍵值的持續期間建立範圍。

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

在上面的範例中,我們會建立並記住 Animatable 的例項,同時初始值為 Color.Gray。根據布林值旗標 ok 的值,動畫顏色會設為 Color.GreenColor.Red。對布林值進行後續變更時,動畫會開始轉為另一個顏色。如果在數值變更時有動畫正在持續進行,系統將會取消動畫;而新動畫會以目前的數據匯報值和目前的速率開始。

此為可備份上一節所述的 animate*AsState API 的動畫實現。與 animate*AsState 相比,直接使用 Animatable 可為我們在多個方面進行更精細的控制。首先,Animatable 的初始值可以和其第一個目標值不同。例如,上述程式碼範例最初顯示灰色方塊,動畫顏色會立即開始變成綠色或紅色。其次,Animatable 會針對內容值提供更多作業,也就是 snapToanimateDecaysnapTo 會立即將目前值設為目標值。如果動畫本身並非唯一的真實來源,且需要與其他狀態 (例如觸控事件) 保持同步,這項功能就非常有用。animateDecay 會開始播放從指定速率開始減慢的動畫。這有助於實現快速滑過的行為。詳情請參閱手勢和動畫

Animatable 可立即支援 FloatColor,但是透過提供 TwoWayConverter 可以使用任何資料類型。詳情請參閱 AnimationVector

提供 AnimationSpec 即可自訂動畫規格。 詳情請參閱 AnimationSpec

updateTransition

Transition 會管理一個或多個動畫作為其子項,並同時在多個狀態之間執行它們。

狀態可以是任何資料類型。在許多情況下,您可以使用自訂 enum 類型以確保類型安全,如以下範例所示:

enum class BoxState {
    Collapsed,
    Expanded
}

updateTransition 會建立並記住 Transition 的例項並且更新其狀態。

var currentState by remember { mutableStateOf(BoxState.Collapsed) }
val transition = updateTransition(currentState)

接下來,您可以使用其中一個 animate* 延伸函式來定義這個轉換作業的子動畫。指定每個狀態的目標值。這些 animate* 函式會傳回動畫值,當轉換狀態透過 updateTransition 更新時,在動畫期間的每個頁框都會更新。

val rect by transition.animateRect { state ->
    when (state) {
        BoxState.Collapsed -> Rect(0f, 0f, 100f, 100f)
        BoxState.Expanded -> Rect(100f, 100f, 300f, 300f)
    }
}
val borderWidth by transition.animateDp { state ->
    when (state) {
        BoxState.Collapsed -> 1.dp
        BoxState.Expanded -> 0.dp
    }
}

您也可以選擇傳遞 transitionSpec 參數,為每個轉換狀態變更組合指定不同的 AnimationSpec。詳情請參閱 AnimationSpec

val color by transition.animateColor(
    transitionSpec = {
        when {
            BoxState.Expanded isTransitioningTo BoxState.Collapsed ->
                spring(stiffness = 50f)
            else ->
                tween(durationMillis = 500)
        }
    }
) { state ->
    when (state) {
        BoxState.Collapsed -> MaterialTheme.colors.primary
        BoxState.Expanded -> MaterialTheme.colors.background
    }
}

當轉換作業到達目標狀態時,Transition.currentState 將會與 Transition.targetState 相同。這可用來作為轉換作業 完成與否的訊號。

我們有時會希望初始狀態與第一個目標狀態不同。我們可以搭配使用 updateTransitionMutableTransitionState 來達成此目的。舉例來說,如此可以讓我們在程式碼進入轉譯後立即開始動畫。

// Start in collapsed state and immediately animate to expanded
var currentState = remember { MutableTransitionState(BoxState.Collapsed) }
currentState.targetState = BoxState.Expanded
val transition = updateTransition(currentState)
// ...

如果是較為複雜,涉及多個可組合函式的轉換,可以使用 createChildTransition 來建立子項轉換。這個技巧適合在複雜的可組合元件中分隔多個子元件。父項轉換 會瞭解子項轉換中的所有動畫值。

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

將轉換與 AnimatedVisibility 和 AnimatedContent 搭配使用

AnimatedVisibilityAnimatedContent 可以做為 Transition 的延伸函式。Transition.AnimatedVisibilityTransition.AnimatedContenttargetState 是 衍生自 Transition,並且會在 TransitiontargetState 變更時 視需要觸發進入/結束轉換。這些延伸函式可允許在其他情況下位於 AnimatedVisibility/AnimatedContent 內部的所有 enter/exit/sizeTransform 動畫被提升進入 Transition。擁有這些延伸函式後,可從外部觀察 AnimatedVisibility/AnimatedContent 的狀態變更。這個版本的 AnimatedVisibility 並非是布林值 visible 參數,而會使用 lambda 將父項轉換的目標狀態轉換為布林值。

詳情請參閱 AnimatedVisibilityAnimatedContent

var selected by remember { mutableStateOf(false) }
// Animates changes when `selected` is changed.
val transition = updateTransition(selected)
val borderColor by transition.animateColor { isSelected ->
    if (isSelected) Color.Magenta else Color.White
}
val elevation by transition.animateDp { 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")
            }
        }
    }
}

封裝轉換作業,方便其重複使用

為提供簡單用途,在與 UI 相同的可組合元件中定義轉換動畫是相當有效的選項。不過,如果您要使用含有多個動畫值的複雜元件,您可能需要將動畫實作與可組合的 UI 分開。

具體方法是建立包含所有動畫值的類別,以及傳回該類別例項的「update」函式。可以將轉換實作擷取到新的獨立函式中。如果需要將動畫邏輯集中使用,或讓複雜的動畫可重複使用,就很適合使用這個模式。

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)
    val color = transition.animateColor { state ->
        when (state) {
            BoxState.Collapsed -> Color.Gray
            BoxState.Expanded -> Color.Red
        }
    }
    val size = transition.animateDp { state ->
        when (state) {
            BoxState.Collapsed -> 64.dp
            BoxState.Expanded -> 128.dp
        }
    }
    return remember(transition) { TransitionData(color, size) }
}

工具支援

Android Studio 支援在動畫預覽中查看轉換效果。

  • 轉換作業的逐頁框預覽
  • 轉換作業中所有動畫的值檢閱
  • 預覽任何初始和目標狀態之間的轉換

開始動畫預覽時,您會看到「動畫」窗格,您可以在其中執行預覽中包括的任何轉換。轉換及其每個動畫值都會標上預設名稱。您可以在 updateTransitionanimate* 函式中指定 label 參數以自訂標籤。

動畫預覽面板

rememberInfiniteTransition

InfiniteTransition 會保留一個或多個子動畫 (例如 Transition),但動畫會在進入轉譯後立即開始執行,除非將其移除,否則不會停止。您可以使用 rememberInfiniteTransition 建立 InfiniteTransition 的例項。可以使用 animateColoranimatedFloatanimatedValue 新增子動畫。您也需要指定 infiniteRepeatable 來指定動畫 規格。

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

TargetBasedAnimation

TargetBasedAnimation 是我們目前所看過為止最低階的動畫 API。其他 API 涵蓋大多數用途,但直接使用 TargetBasedAnimation 可讓您自行控制動畫播放時間。在以下範例中,系統會根據 withFrameMillis 提供的影格時間手動控制 TargetAnimation 的播放時間。

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

自訂動畫

許多動畫 API 通常都接受參數以自訂其行為。

AnimationSpec

多數的動畫 API 都允許開發人員透過選用的 AnimationSpec 參數來自訂動畫規格。

val alpha: Float by animateFloatAsState(
    targetValue = if (enabled) 1f else 0.5f,
    // Configure the animation duration and easing.
    animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing)
)

有不同類型的 AnimationSpec 可用於建立不同類型的動畫。

彈簧動畫

spring 會在開始和結束值之間建立基於物理的動畫。這 需要使用 2 個參數:dampingRatiostiffness

dampingRatio 可定義彈簧應有的彈性。預設值為 Spring.DampingRatioNoBouncy

顯示不同阻尼比行為的動畫圖形

stiffness 可定義彈簧朝結束值進行的速度。預設值為 Spring.StiffnessMedium

val value by animateFloatAsState(
    targetValue = 1f,
    animationSpec = spring(
        dampingRatio = Spring.DampingRatioHighBouncy,
        stiffness = Spring.StiffnessMedium
    )
)

與以時間為基礎的 AnimationSpec 類型相比,spring 可更順暢地處理干擾,因為在動畫間變更目標值時,可以確保速率的持續性。許多動畫 API (例如 animate*AsStateupdateTransition) 會使用 spring 作為預設的 AnimationSpec。

補間動畫

使用加/減速曲線時,tween 會在指定 durationMillis 的開始值與結束值之間建立動畫。詳情請參閱加/減速。您還可以指定 delayMillis 來延遲動畫的開始。

val value by animateFloatAsState(
    targetValue = 1f,
    animationSpec = tween(
        durationMillis = 300,
        delayMillis = 50,
        easing = LinearOutSlowInEasing
    )
)

主要畫面格

keyframes 的動畫是根據動畫期間不同時間戳記指定的快照值所建立。在任何指定的時間,動畫值將在兩個主要畫面格值之間插入。針對每個主要畫面格,可以指定加/減速以便藉此決定內插曲線。

您可以選擇將數值指定為 0 毫秒以及持續時間。如果沒有指定這些值,這些值會分別預設為動畫的開始與結束值。

val value by animateFloatAsState(
    targetValue = 1f,
    animationSpec = keyframes {
        durationMillis = 375
        0.0f at 0 with LinearOutSlowInEasing // for 0-15 ms
        0.2f at 15 with FastOutLinearInEasing // for 15-75 ms
        0.4f at 75 // ms
        0.4f at 225 // ms
    }
)

可重複

repeatable 會不斷執行以時間為基礎的動畫 (例如 tweenkeyframes),直到達到指定的疊代次數為止。您可以傳遞 repeatMode 參數以指定是否應該從開始 (RepeatMode.Restart) 或結束 (RepeatMode.Reverse) 開始重複播放動畫。

val value by animateFloatAsState(
    targetValue = 1f,
    animationSpec = repeatable(
        iterations = 3,
        animation = tween(durationMillis = 300),
        repeatMode = RepeatMode.Reverse
    )
)

infiniteRepeatable

infiniteRepeatable 類似 repeatable,但會重複無限數量的疊代作業。

val value by animateFloatAsState(
    targetValue = 1f,
    animationSpec = infiniteRepeatable(
        animation = tween(durationMillis = 300),
        repeatMode = RepeatMode.Reverse
    )
)

在使用 ComposeTestRule 的測試中,系統將不會執行使用 infiniteRepeatable 的動畫。此元件會使用每個動畫值的初始值來呈現。

貼齊

snap 是可立即將值轉換為結束值的特殊 AnimationSpec。您可以指定 delayMillis 以便延後動畫開始的時間。

val value by animateFloatAsState(
    targetValue = 1f,
    animationSpec = snap(delayMillis = 50)
)

加/減速

基於持續時間的 AnimationSpec 作業 (例如 tweenkeyframes) 會使用 Easing 來調整動畫的分數。如此一來,動畫值就能用來加速或減速,而非以固定速率移動。分數代表介於 0 (開始) 和 1.0 (結束) 之間的值,以表示動畫中的目前點。

加/減速實際上是在 0 和 1.0 之間取得分數值,然後傳回浮點值的函式。傳回的值可能超出範圍,表示過衝或是下衝。您可以按照下方程式碼建立自訂加/減速。

val CustomEasing = Easing { fraction -> fraction * fraction }

@Composable
fun EasingUsage() {
    val value by animateFloatAsState(
        targetValue = 1f,
        animationSpec = tween(
            durationMillis = 300,
            easing = CustomEasing
        )
    )
    // ...
}

Compose 提供數種涵蓋大多數用途的內建 Easing 函式。請參閱「速度 - 質感設計」,瞭解有關根據本身情境使用哪些加/減速的詳細資訊。

  • FastOutSlowInEasing
  • LinearOutSlowInEasing
  • FastOutLinearEasing
  • LinearEasing
  • CubicBezierEasing

AnimationVector

多數 Compose 動畫 API 皆支援 FloatColorDp 和其他基本資料類型作為可立即使用的動畫值,但在某些情況下,您需要為其他資料類型 (包括您的自訂類型) 製作動畫。在動畫期間,任何動畫值都會以 AnimationVector 表示。藉由對應 TwoWayConverter 使得核心動畫系統可以統一處理它們,此值會轉換成 AnimationVector (反之亦然)。舉例來說,Int 代表一個保有單一浮點值的 AnimationVector1D。「Int」的「TwoWayConverter」看起來會像是這樣:

val IntToVector: TwoWayConverter<Int, AnimationVector1D> =
    TwoWayConverter({ AnimationVector1D(it.toFloat()) }, { it.value.toInt() })

Color 基本上是一組 4 個值,例如紅色、綠色、藍色和 alpha,因此 Color 會轉換為包含 4 個浮動值的 AnimationVector4D。在這種情況下,動畫中使用的每種資料類型都會被轉換成 AnimationVector1DAnimationVector2DAnimationVector3D,或 AnimationVector4D (視其維度而定)。如此一來,物件的不同元件就可以個別建立動畫,每個動畫都有各自的速率追蹤。基本資料類型的內建轉換器可使用 Color.VectorConverterDp.VectorConverter 等方式存取。

如要想要為新的資料類型新增支援以作為動畫值,您可以建立專屬的 TwoWayConverter 並將其提供給 API。舉例來說,您可以使用 animateValueAsState 為自訂資料類型建立動畫,如下所示:

data class MySize(val width: Dp, val height: Dp)

@Composable
fun MyAnimation(targetSize: MySize) {
    val animSize: MySize by animateValueAsState<MySize, AnimationVector2D>(
        targetSize,
        TwoWayConverter(
            convertToVector = { size: MySize ->
                // Extract a float value from each of the `Dp` fields.
                AnimationVector2D(size.width.value, size.height.value)
            },
            convertFromVector = { vector: AnimationVector2D ->
                MySize(vector.v1.dp, vector.v2.dp)
            }
        )
    )
}

手勢和動畫 (進階)

與單獨使用動畫相比,在使用觸控事件和動畫時,必須考量幾件事情。首先,在觸控事件開始時,我們可能必須中斷處理中的動畫,因為使用者互動事件應該擁有最高的優先順序。

在以下範例中,我們使用 Animatable 表示圓形元件的偏移位置。觸控事件會透過 pointerInput 輔助鍵處理。我們偵測到新的輕觸事件時,會呼叫 animateTo 以將位移值設定成輕觸位置。輕觸事件也可能會在出現動畫期間發生,在這種情況下,animateTo 會中斷目前播放的動畫,並將動畫從新的目標位置開始,同時維持已中斷動畫的速度。

@Composable
fun Gesture() {
    val offset = remember { Animatable(Offset(0f, 0f), Offset.VectorConverter) }
    Box(
        modifier = Modifier
            .fillMaxSize()
            .pointerInput(Unit) {
                coroutineScope {
                    while (true) {
                        // Detect a tap event and obtain its position.
                        val position = awaitPointerEventScope {
                            awaitFirstDown().position
                        }
                        launch {
                            // Animate to the tap position.
                            offset.animateTo(position)
                        }
                    }
                }
            }
    ) {
        Circle(modifier = Modifier.offset { offset.value.toIntOffset() })
    }
}

private fun Offset.toIntOffset() = IntOffset(x.roundToInt(), y.roundToInt())

另一個常見的模式是需要將動畫值與來自觸控事件 (例如拖曳) 的值同步處理。在以下範例中,我們看到「滑動即可關閉」已執行作為 Modifier (而非使用 SwipeToDismiss 可組合元件)。元素的水平位移會以 Animatable 表示。這個 API 在手勢動畫中相當實用。觸控事件和動畫都可以變更這個值。收到向下觸控事件時,我們會透過 stop 方法停止 Animatable,以便攔截所有進行中的動畫。

在拖曳事件期間,我們會使用 snapTo 以透過觸控事件計算的值來更新 Animatable 值。在快速滑過時,Compose 會提供 VelocityTracker 以記錄拖曳事件並計算速率。該速率可以直接動態饋給至 animateDecay 以便快速滑過動畫。如果想要將位移值滑回原始位置,我們可以使用 animateTo 方法指定 0f 的目標位移值。

fun Modifier.swipeToDismiss(
    onDismissed: () -> Unit
): Modifier = composed {
    val offsetX = remember { Animatable(0f) }
    pointerInput(Unit) {
        // Used to calculate fling decay.
        val decay = splineBasedDecay<Float>(this)
        // Use suspend functions for touch events and the Animatable.
        coroutineScope {
            while (true) {
                // Detect a touch down event.
                val pointerId = awaitPointerEventScope { awaitFirstDown().id }
                val velocityTracker = VelocityTracker()
                // Stop any ongoing animation.
                offsetX.stop()
                awaitPointerEventScope {
                    horizontalDrag(pointerId) { change ->
                        // Update the animation value with touch events.
                        launch {
                            offsetX.snapTo(
                                offsetX.value + change.positionChange().x
                            )
                        }
                        velocityTracker.addPosition(
                            change.uptimeMillis,
                            change.position
                        )
                    }
                }
                // No longer receiving touch events. Prepare the animation.
                val velocity = velocityTracker.calculateVelocity().x
                val targetOffsetX = decay.calculateTargetValue(
                    offsetX.value,
                    velocity
                )
                // The animation stops when it reaches the bounds.
                offsetX.updateBounds(
                    lowerBound = -size.width.toFloat(),
                    upperBound = size.width.toFloat()
                )
                launch {
                    if (targetOffsetX.absoluteValue <= size.width) {
                        // Not enough velocity; Slide back.
                        offsetX.animateTo(
                            targetValue = 0f,
                            initialVelocity = velocity
                        )
                    } else {
                        // The element was swiped away.
                        offsetX.animateDecay(velocity, decay)
                        onDismissed()
                    }
                }
            }
        }
    }
        .offset { IntOffset(offsetX.value.roundToInt(), 0) }
}

測試

Compose 提供 ComposeTestRule,可讓您以確定的方式編寫動畫測試,並完全控制測試時鐘。如此將可驗證中間動畫值。此外,執行測試的速度會比動畫的實際持續時間更快。

ComposeTestRule 會將測試時鐘顯示為 mainClock。您可以將 autoAdvance 屬性設為 false,控制測試程式碼中的時鐘。開始要測試的動畫之後,時鐘會以 advanceTimeBy 向前移動。

這裡要注意的一件事情是,advanceTimeBy 不會完全依指定的持續時間移動時鐘。而是將秒數四捨五入至最接近的持續時間,而此時間為畫格持續時間的倍數。

@get:Rule
val rule = createComposeRule()

@Test
fun testAnimationWithClock() {
    // Pause animations
    rule.mainClock.autoAdvance = false
    var enabled by mutableStateOf(false)
    rule.setContent {
        val color by animateColorAsState(
            targetValue = if (enabled) Color.Red else Color.Green,
            animationSpec = tween(durationMillis = 250)
        )
        Box(Modifier.size(64.dp).background(color))
    }

    // Initiate the animation.
    enabled = true

    // Let the animation proceed.
    rule.mainClock.advanceTimeBy(50L)

    // Compare the result with the image showing the expected result.
    // `assertAgainGolden` needs to be implemented in your code.
    rule.onRoot().captureToImage().assertAgainstGolden()
}

瞭解詳情

如要瞭解更多有關 Jetpack Compose 中的動畫詳情,請參閱下列其他資源:

Codelab

影片