1. 簡介
上次更新時間:2023 年 11 月 21 日
在本程式碼研究室中,您將瞭解如何使用 Jetpack Compose 中的一些動畫 API。
Jetpack Compose 是專為簡化 UI 開發程序而設計的新型 UI 工具包。如果您是第一次使用 Jetpack Compose,不妨先參考以下的程式碼研究室。
課程內容
- 如何使用多個基本動畫 API
必要條件
- 具備 Kotlin 基本知識
- 具備 Compose 基本知識,包括:
- 簡易版面配置 (欄、列、方塊等)
- 簡易 UI 元素 (按鈕、文字等)
- 狀態和重新組成
軟硬體需求
2. 開始設定
請下載程式碼研究室的程式碼。您可以複製存放區,如下所示:
$ git clone https://github.com/android/codelab-android-compose.git
或者,您也可以將存放區下載為 ZIP 檔案:
在 Android Studio 中匯入 AnimationCodelab
專案。
專案中包含多個模組:
start
是本程式碼研究室的起始狀態。finished
是完成本程式碼研究室之後的應用程式最終狀態。
請確定您已選取執行設定下拉式選單內的 start
。
我們會從下一節開始介紹幾種動畫情境。本程式碼研究室使用的每個程式碼片段都會加上 // TODO
註解。只要開啟 Android Studio 中的「TODO」工具視窗,就能瀏覽該章節的每個 TODO 註解。
3. 為簡易值變更加上動畫效果
首先要使用 Compose 中最簡單的動畫 API 之一:animate*AsState
API。如要為 State
變更加上動畫效果,則應使用這個 API。
請執行 start
設定,然後點選頂端的「Home」和「Work」按鈕,嘗試切換分頁。這項操作並不會實際切換分頁內容,但內容的背景顏色會變換。
按一下「TODO」工具視窗中的「TODO 1」,並查看實作方式。這個內容位於 Home
可組合函式。
val backgroundColor = if (tabPage == TabPage.Home) Seashell else GreenLight
此處的 tabPage
是由 State
物件支援的 TabPage
。背景色彩會依照值切換為蜜桃色和綠色。我們要為這種值變更行為加上動畫效果。
為這種簡單的值變更行為加上動畫效果時,可以使用 animate*AsState
API。只要把變更的值納入 animate*AsState
可組合函式的對應變數 (在此情況下為 animateColorAsState
),就能建立動畫值。回傳的值為 State<T>
物件,因此可以使用本機指派屬性搭配 by
宣告,將該物件視為普通變數。
val backgroundColor by animateColorAsState(
targetValue = if (tabPage == TabPage.Home) Seashell else GreenLight,
label = "background color")
請再次執行應用程式,並嘗試切換分頁。現在色彩變更時會有動畫效果。
4. 為顯示項目加上動畫效果
如果您捲動應用程式內容,就會發現懸浮動作按鈕隨著捲動方向展開和縮小。
找到 TODO 2-1 並看看運作方式。這個項目位於 HomeFloatingActionButton
可組合函式。系統會使用 if
陳述式決定要顯示或隱藏「EDIT」文字。
if (extended) {
Text(
text = stringResource(R.string.edit),
modifier = Modifier
.padding(start = 8.dp, top = 3.dp)
)
}
如要為這種顯示變更行為加上動畫效果,只要將 if
替換為 AnimatedVisibility
可組合函式即可。
AnimatedVisibility(extended) {
Text(
text = stringResource(R.string.edit),
modifier = Modifier
.padding(start = 8.dp, top = 3.dp)
)
}
請執行應用程式,查看懸浮動作按鈕 (FAB) 現在如何展開和縮小。
每當指定的 Boolean
值有所變更,AnimatedVisibility
都會執行動畫。根據預設,AnimatedVisibility
顯示元素時會淡入並展開,隱藏元素時則會淡出並縮小。這項行為非常適合此範例中的懸浮動作按鈕 (FAB),但我們也可以自訂行為。
嘗試點選懸浮動作按鈕 (FAB) 後,應該會看到「Edit feature is not supported」訊息。如要加上出現和消失動畫效果,也會用到 AnimatedVisibility
。接下來,您將會自訂這項行為,讓這個訊息從頂端滑入,然後再滑出頂端。
請找出 TODO 2-2,然後查看 EditMessage
可組合函式中的程式碼。
AnimatedVisibility(
visible = shown
) {
Surface(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.secondary,
elevation = 4.dp
) {
Text(
text = stringResource(R.string.edit_message),
modifier = Modifier.padding(16.dp)
)
}
}
為了自訂動畫,請在 AnimatedVisibility
可組合函式中加入 enter
和 exit
參數。
enter
參數應該是 EnterTransition
的例項。在本範例中,我們可以利用 slideInVertically
函式建立 EnterTransition
和 slideOutVertically
,製作退出轉場效果。請變更程式碼,如下所示:
AnimatedVisibility(
visible = shown,
enter = slideInVertically(),
exit = slideOutVertically()
)
請再度執行應用程式,然後點選「EDIT」按鈕。您可能會發現動畫效果更流暢,但不太正確,這是因為 slideInVertically
和 slideOutVertically
的預設行為只使用項目的一半高度。
針對進入轉場效果,我們可以設定 initialOffsetY
參數,將預設行為調整為使用項目的完整高度,製作出合適的動畫效果。initialOffsetY
應是會傳回初始位置的 lambda。
這個 lambda 會接收元素高度引數。為確保項目從畫面頂端滑入,我們會傳回負值,因為畫面頂端的值為 0。我們想讓動畫從 -height
開始再到 0
(最終位置),這樣動畫就會從上方開始進入。
使用 slideInVertically
時,滑入後的目標偏移值一律為 0
(像素)。initialOffsetY
可以指定為絕對值,或以 lambda 函式指定為元素完整高度的百分比。
同樣地,slideOutVertically
也會將初始偏移值假設為 0,所以只需要指定 targetOffsetY
。
AnimatedVisibility(
visible = shown,
enter = slideInVertically(
// Enters by sliding down from offset -fullHeight to 0.
initialOffsetY = { fullHeight -> -fullHeight }
),
exit = slideOutVertically(
// Exits by sliding up from offset 0 to -fullHeight.
targetOffsetY = { fullHeight -> -fullHeight }
)
) {
Surface(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.secondary,
elevation = 4.dp
) {
Text(
text = stringResource(R.string.edit_message),
modifier = Modifier.padding(16.dp)
)
}
}
請再度執行應用程式,就可以看到動畫更接近預期效果:
我們可以利用 animationSpec
參數,進一步自訂動畫。animationSpec
是許多動畫 API 的常用參數,包括 EnterTransition
和 ExitTransition
。我們可以傳遞其中一種 AnimationSpec
類型,指定動畫值應如何隨著時間變化。在這個範例中,我們要使用以時間長度為基礎的簡易 AnimationSpec
。您可以使用 tween
函式建立這個項目。時間長度為 150 毫秒,加/減速為 LinearOutSlowInEasing
。至於退出動畫,我們在 animationSpec
參數使用相同的 tween
函式,但是時間設為 250 毫秒,加/減速為 FastOutLinearInEasing
。
完成的程式碼應如下所示:
AnimatedVisibility(
visible = shown,
enter = slideInVertically(
// Enters by sliding down from offset -fullHeight to 0.
initialOffsetY = { fullHeight -> -fullHeight },
animationSpec = tween(durationMillis = 150, easing = LinearOutSlowInEasing)
),
exit = slideOutVertically(
// Exits by sliding up from offset 0 to -fullHeight.
targetOffsetY = { fullHeight -> -fullHeight },
animationSpec = tween(durationMillis = 250, easing = FastOutLinearInEasing)
)
) {
Surface(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.secondary,
elevation = 4.dp
) {
Text(
text = stringResource(R.string.edit_message),
modifier = Modifier.padding(16.dp)
)
}
}
請再度執行應用程式,然後點選懸浮動作按鈕 (FAB)。您現在可以看到訊息從頂端滑入並滑出,且具有不同的加/減速函式和時間長度:
5. 為內容大小變更加上動畫效果
這個應用程式的內容會顯示多個主題。您可以嘗試點選任何主題,主題應會展開並顯示內文。主題顯示或隱藏內文時,含有文字的資訊卡分別會展開和縮小。
請查看 TopicRow
可組合函式中 TODO 3 的程式碼。
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
// ... the title and the body
}
此 Column
可組合函式會在內容變更時變更大小。我們可以加入 animateContentSize
修飾符,為這個大小變更行為加上動畫效果。
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.animateContentSize()
) {
// ... the title and the body
}
請執行應用程式,然後點選其中一個主題。您可以看到主題展開和縮小的動畫。
經過自訂的 animationSpec
也可以用來自訂 animateContentSize
。我們可以提供選項,將動畫類型從彈簧動畫改成補間動畫等等。詳情請參閱自訂動畫說明文件。
6. 為多個值加上動畫效果
現在我們已熟悉基本的動畫 API,接下來要瞭解如何使用 Transition
API 製作更複雜的動畫。使用 Transition
API 時,我們可以追蹤 Transition
上的所有動畫何時播放完畢,而先前介紹的個別 animate*AsState
API 沒有這項功能。Transition
API 也可以用來定義狀態轉換期間的不同 transitionSpec
。以下說明使用方式:
在本範例中,我們要自訂分頁指標,也就是目前所選取分頁上的矩形。
請在 HomeTabIndicator
可組合函式中找出 TODO 4,瞭解如何實作分頁指標。
val indicatorLeft = tabPositions[tabPage.ordinal].left
val indicatorRight = tabPositions[tabPage.ordinal].right
val color = if (tabPage == TabPage.Home) PaleDogwood else Green
此處 indicatorLeft
指的是指標左側邊緣在分頁列中的水平位置。indicatorRight
則是指標右側邊緣的水平位置。此外,色彩會切換為蜜桃色和綠色。
如要同時為多個值加上動畫效果,可以使用 Transition
。您可以利用 updateTransition
函式建立 Transition
。請將目前所選取分頁的索引傳遞為 targetState
參數。
每個動畫值都可以使用 Transition
的 animate*
擴充函式進行宣告。本範例使用的是 animateDp
和 animateColor
。這些擴充函式會接收 lambda 區塊,而我們可以指定每個狀態的指定值。我們已知道所有指定值,因此能透過以下方式納入值。請注意,我們可以使用 by
宣告,在此處再次將它變成本機委派屬性,因為 animate*
函式會傳回 State
物件。
val transition = updateTransition(tabPage, label = "Tab indicator")
val indicatorLeft by transition.animateDp(label = "Indicator left") { page ->
tabPositions[page.ordinal].left
}
val indicatorRight by transition.animateDp(label = "Indicator right") { page ->
tabPositions[page.ordinal].right
}
val color by transition.animateColor(label = "Border color") { page ->
if (page == TabPage.Home) PaleDogwood else Green
}
立即執行應用程式,您可以看到分頁切換的過程變得更生動了。點選分頁標籤會變更 tabPage
狀態的值,因此所有與 transition
相關聯的動畫值都會開始以動畫形式切換成目標狀態所指定的值。
此外,我們還可以指定 transitionSpec
參數,自訂動畫行為,例如讓靠近目的地該側的移動速度大於另一側,創造出指標的彈跳效果。我們可以在 transitionSpec
lambda 中使用 isTransitioningTo
中置函式,決定狀態變更的方向。
val transition = updateTransition(
tabPage,
label = "Tab indicator"
)
val indicatorLeft by transition.animateDp(
transitionSpec = {
if (TabPage.Home isTransitioningTo TabPage.Work) {
// Indicator moves to the right.
// The left edge moves slower than the right edge.
spring(stiffness = Spring.StiffnessVeryLow)
} else {
// Indicator moves to the left.
// The left edge moves faster than the right edge.
spring(stiffness = Spring.StiffnessMedium)
}
},
label = "Indicator left"
) { page ->
tabPositions[page.ordinal].left
}
val indicatorRight by transition.animateDp(
transitionSpec = {
if (TabPage.Home isTransitioningTo TabPage.Work) {
// Indicator moves to the right
// The right edge moves faster than the left edge.
spring(stiffness = Spring.StiffnessMedium)
} else {
// Indicator moves to the left.
// The right edge moves slower than the left edge.
spring(stiffness = Spring.StiffnessVeryLow)
}
},
label = "Indicator right"
) { page ->
tabPositions[page.ordinal].right
}
val color by transition.animateColor(
label = "Border color"
) { page ->
if (page == TabPage.Home) PaleDogwood else Green
}
請再度執行應用程式,然後嘗試切換分頁。
Android Studio 支援在 Compose 預覽中檢查轉場效果。如要使用動畫預覽,請先按一下預覽畫面 ( 圖示) 可組合函式右上角的「Start Animation Preview」圖示,開啟互動模式。請嘗試點選 PreviewHomeTabBar
可組合函式的圖示。這麼做會開啟新的「Animations」窗格。
按一下「Play」圖示按鈕即可執行動畫。此外,只要拖曳跳轉滑桿,即可查看每一個動畫影格。您可以在 updateTransition
和 animate*
方法內指定 label
參數,為動畫值提供更清楚的說明。
7. 重複播放動畫
請嘗試點選目前溫度旁的重新整理圖示按鈕。應用程式會假裝開始載入最新的天氣資訊。在完成載入作業之前,您會看到載入指標,也就是灰色圓圈和長條。我們接下來要為這個指標的 Alpha 值加上動畫效果,讓使用者更清楚瞭解程序正在執行。
請在 LoadingRow
可組合函式中找出 TODO 5。
val alpha = 1f
我們要讓這個值在 0f 和 1f 之間重複播放動畫。使用 InfiniteTransition
即可達成此效果。這個 API 和上一章節的 Transition
API 非常類似。這兩種 API 都可以為多個值加上動畫效果,但 Transition
會根據狀態變更來執行值的動畫,InfiniteTransition
則會無限期執行值的動畫。
為建立 InfiniteTransition
,請使用 rememberInfiniteTransition
函式。然後,您可以使用 InfiniteTransition
的其中一個 animate*
擴充函式,宣告每個值變更行為的動畫。在本範例中,我們要為 Alpha 值加上動畫效果,所以要使用 animatedFloat
。initialValue
參數應該為 0f
,targetValue
則是 1f
。我們也可以為動畫指定 AnimationSpec
,但這個 API 只接受 InfiniteRepeatableSpec
。請使用 infiniteRepeatable
函式建立該項目。此 AnimationSpec
會納入任何以時間長度為基礎的 AnimationSpec
,並設為可重複。舉例來說,完成的程式碼應如下所示。
val infiniteTransition = rememberInfiniteTransition()
val alpha by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = keyframes {
durationMillis = 1000
0.7f at 500
},
repeatMode = RepeatMode.Reverse
),
label = "alpha"
)
repeatMode
預設為 RepeatMode.Restart
。轉場效果會從 initialValue
到 targetValue
,然後再度從 initialValue
開始。把 repeatMode
設定為 RepeatMode.Reverse
,動畫就會從 initialValue
變為 targetValue
,再從 targetValue
變為 initialValue
。動畫會從 0 到 1,再從 1 到 0。
keyFrames
動畫是另一類型的 animationSpec
,可在不同毫秒時變更處理中的值。其他類型包括 tween
和 spring
。我們最初將 durationMillis
設為 1000 毫秒,之後則可以定義動畫的主要畫面格,例如在動畫 500 毫秒時將 Alpha 值設為 0.7f。這樣做能夠改變動畫的速度,在動畫前 500 毫秒裡,從 0 變成 0.7 的速度會比較快,而到最後從 500 毫秒變成 1000 毫秒裡,從 0.7 變成 1.0 的速度比較慢。
如果想設定多個主要畫面格,可以定義多個 keyFrames
,如下所示:
animation = keyframes {
durationMillis = 1000
0.7f at 500
0.9f at 800
}
請執行應用程式,然後嘗試點選重新整理按鈕。您現在可以看到載入指標的動畫。
8. 手勢動畫
在最後這一節中,我們會學到如何執行以觸控輸入為基礎的動畫。我們會從頭開始建構 swipeToDismiss
修飾符。
請在 swipeToDismiss
修飾符中找出 TODO 6-1。我們在這裡要嘗試製作修飾詞,讓元素可以用觸控方式滑動。當元素快速滑過畫面邊緣時,我們會呼叫 onDismissed
回呼,讓系統移除元素。
想建構 swipeToDismiss
修飾符之前,我們需要釐清幾個重要概念。首先,當使用者用手指觸碰螢幕時,會產生一個有 x 和 y 座標的觸控事件,然後使用者會把手指移到右邊或左邊,根據移動方式移動 x 跟 y。使用者觸碰的項目需要跟著手指一起移動,所以我們會根據觸控事件的位置和速率更新項目的位置。
我們可以運用 Compose 手勢說明文件所述的幾種概念。透過使用 pointerInput
修飾符,我們可以取得低階存取權,進而存取傳入的指標觸控事件,並利用同一指標追蹤使用者的拖曳速率。如果使用者在項目超過關閉邊界之前就放開手指,項目會回到原有位置上。
在這個情境中,需要考量一些獨特事項。首先,播放中的動畫可能會遭到觸控事件中斷。再來,動畫值可能不是唯一的資料來源。也就是說,我們可能需要同步處理動畫值和來自觸控事件的值。
Animatable
是我們目前看到最低階的 API。這個 API 可以為手勢情境提供多種實用功能,例如立即切換成來自手勢的新值,以及在觸發新觸控事件時停止任何播放中的動畫。我們可以建立 Animatable
的執行個體,並用來代表滑動式元素的水平偏移。請務必從 androidx.compose.animation.core.Animatable
匯入 Animatable
,而非從 androidx.compose.animation.Animatable
。
val offsetX = remember { Animatable(0f) } // Add this line
// used to receive user touch events
pointerInput {
// Used to calculate a settling position of a fling animation.
val decay = splineBasedDecay<Float>(this)
// Wrap in a coroutine scope to use suspend functions for touch events and animation.
coroutineScope {
while (true) {
// ...
TODO 6-2 就是剛才接收觸控事件的地方。如果目前動畫正在執行,我們應該中斷動畫。只要在 Animatable
上呼叫 stop
即可達成這個效果。請注意,如果目前沒有執行動畫,系統就會忽略此呼叫。VelocityTracker
會計算使用者從左到右的移動速度。awaitPointerEventScope
屬於暫停函式,可以等待使用者輸入事件,再做出回應。
// Wait for a touch down event. Track the pointerId based on the touch
val pointerId = awaitPointerEventScope { awaitFirstDown().id }
offsetX.stop() // Add this line to cancel any on-going animations
// Prepare for drag events and record velocity of a fling gesture
val velocityTracker = VelocityTracker()
// Wait for drag events.
awaitPointerEventScope {
我們會在 TODO 6-3 持續接收拖曳事件,而且必須將觸控事件的位置同步處理至動畫值。在 Animatable
上使用 snapTo
即可達到這個效果。您必須在其他 launch
區塊內呼叫 snapTo
,因為 awaitPointerEventScope
和 horizontalDrag
是受限制的協同程式範圍。也就是說,這些範圍只能為 awaitPointerEvents
而 suspend
,但 snapTo
並不是指標事件。
horizontalDrag(pointerId) { change ->
// Add these 4 lines
// Get the drag amount change to offset the item with
val horizontalDragOffset = offsetX.value + change.positionChange().x
// Need to call this in a launch block in order to run it separately outside of the awaitPointerEventScope
launch {
// Instantly set the Animable to the dragOffset to ensure its moving
// as the user's finger moves
offsetX.snapTo(horizontalDragOffset)
}
// Record the velocity of the drag.
velocityTracker.addPosition(change.uptimeMillis, change.position)
// Consume the gesture event, not passed to external
if (change.positionChange() != Offset.Zero) change.consume()
}
TODO 6-4 是剛才釋放和快速滑過元素的位置。我們需要計算快速滑過設定的最終位置,才能決定是把元素移回本來的位置,還是滑出並叫用回呼。我們會使用先前建立的 decay
來計算 targetOffsetX
:
// Dragging finished. Calculate the velocity of the fling.
val velocity = velocityTracker.calculateVelocity().x
// Add this line to calculate where it would end up with
// the current velocity and position
val targetOffsetX = decay.calculateTargetValue(offsetX.value, velocity)
在 TODO 6-5,我們將要開始執行動畫。在此之前,我們需要為 Animatable
設定上限值與下限值,以便其在達到限制時立刻停止 (即 -size.width
和 size.width
,因為我們不想讓 offsetX
超過這兩個值)。pointerInput
修飾符可透過 size
屬性存取元素大小,因此我們要使用此修飾符取得邊界。
offsetX.updateBounds(
lowerBound = -size.width.toFloat(),
upperBound = size.width.toFloat()
)
在 TODO 6-6,我們終於可以開始執行動畫了。首先,要比較先前計算的快速滑過最終位置和元素大小。如果最終位置低於大小,表示快速滑過速率不足。我們可以使用 animateTo
,將動畫值設回 0f。如果是其他情況,就能使用 animateDecay
開始執行快速滑過動畫。動畫結束時,就可以呼叫回呼。動畫結束原因很可能是先前設定的上下限。
launch {
if (targetOffsetX.absoluteValue <= size.width) {
// Not enough velocity; Slide back.
offsetX.animateTo(targetValue = 0f, initialVelocity = velocity)
} else {
// Enough velocity to slide away the element to the edge.
offsetX.animateDecay(velocity, decay)
// The element was swiped away.
onDismissed()
}
}
最後,請查看 TODO 6-7。我們已設定所有動畫和手勢,因此請務必為元素套用偏移。這樣一來,畫面上的元素就會移動到手勢或動畫所產生的值:
.offset { IntOffset(offsetX.value.roundToInt(), 0) }
本節結束後,您的程式碼應如下所示:
private fun Modifier.swipeToDismiss(
onDismissed: () -> Unit
): Modifier = composed {
// This Animatable stores the horizontal offset for the element.
val offsetX = remember { Animatable(0f) }
pointerInput(Unit) {
// Used to calculate a settling position of a fling animation.
val decay = splineBasedDecay<Float>(this)
// Wrap in a coroutine scope to use suspend functions for touch events and animation.
coroutineScope {
while (true) {
// Wait for a touch down event.
val pointerId = awaitPointerEventScope { awaitFirstDown().id }
// Interrupt any ongoing animation.
offsetX.stop()
// Prepare for drag events and record velocity of a fling.
val velocityTracker = VelocityTracker()
// Wait for drag events.
awaitPointerEventScope {
horizontalDrag(pointerId) { change ->
// Record the position after offset
val horizontalDragOffset = offsetX.value + change.positionChange().x
launch {
// Overwrite the Animatable value while the element is dragged.
offsetX.snapTo(horizontalDragOffset)
}
// Record the velocity of the drag.
velocityTracker.addPosition(change.uptimeMillis, change.position)
// Consume the gesture event, not passed to external
change.consumePositionChange()
}
}
// Dragging finished. Calculate the velocity of the fling.
val velocity = velocityTracker.calculateVelocity().x
// Calculate where the element eventually settles after the fling animation.
val targetOffsetX = decay.calculateTargetValue(offsetX.value, velocity)
// The animation should end as soon as it reaches these bounds.
offsetX.updateBounds(
lowerBound = -size.width.toFloat(),
upperBound = size.width.toFloat()
)
launch {
if (targetOffsetX.absoluteValue <= size.width) {
// Not enough velocity; Slide back to the default position.
offsetX.animateTo(targetValue = 0f, initialVelocity = velocity)
} else {
// Enough velocity to slide away the element to the edge.
offsetX.animateDecay(velocity, decay)
// The element was swiped away.
onDismissed()
}
}
}
}
}
// Apply the horizontal offset to the element.
.offset { IntOffset(offsetX.value.roundToInt(), 0) }
}
請執行應用程式,並嘗試滑動其中一個任務項目。您可以看到元素滑回預設位置,或在滑出後遭到移除,具體取決於快速滑過手勢的速率。您也可以在動畫播放過程中擷取元素。
9. 恭喜!
恭喜!您已瞭解基本的 Compose 動畫 API。
在本程式碼研究室中,我們學到如何使用以下 API:
高階動畫 API:
animatedContentSize
AnimatedVisibility
低階動畫 API:
animate*AsState
:可為單一值加上動畫效果updateTransition
:可為多個值加上動畫效果infiniteTransition
:可無限期為值加上動畫效果Animatable
:可利用觸控手勢建構自訂動畫
後續步驟
請參閱 Compose 課程中的其他程式碼研究室。
如果想瞭解更多資訊,請參閱 Compose 動畫相關文章和以下參考文件: