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
。
- 使用
- 否則,請使用
AnimationState
或animate
。
高階動畫 API
Compose 提供高階動畫 API 以便用於在眾多應用程式中常用的數種動畫模式。這些 API 的設計符合質感設計動態的最佳做法。
AnimatedVisibility (實驗性)
AnimatedVisibility
可組合元件以動畫方式呈現內容的顯示與消失。
var editable by remember { mutableStateOf(true) }
AnimatedVisibility(visible = editable) {
Text(text = "Edit")
}
根據預設,內容會以淡入與展開方式出現,並且以淡出和縮小方式消失。藉由指定 EnterTransition
和 ExitTransition
可以自訂轉換。
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))
}
如上例所示,您可以將多個 EnterTransition
或 ExitTransition
物件與 +
運算子合併,並個別接受選擇性參數以自訂其行為。詳情請參閱參考資料。
EnterTransition
樣本
ExitTransition
樣本
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.None
和 ExitTransition.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 函式結合 EnterTransition
與 ExitTransition
建立 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
可用的所有 EnterTransition
和 ExitTransition
函式外,AnimatedContent
也提供 slideIntoContainer
和 slideOutOfContainer
。
這些為 slideInHorizontally/Vertically
和 slideOutHorizontally/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
修飾元。使用此方法將 EnterAnimation
和 ExitAnimation
分別套用至每個直接或間接的子項中。
新增自訂動畫
和 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
的某些自訂功能。如要進一步瞭解有關 AnimationVector
和 AnimationSpec
的資訊,請參閱自訂動畫。
animate*AsState
您可以透過 animate*AsState
函式是在 Compose 中為單一值製作動畫時最簡單的動畫 API。您只需提供結束值 (或目標值),API 就會開始從目前的值轉為指定值。
以下是使用這個 API 建立 Alpha 版動畫的範例。只要將 animateFloatAsState
中的目標值納入,Alpha 值就會是提供值 (在本例中為 1f
或 0.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
函式提供立即可用的 Float
、
Color
、Dp
、Size
、Offset
、Rect
、Int
、IntOffset
和
IntSize
。只要將 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.Green
或 Color.Red
。對布林值進行後續變更時,動畫會開始轉為另一個顏色。如果在數值變更時有動畫正在持續進行,系統將會取消動畫;而新動畫會以目前的數據匯報值和目前的速率開始。
此為可備份上一節所述的 animate*AsState
API 的動畫實現。與 animate*AsState
相比,直接使用 Animatable
可為我們在多個方面進行更精細的控制。首先,Animatable
的初始值可以和其第一個目標值不同。例如,上述程式碼範例最初顯示灰色方塊,動畫顏色會立即開始變成綠色或紅色。其次,Animatable
會針對內容值提供更多作業,也就是 snapTo
和 animateDecay
。snapTo
會立即將目前值設為目標值。如果動畫本身並非唯一的真實來源,且需要與其他狀態 (例如觸控事件) 保持同步,這項功能就非常有用。animateDecay
會開始播放從指定速率開始減慢的動畫。這有助於實現快速滑過的行為。詳情請參閱手勢和動畫。
Animatable
可立即支援 Float
和 Color
,但是透過提供 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
相同。這可用來作為轉換作業
完成與否的訊號。
我們有時會希望初始狀態與第一個目標狀態不同。我們可以搭配使用 updateTransition
和 MutableTransitionState
來達成此目的。舉例來說,如此可以讓我們在程式碼進入轉譯後立即開始動畫。
// 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 搭配使用
AnimatedVisibility
和 AnimatedContent
可以做為 Transition
的延伸函式。Transition.AnimatedVisibility
和 Transition.AnimatedContent
的 targetState
是
衍生自 Transition
,並且會在 Transition
的 targetState
變更時
視需要觸發進入/結束轉換。這些延伸函式可允許在其他情況下位於 AnimatedVisibility
/AnimatedContent
內部的所有 enter/exit/sizeTransform 動畫被提升進入 Transition
。擁有這些延伸函式後,可從外部觀察 AnimatedVisibility
/AnimatedContent
的狀態變更。這個版本的 AnimatedVisibility
並非是布林值 visible
參數,而會使用 lambda 將父項轉換的目標狀態轉換為布林值。
詳情請參閱 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")
}
}
}
}
封裝轉換作業,方便其重複使用
為提供簡單用途,在與 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 支援在動畫預覽中查看轉換效果。
- 轉換作業的逐頁框預覽
- 轉換作業中所有動畫的值檢閱
- 預覽任何初始和目標狀態之間的轉換
開始動畫預覽時,您會看到「動畫」窗格,您可以在其中執行預覽中包括的任何轉換。轉換及其每個動畫值都會標上預設名稱。您可以在 updateTransition
和 animate*
函式中指定 label
參數以自訂標籤。
rememberInfiniteTransition
InfiniteTransition
會保留一個或多個子動畫 (例如 Transition
),但動畫會在進入轉譯後立即開始執行,除非將其移除,否則不會停止。您可以使用 rememberInfiniteTransition
建立 InfiniteTransition
的例項。可以使用 animateColor
、animatedFloat
或 animatedValue
新增子動畫。您也需要指定
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 個參數:dampingRatio
和 stiffness
。
dampingRatio
可定義彈簧應有的彈性。預設值為
Spring.DampingRatioNoBouncy
。
stiffness
可定義彈簧朝結束值進行的速度。預設值為 Spring.StiffnessMedium
。
val value by animateFloatAsState(
targetValue = 1f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioHighBouncy,
stiffness = Spring.StiffnessMedium
)
)
與以時間為基礎的 AnimationSpec
類型相比,spring
可更順暢地處理干擾,因為在動畫間變更目標值時,可以確保速率的持續性。許多動畫 API (例如 animate*AsState
和 updateTransition
) 會使用 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
會不斷執行以時間為基礎的動畫 (例如 tween
或 keyframes
),直到達到指定的疊代次數為止。您可以傳遞 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
作業 (例如 tween
或 keyframes
) 會使用 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 皆支援 Float
、Color
、Dp
和其他基本資料類型作為可立即使用的動畫值,但在某些情況下,您需要為其他資料類型 (包括您的自訂類型) 製作動畫。在動畫期間,任何動畫值都會以 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
。在這種情況下,動畫中使用的每種資料類型都會被轉換成 AnimationVector1D
、AnimationVector2D
、AnimationVector3D
,或
AnimationVector4D
(視其維度而定)。如此一來,物件的不同元件就可以個別建立動畫,每個動畫都有各自的速率追蹤。基本資料類型的內建轉換器可使用 Color.VectorConverter
、Dp.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 中的動畫詳情,請參閱下列其他資源: