動畫

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

總覽

動畫是現代行動應用程式中不可或缺的元素,能夠提供流暢且明瞭的使用者體驗。許多 Jetpack Compose Animation API 的使用方式如同版面配置和其他 UI 元素等可組合函式,採用以 Kotlin 協同程式暫停函式建立的低階 API。本指南從對許多實際情況都相當實用的高階 API 開始介紹,隨後說明可讓您進一步控管及自訂功能的低階 API。

下圖可協助您決定要使用哪個 API 實作動畫。

這張流程圖展示了用於選擇適當動畫 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 物件與 + 運算子合併,並為每個物件納入選用參數來自訂行為。詳情請參閱參考資料。

EnterTransitionExitTransition 範例

EnterTransition ExitTransition
fadeIn
淡入動畫
fadeOut
淡出動畫
slideIn
滑入動畫
slideOut
滑出動畫
slideInHorizontally
水平滑入動畫
slideOutHorizontally
水平滑出動畫
slideInVertically
垂直滑入動畫
slideOutVertically
垂直滑出動畫
scaleIn
向內縮減動畫
scaleOut
向外擴充動畫
expandIn
展開進入動畫
shrinkOut
縮小退出動畫
expandHorizontally
水平展開動畫
shrinkHorizontally
水平縮小動畫
expandVertically
垂直展開動畫
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 例項。新增至 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

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 的特點在於可為 FloatColorDpSizeOffsetRectIntIntOffsetIntSize 提供 animate*AsState 函式。只要為可接受一般類型的 animateValueAsState 提供 TwoWayConverter,即可輕鬆將其他資料類型納入支援。

您可以納入 AnimationSpec 來自訂動畫規格。詳情請參閱 AnimationSpec

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

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

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

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 會接受可將上層轉換的目標狀態轉換為布林值的 lambda,但不接受布林值的 visible 參數。

詳情請參閱「AnimatedVisibility」和「AnimatedContent」。

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

封裝 Transition 以重複使用

如果是簡單用途,在與 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 支援在「Animation Preview」中檢查轉場效果。

  • 預覽每個影格的轉場效果
  • 檢查轉場效果中所有動畫的值
  • 預覽任何初始和目標狀態之間的轉場效果

開啟 Animation Preview 時,您會看到「Animations」窗格,可讓您執行預覽中包含的任何轉場效果。轉場效果及其每個動畫值都會標有預設名稱。您可以在 updateTransitionanimate* 函式中指定 label 參數來自訂標籤。

Animation Preview 面板

rememberInfiniteTransition

InfiniteTransitionTransition 一樣會保留一個或多個子動畫,但這類動畫會在進入組合階段後立即開始執行。除非您將動畫移除,否則動畫不會停止。您可以使用 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))

低階動畫 API

上一節提及的所有高階動畫 API 都是以低階動畫 API 為基礎建構而成。

animate*AsState 函式是最簡單的 API,可將即時值變化轉譯為動畫值。這個函式採用的 Animatable 是以協同程式為基礎的 API,可為單一值建立動畫效果。updateTransition 可建立轉場物件,用於管理多個動畫值並依據狀態變化執行這些值。rememberInfiniteTransition 也是類似的函式,但可建立無限轉場效果,用於管理多個無限期執行的動畫。這些 API (Animatable 除外) 都是可組合項,意味著您可以在組合外建立這些動畫效果。

這些 API 都是根據最根本的 Animation API 所建立。雖然多數應用程式不會直接與 Animation 互動,但可透過高階 API 使用 Animation 的某些自訂功能。如要進一步瞭解 AnimationVectorAnimationSpec,請參閱「自訂動畫」。

這張圖表展示各種低階動畫 API 之間的關聯性

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

在上述範例中,我們建立並記住了初始值為 Color.GrayAnimatable 執行個體。根據布林值旗標 ok 的值,動畫顏色會設為 Color.GreenColor.Red。對布林值進行後續變更時,動畫會開始呈現另一個顏色。如果在變更該值時有正在執行的動畫,則系統會取消動畫,且新動畫會以目前速率從目前的快照值開始執行。

以上便是根據上一節所述的 animate*AsState API 建構的動畫實作內容。與 animate*AsState 相比,我們可直接使用 Animatable 多方面進行更精細的控制。首先,Animatable 的初始值可以與其第一個目標值不同。舉例來說,上述程式碼範例最初顯示灰色方塊,隨後開始呈現轉為綠色或紅色的動畫效果。其次,Animatable 針對內容值提供更多操作,也就是 snapToanimateDecaysnapTo 會立即將現值設為目標值。如果動畫本身並非唯一的可靠來源,且需要與其他狀態 (例如觸控事件) 保持同步,這個函式就非常實用。animateDecay 會開始執行從指定速率開始減慢的動畫,有助於實作快速滑過行為。詳情請參閱「手勢和動畫」。

Animatable 的特點在於可支援 FloatColor,但只要納入 TwoWayConverter 即可使用任何資料類型。詳情請參閱「AnimationVector」。

您可以納入 AnimationSpec 來自訂動畫規格。詳情請參閱 AnimationSpec

動畫

Animation 是可用的最低階 Animation API。目前為止,我們看到的許多動畫都是以 Animation 為基礎。有兩種 Animation 子類型:TargetBasedAnimationDecayAnimation

Animation 只該用來手動控制動畫的時間。Animation 是無狀態的,沒有任何生命週期概念。它用做較高階 API 使用的動畫計算引擎。

TargetBasedAnimation

其他 API 涵蓋大部分應用實例,但直接使用 TargetBasedAnimation,可讓您自行控制動畫播放時間。在以下範例中,您要根據 withFrameNanos 提供的影格時間手動控制 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())
}

DecayAnimation

TargetBasedAnimation 不同,DecayAnimation 不需要提供 targetValue。相反,它會根據啟動條件 (由 initialVelocityinitialValue 以及提供的 DecayAnimationSpec 設定) 計算其 targetValue

衰退動畫通常在在翻轉手勢後使用,以將元素放慢至停止。動畫速率以 initialVelocityVector 設定的值開始,且會隨時間變慢。

自訂動畫

許多 Animation 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

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

tween 會使用緩和曲線,透過指定的 durationMillis 於起始值和結束值之間建立動畫效果。詳情請參閱「Easing」。您也可以指定 delayMillis 來延遲動畫的開始時間。

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

keyframes

keyframes 會根據動畫期間不同時間戳記中指定的快照值建立動畫效果。在任何指定的時間,系統會在兩個主要畫面格值之間內插動畫值。針對對每個主要畫面格,都能指定 Easing 來決定內插曲線。

您可以選擇將在 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

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

infiniteRepeatablerepeatable 類似,但會重複無限次的疊代。

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

在使用 ComposeTestRule 的測試中,系統不會執行使用 infiniteRepeatable 的動畫,而會使用每個動畫值的初始值來轉譯這個元件。

snap

snap 是一種特殊的 AnimationSpec,可將值立即切換到結束值。您可以指定 delayMillis 以便延遲動畫的開始時間。

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

Easing

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

Easing 實際上是一種函式,可接受介於 0 和 1.0 之間的分數值並傳回浮點值。傳回的值可能在範圍邊界外,代表過衝或是下衝。您可以建立自訂的 Easing,如以下程式碼所示。

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

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

Compose 提供多種內建 Easing 函式,可滿足大多數用途的需求。如要進一步瞭解如何根據您的情境使用哪些 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 是紅色、綠色、藍色和 alpha 這 4 個值的組合,因此 Color 可轉換成包含 4 個浮點值的 AnimationVector4D。透過這種方式,動畫中使用的每種資料類型都會轉換成 AnimationVector1DAnimationVector2DAnimationVector3DAnimationVector4D,視其維度而定。如此一來,您就能針對物件的不同元件個別建立動畫效果,而每個元件都有各自的速率追蹤。基本資料類型的內建轉換器可使用 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)
            }
        )
    )
}

動畫向量資源 (實驗功能)

若要使用 AnimatedVectorDrawable 資源,請使用 animatedVectorResource 載入可繪項目檔案,然後傳遞到 boolean,以便讓可繪項目切換開始和結束狀態。

@Composable
fun AnimatedVectorDrawable() {
    val image = AnimatedImageVector.animatedVectorResource(R.drawable.ic_hourglass_animated)
    var atEnd by remember { mutableStateOf(false) }
    Image(
        painter = rememberAnimatedVectorPainter(image, atEnd),
        contentDescription = "Timer",
        modifier = Modifier.clickable {
            atEnd = !atEnd
        },
        contentScale = ContentScale.Crop
    )
}

有關可繪項目檔案格式的詳細資訊請參閱「以動畫方式呈現可繪項目圖形」。

清單項目動畫

如果您要在 Lazy 清單或格線中為項目重新排序建立動畫,請參閱 Lazy 版面配置項目動畫文件

手勢和動畫 (進階)

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

在以下範例中,我們使用 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,以便攔截任何執行中的動畫。

在拖曳事件期間,我們會使用 snapToAnimatable 值更新為從觸控事件計算得出的值。針對快速滑過,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 中的動畫,請參閱以下其他資源:

範例

網誌文章

程式碼研究室

影片