Compose 動畫邊學邊做 - 夏日彩虹
theme: nico
“我正在參加「初夏創意投稿大賽」詳情請看:初夏創意投稿大賽”
Compose 在動畫方面下足了功夫,提供了種類豐富的 API。但也正由於 API 種類繁多,如果想一氣兒學下來,可能會消化不良導致似懂非懂。結合例子學習是一個不錯的方法,本文就帶大家邊學邊做,通過高仿微博長按點讚的彩虹動畫,學習和實踐 Compose 動畫的相關技巧。
| 原版:微博長按點贊 | 本文:掘金夏日主題|
|:--:|:--:|
||
|
程式碼地址: https://github.com/vitaviva/AnimatedLike
1. Compose 動畫 API 概覽
Compose 動畫 API 在使用場景的維度上大體分為兩類:高級別 API 和低級別 API。就像程式設計語
言分為高階語言和低階語言一樣,這列高階低階指 API 的易用性:
- 高級別 API 主開啟箱即用,適用於一些 UI 元素的展現/退出/切換等常見場景,例如常見的 AnimatedVisibility
以及 AnimatedContent
等,它們被設計成 Composable 元件,可以在宣告式佈局中與其他元件融為一體。
kotlin
//Text通過動畫淡入
var editable by remember { mutableStateOf(true) }
AnimatedVisibility(visible = editable) {
Text(text = "Edit")
}
- 低級別 API 使用成本更高但是更加靈活,可以更精準地實現 UI 元素個別屬性的動畫,多個低級別動畫還可以組合實現更復雜的動畫效果。最常見的低級別 animateFloatAsState
系列了,它們也是 Composable 函式,可以參與 Composition 的組合過程。
kotlin
//動畫改變 Box 透明度
val alpha: Float by animateFloatAsState(if (enabled) 1f else 0.5f)
Box(
Modifier.fillMaxSize()
.graphicsLayer(alpha = alpha)
.background(Color.Red)
)
處於上層的 API 由底層 API 支撐實現,TargetBasedAnimation
是開發者可直接使用的最低階 API。Animatable 也是一個相對低階的 API,它是一個動畫值的包裝器,在協程中完成狀態值的變化,向上提供對 animate*AsState
的支撐。它與其他 API 不同,是一個普通類而非一個 Composable 函式,所以可以在 Composable 之外使用,因此更具靈活性。本例子的動畫主要也是依靠它完成的。
kotlin
// Animtable 包裝了一個顏色狀態值
val color = remember { Animatable(Color.Gray) }
LaunchedEffect(ok) {
// animateTo 是個掛起函式,驅動狀態之變化
color.animateTo(if (ok) Color.Green else Color.Gray)
}
Box(Modifier.fillMaxSize().background(color.value))
無論高級別 API 還是低級別 API ,它們都遵循狀態驅動的動畫方式,即目標物件通過觀察狀態變化實現自身的動畫。
2. 長按點贊動畫分解
長按點讚的動畫乍看之下非常複雜,但是稍加分解後,不難發現它也是由一些常見的動畫形式組合而成,因此我們可以對其拆解後逐個實現: - 彩虹動畫:全屏範圍內不斷擴散的彩虹效果。可以通過半徑不斷擴大的圓形圖案並依次疊加來實現 - 表情動畫:從按壓位置不斷丟擲的表情。可以進一步拆解為三個動畫:透明度動畫,旋轉動畫以及拋物線軌跡動畫。 - 煙花動畫:丟擲的表情在消失時會有一個煙花炸裂的效果。其實就是圍繞中心的八個圓點逐漸消失的過程,圓點的顏色提取自表情本身。
傳統檢視動畫可以作用在 View 上,通過動畫改變其屬性;也可以在 onDraw
中通過不斷重繪實現逐幀的動畫效果。 Compose 也同樣,我們可以在 Composable 中觀察動畫狀態,通過重組實現動畫效果(本質是改變 UI 元件的佈局屬性),也可以在 Canvas 中觀察動畫狀態,只在重繪中實現動畫(跳過組合)。這個例子的動畫效果也需要通過 Canvas 的不斷重繪來實現。
Compose 的 Canvas 也可以像 Composable 一樣宣告式的呼叫,基本寫法如下:
kotlin
Canvas {
...
drawRainbow(rainbowState) //繪製彩虹
...
drawEmoji(emojiState) //繪製表情
...
drawFlow(flowState) //繪製煙花
...
}
State 的變化會驅動 Canvas 會自動重繪,無需手動呼叫 invalidate
之類的方法。那麼接下來針對彩虹、表情、煙花等各種動畫的實現,我們的工作主要有兩個:
- 狀態管理:定義相關 State,並在在動畫中驅動其變化,如前所述這主要依靠 Animatable 實現。
- 內容繪製:通過 Canvas API 基於當前狀態繪製圖案
3. 彩虹動畫
3.1 狀態管理
對於彩虹動畫,唯一的動畫狀態就是圓的半徑,其值從 0F 過渡到 screensize,圓形面積鋪滿至整個螢幕。我們使用 Animatable
包裝這個狀態值,呼叫 animateTo
方法可以驅動狀態變化:
```kotlin val raduis = Animatable(0f) //初始值 0f
radius.animateTo(
targetValue = screenSize, //目標值
animationSpec = tween(
durationMillis = duration, //動畫時長
easing = FastOutSlowInEasing //動畫衰減效果
)
)
``
animationSpec用來指定動畫規格,不同的動畫規格決定了了狀態值變化的節奏。Compose 中常用的建立動畫規格的方法有以下幾種,它們建立不同型別的動畫規格,但都是
AnimationSpec` 的子類:
- tween:建立補間動畫規格,補間動畫是一個固定時長動畫,比如上面例子中這樣設定時長 duration,此外,tween 還能通過 easiing 指定動畫衰減效果,後文詳細介紹。
- spring: 彈跳動畫:spring 可以建立基於物理特性的彈簧動畫,它通過設定阻尼比實現符合物理規律的動畫衰減,因此不需要也不能指定動畫時長
- Keyframes:建立關鍵幀動畫規格,關鍵幀動畫可以逐幀設定當前動畫的軌跡,後文會詳細介紹。
AnimatedRainbow
要實現上面這樣多個彩虹疊加的效果,我們還需有多個 Animtable
同時執行,在 Canvas 中依次對它們進行繪製。繪製彩虹除了依靠 Animtable 的狀態值,還有 Color 等其他資訊,因此我們定義一個 AnimatedRainbow
類儲存包括 Animtable 在內的繪製所需的的狀態
```kotlin class AnimatedRainbow( //螢幕尺寸(寬邊長邊大的一方) private val screenSize: Float, //RainbowColors是彩虹的候選顏色 private val color: Brush = RainbowColors.random(), //動畫時長 private val duration: Int = 3000 ) { private val radius = Animatable(0f)
suspend fun startAnim() = radius.animateTo(
targetValue = screenSize * 1.6f, // 關於 1.6f 後文說明
animationSpec = tween(
durationMillis = duration,
easing = FastOutSlowInEasing
)
)
} ```
animatedRainbows 列表
我們還需要一個集合來管理執行中的 AnimatedRainbow
。這裡我們使用 Compose 的 MutableStateList
作為集合容器,MutableStateList
中的元素髮生增減時,可以被觀察到,而當我們觀察到新的 AnimatedRainbow
被新增時,為它啟動動畫。關鍵程式碼如下:
```kotlin
//MutableStateList 儲存 AnimatedRainbow
val animatedRainbows = mutableStateListOf
//長按螢幕時,向列表加入 AnimtaedRainbow, 意味著增加一個新的彩虹
animatedRainbows.add(
AnimatedRainbow(
screenHeightPx.coerceAtLeast(screenWidthPx),
RainbowColors.random()
)
)
``
我們使用
LaunchedEffect+
snapshotFlow` 觀察 animatedRainbows 的變化,程式碼如下:
kotlin
LaunchedEffect(Unit) {
//監聽到新新增的 AnimatedRainbow
snapshotFlow { animatedRainbows.lastOrNull() }
.filterNotNull()
.collect {
launch {
//啟動 AnimatedRainbow 動畫
val result = it.startAnim()
//動畫結束後,從列表移除,避免洩露
if (result.endReason == AnimationEndReason.Finished) {
animatedRainbows.remove(it)
}
}
}
}
LaunchedEffect
和 snapshotFlow
都是 Compose 處理副作用的 API,由於不是本文重點就不做深入介紹了,這裡只需要知道 LaunchedEffect
是一個提供了執行副作用的協程環境,而 snapshotFlow
可以將 animatedRainbows
中的變化轉化為 Flow 發射給下游。當通過 Flow 收集到新加入的 AnimtaedRainbow
時,呼叫 startAnim
啟動動畫,這裡充分發揮了掛起函式的優勢,同步等待動畫執行完畢,從 animatedRainbows
中移除 AnimtaedRainbow
即可。
值得一提的是,MutableStateList
的主要目的是在組合中觀察列表的狀態變化,本例子的動畫不發生在組合中(只發生在重繪中),完全可以使用普通的集合型別替代,這裡使用 MutableStateList
有兩個好處:
- 可以響應式地觀察列表變化
- 在 LaunchEffect 中響應變化並啟動動畫,協程可以隨當前 Composable 的生命週期結束而終止,避免洩露。
3.2 內容繪製
我們在 Canvas 中遍歷 animatedRainbows 所有的 AnimtaedRainbow 完成彩虹的繪製。彩虹的圖形主要依靠 DrawScope
的 drawCircle
完成,比較簡單。一點需要特別注意,彩虹動畫結束時也要以一個圓形圖案逐漸退出直至漏出底部內容,要實現這個效果,用到一個小技巧,我們的圓形繪製使用空心圓 (Stroke ) 而非 實心圓( Fill )
- 出現彩虹:圓環逐漸鋪滿螢幕卻不能漏出空心。這要求 StrokeWidth 寬度覆蓋 ScreenSize,且始終保持 CircleRadius 的兩倍
- 結束彩虹:圓環空心部分逐漸覆蓋螢幕。此時要求 CircleRadius 減去 StrokeWidth / 2 之後依然能覆蓋 ScreenSize 基於以上原則,我們為 AnimatedRainbow 新增單個 AnnimatedRainbow 的繪製方法:
kotlin
fun DrawScope.draw() {
drawCircle(
brush = color, //圓環顏色
center = center, //圓心:點贊位置
radius = radius.value,// Animtable 中變化的 radius 值,
style = Stroke((radius.value * 2).coerceAtMost(_screenSize)),
)
}
如上,StrokeWidth 覆蓋 ScreenSize 之後無需繼續增長,而 CircleRadius 的最終尺寸除去 ScreenSize 之外還要將 StrokeWidth 考慮進去,因此前面程式碼中將 Animtable 的 targetValue 設定為 ScreenSize 的 1.6 倍。
4. 表情動畫
4.1 狀態管理
表情動畫又由三個子動畫組成:旋轉動畫、透明度動畫以及拋物線軌跡動畫。像 AnimtaedRainbow 一樣,我們定義 AnimatedEmoji
管理每個表情動畫的狀態,AnimatedEmoji 中通過多個 Animatable 分別管理前面提到的幾個子動畫
AnimatedEmoji
```kotlin class AnimatedEmoji( private val start: Offset, //表情拋點位置,即長按的螢幕位置 private val screenWidth: Float, //螢幕寬度 private val screenHeight: Float, //螢幕高度 private val duration: Int = 1500 //動畫時長 ) {
//丟擲距離(x方向移動終點),在左右一個螢幕之間取隨機數
private val throwDistance by lazy {
((start.x - screenWidth).toInt()..(start.x + screenWidth).toInt()).random()
}
//丟擲高度(y方向移動終點),在螢幕頂端到拋點之間取隨機數
private val throwHeight by lazy {
(0..start.y.toInt()).random()
}
private val x = Animatable(start.x)//x方向移動動畫值
private val y = Animatable(start.y)//y方向移動動畫值
private val rotate = Animatable(0f)//旋轉動畫值
private val alpha = Animatable(1f)//透明度動畫值
suspend fun CoroutineScope.startAnim() {
async {
//執行旋轉動畫
rotate.animateTo(
360f, infiniteRepeatable(
animation = tween(_duration / 2, easing = LinearEasing),
repeatMode = RepeatMode.Restart
)
)
}
awaitAll(
async {
//執行x方向移動動畫
x.animateTo(
throwDistance.toFloat(),
animationSpec = tween(durationMillis = duration, easing = LinearEasing)
)
},
async {
//執行y方向移動動畫(上升)
y.animateTo(
throwHeight.toFloat(),
animationSpec = tween(
duration / 2,
easing = LinearOutSlowInEasing
)
)
//執行y方向移動動畫(下降)
y.animateTo(
screenHeight,
animationSpec = tween(
duration / 2,
easing = FastOutLinearInEasing
)
)
},
async {
//執行透明度動畫,最終狀態是半透明
alpha.animateTo(
0.5f,
tween(duration, easing = CubicBezierEasing(1f, 0f, 1f, 0.8f))
)
}
)
}
```
infiniteRepeatable
上面程式碼中,旋轉動畫的 AnimationSpec 使用 infiniteRepeatable
建立了一個無限迴圈的動畫,RepeatMode.Restart
表示它的從 0F
過渡到 360F
之後,再次重複這個過程。
除了旋轉動畫之外,其他動畫都會在 duration
之後結束,它們分別在 async
中啟動並行執行,awaitAll
等待它們全部結束。而由於旋轉動畫不會結束,因此不能放到 awaitAll 中,否則 startAnim 的呼叫方將永遠無法恢復執行。
CubicBezierEasing
透明度動畫中的 easing
指定了一個 CubicBezierEasing
。easing 是動畫衰減效果,即動畫狀態以何種速率逼近目標值。Compose 提供了幾個預設的 Easing 型別可供使用,分別是:
kotlin
//預設的 Easing 型別,以加速度起步,減速度收尾
val FastOutSlowInEasing: Easing = CubicBezierEasing(0.4f, 0.0f, 0.2f, 1.0f)
//勻速起步,減速度收尾
val LinearOutSlowInEasing: Easing = CubicBezierEasing(0.0f, 0.0f, 0.2f, 1.0f)
//加速度起步,勻速收尾
val FastOutLinearInEasing: Easing = CubicBezierEasing(0.4f, 0.0f, 1.0f, 1.0f)
//勻速接近目標值
val LinearEasing: Easing = Easing { fraction -> fraction }
上圖橫軸是時間,縱軸是逼近目標值的進度,可以看到除了 LinearEasing
之外,其它的的曲線變化都滿足 CubicBezierEasing
三階貝塞爾曲線,如果預設 Easing 不符合你的使用要求,可以使用 CubicBezierEasing
,通過引數,自定義合適的曲線效果。比如例子中曲線如下:
這個曲線前半程狀態值進度非常緩慢,臨近時間結束才快速逼近最終狀態。因為我們希望表情動畫全程清晰可見,透明度的衰減儘量後置,預設 easiing 無法提供這種效果,因此我們自定義 CubicBezierEasing
拋物線動畫
再來看一下拋物線動畫的實現。通常我們可以藉助拋物線公式,基於一些動畫狀態變數計算拋物線座標來實現動畫,但這個例子中我們藉助 Easing 更加巧妙的實現了拋物線動畫。 我們將拋物線動畫拆解為 x 軸和 y 軸兩個方向兩個並行執行的位移動畫,x 軸位移通過 LinearEasing 勻速完成,y 軸又拆分成兩個過程 - 上升到最高點,使用 LinearOutSlowInEasing 上升時速度加速衰減 - 下落到螢幕底端,使用 FastOutLinearInEasing 下落時速度加速增加
上升和下降的 Easing 曲線互相對稱,符合拋物線規律
animatedEmojis 列表
像彩虹動畫一樣,我們同樣使用一個 MutableStateList 集合管理 AnimatedEmoji 物件,並在 LaunchedEffect 中監聽新元素的插入,並執行動畫。只是表情動畫每次會批量增加多個
```kotlin
//MutableStateList 儲存 animatedEmojis
val animatedEmojis = mutableStateListOf
//一次增加 EmojiCnt 個表情 animatedEmojis.addAll(buildList { repeat(EmojiCnt) { add(AnimatedEmoji(offset, screenWidthPx, screenHeightPx, res)) } })
//監聽 animatedEmojis 變化 LaunchedEffect(Unit) { //監聽到新加入的 EmojiCnt 個表情 snapshotFlow { animatedEmojis.takeLast(EmojiCnt) } .flatMapMerge { it.asFlow() } .collect { launch { with(it) { startAnim()//啟動表情動畫,等待除了旋轉動畫外的所有動畫結束 animatedEmojis.remove(it) //從列表移除 } } } } ```
4.2 內容繪製
單個 AnimatedEmoji 繪製程式碼很簡單,藉助 DrawScope
的 drawImage
繪製表情素材即可
```kotlin
//當前 x,y 位移的位置
val offset get() = Offset(x.value, y.value)
//圖片topLeft相對於offset的距離 val d by lazy { Offset(img.width / 2f, img.height / 2f) }
//繪製表情
fun DrawScope.draw() {
rotate(rotate.value, pivot = offset) {
drawImage(
image = img, //表情素材
topLeft = offset - dCenter,//當前位置
alpha = alpha.value, //透明度
)
}
}
``
注意旋轉動畫實際上是藉助
DrawScope的
rotate方法實現的,在 block 內部呼叫
drawImage指定當前的
alpha和
topLeft` 即可。
5. 煙花動畫
5.1 狀態管理
煙花動畫緊跟在表情動畫結束時發生,動畫不涉及位置變化,主要是幾個花瓣不斷縮小的過程。花瓣用圓形繪製,動畫狀態值就是圓形半徑,使用 Animatable 包裝。
AnimatedFlower
煙花的繪製還要用到顏色等資訊,我們定義 AnimatedFlower 儲存包括 Animtable 在內的相關狀態。 ```kotlin class AnimatedFlower( private val intial: Float, //花瓣半徑初始值,一般是表情的尺寸 private val duration: Int = 2500 ) { //花瓣半徑 private val radius = Animatable(intial)
suspend fun startAnim() {
radius.animateTo(0f, keyframes {
durationMillis = duration
intial / 3 at 0 with FastOutLinearInEasing
intial / 5 at (duration * 0.95f).toInt()
})
}
```
keyframes
這裡又出現了一種 AnimationSpec,即幀動畫 keyframes
,相對於 tween ,keyframes
可以更精確指定時間區間內的動畫進度。比如程式碼中 radius / 3 at 0
表示 0 秒時狀態值達到 intial / 3
,相當於以初始值的 1/3
尺寸出現,這是一般的 tween 難以實現的。另外我們希望花瓣可以持久可見,所以使用 keyframe
確保時間進行到 95% 時,radius 的尺寸仍然清晰可見。
animatedFlower
animatedFlower 列表
由於煙花動畫設計是表情動畫的延續,所以它緊跟表情動畫執行,共享 CoroutienScope ,不需要藉助 LaunchedEffect ,所以使用普通列表定義 animatedFlower 即可:
```kotlin
//animatedFlowers 使用普通列表建立
val animatedFlowers = mutableListOf
launch { with(it) {//表情動畫執行 startAnim() animatedEmojis.remove(it) } //建立 AnimatedFlower 動畫 val anim = AnimatedFlower( center = it.offset, //使用 Palette 從表情圖片提取煙花顏色 color = Palette.from(it.img.asAndroidBitmap()).generate().let { arrayOf( Color(it.getDominantColor(Color.Transparent.toArgb())), Color(it.getVibrantColor(Color.Transparent.toArgb())) ) }, initial = it.img.run { width.coerceAtLeast(height) / 2 }.toFloat() ) animatedFlowers.add(anim) //新增進列表 anim.startAnim() //執行煙花動畫 animatedFlowers.remove(anim) //移除動畫 } ```
5.2 內容繪製
煙花的內容繪製,需要計算每個花瓣的位置,一共8個花瓣,各自位置計算如下:
```kotlin //計算 sin45 的值 val sin by lazy { sin(Math.PI / 4).toFloat() }
val points get() = run { val d1 = initial - radius.value val d2 = (initial - radius.value) * sin arrayOf( center.copy(y = center.y - d1), //0點方向 center.copy(center.x + d2, center.y - d2), center.copy(x = center.x + d1),//3點方向 center.copy(center.x + d2, center.y + d2), center.copy(y = center.y + d1),//6點方向 center.copy(center.x - d2, center.y + d2), center.copy(x = center.x - d1),//9點方向 center.copy(center.x - d2, center.y - d2), ) } ```
center
是煙花的中心位置,隨著花瓣的變小,同時越來越遠離中心位置,因此 d1
和 d2
就是偏離 center 的距離,與 radius 大小成反比。
最後在 Canvas 中繪製這些 points 即可:
kotlin
fun DrawScope.draw() {
points.forEachIndexed { index, point ->
drawCircle(color = color[index % 2], center = point, radius = radius.value)
}
}
6. 合體效果
最後我們定義一個 AnimatedLike
的 Composable ,整合上面程式碼
```kotlin @Composable fun AnimatedLike(modifier: Modifier = Modifier, state: LikeAnimState = rememberLikeAnimState()) {
LaunchedEffect(Unit) {
//監聽新增表情
snapshotFlow { state.animatedEmojis.takeLast(EmojiCnt) }
.flatMapMerge { it.asFlow() }
.collect {
launch {
with(it) {
startAnim()
state.animatedEmojis.remove(it)
}
//新增煙花動畫
val anim = AnimatedFlower(
center = it.offset,
color = Palette.from(it.img.asAndroidBitmap()).generate().let {
arrayOf(
Color(it.getDominantColor(Color.Transparent.toArgb())),
Color(it.getVibrantColor(Color.Transparent.toArgb()))
)
},
initial = it.img.run { width.coerceAtLeast(height) / 2 }.toFloat()
)
state.animatedFlowers.add(anim)
anim.startAnim()
state.animatedFlowers.remove(anim)
}
}
}
LaunchedEffect(Unit) {
//監聽新增彩虹
snapshotFlow { state.animatedRainbows.lastOrNull() }
.filterNotNull()
.collect {
launch {
val result = it.startAnim()
if (result.endReason == AnimationEndReason.Finished) {
state.animatedRainbows.remove(it)
}
}
}
}
//繪製動畫
Canvas(modifier.fillMaxSize()) {
//繪製彩虹
state.animatedRainbows.forEach { animatable ->
with(animatable) { draw() }
}
//繪製表情
state.animatedEmojis.forEach { animatable ->
with(animatable) { draw() }
}
//繪製煙花
state.animatedFlowers.forEach { animatable ->
with(animatable) { draw() }
}
}
}
``
我們使用
AnimatedLike佈局就可以為頁面新增動畫效果了,由於 Canvas 本身是基於
modifier.drawBehind` 實現的,我們也可以將 AnimatedLike 改為 Modifier 修飾符使用,這裡就不贅述了。
最後,複習一下本文例子中的內容:
Animatable
:包裝動畫狀態值,並且在協程中執行動畫,同步返回動畫結果AnimationSpec
:動畫規格,可以配置動畫時長、Easing 等,例子中用到了 tween,keyframes,infiniteRepeatable 等多個動畫規格Easing
:動畫狀態值隨時間變化的趨勢,通常使用預設型別即可, 也可以基於 CubicBezierEasing 定製。
一個例子不可能覆蓋到 Compose 所有的動畫 API,但是我們只要掌握了上述幾個關鍵知識點,再學習其他 API 就是水到渠成的事情了。
- Android Studio Electric Eel 起支援手機投屏
- Compose 為什麼可以跨平臺?
- 一看就懂!圖解 Kotlin SharedFlow 快取系統
- 深入淺出 Compose Compiler(2) 編譯器前端檢查
- 深入淺出 Compose Compiler(1) Kotlin Compiler & KCP
- Jetpack MVVM七宗罪之三:在 onViewCreated 中載入資料
- 為什麼說 Compose 的宣告式程式碼最簡潔 ?Compose/React/Flutter/SwiftUI 語法對比
- Compose 型別穩定性註解:@Stable & @Immutable
- Fragment 這些 API 已廢棄,你還在使用嗎?
- 告別KAPT!使用 KSP 為 Kotlin 編譯提速
- 探索 Jetpack Compose 核心:深入 SlotTable 系統
- 盤點 Material Design 3 帶來的新變化
- Compose 動畫邊學邊做 - 夏日彩虹
- Google I/O :Android Jetpack 最新變化(二) Performance
- Google I/O :Android Jetpack 最新變化(一) Architecture
- Google I/O :Android Jetpack 最新變化(四)Compose
- Google I/O :Android Jetpack 最新變化(三)UI
- 一文看懂 Jetpack Compose 快照系統
- 聊聊 Kotlin 代理的“缺陷”與應對
- AAB 扶正!APK 再見!