Android Jetpack結構運用Compose實現微博長按點贊彩虹效果
原版
效果高仿效果
1. Compose 動畫 API 概覽
Compose 動畫 API 在使用場景的維度上大體分為兩類:高級別 API 和低級別 API。就像編程語言分為高級語言和低級語言一樣,這列高級低級指 API 的易用性:
高級別 API 主打開箱即用,適用於一些 UI 元素的展現/退出/切換等常見場景,例如常見的 AnimatedVisibility
以及 AnimatedContent
等,它們被設計成 Composable 組件,可以在聲明式佈局中與其他組件融為一體。
//Text通過動畫淡入 var editable by remember { mutableStateOf(true) } AnimatedVisibility(visible = editable) { Text(text = "Edit") }
低級別 API 使用成本更高但是更加靈活,可以更精準地實現 UI 元素個別屬性的動畫,多個低級別動畫還可以組合實現更復雜的動畫效果。最常見的低級別 animateFloatAsState
系列瞭,它們也是 Composable 函數,可以參與 Composition 的組合過程。
//動畫改變 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 之外使用,因此更具靈活性。本例子的動畫主要也是依靠它完成的。
// 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 一樣聲明式的調用,基本寫法如下:
Canvas {
…
drawRainbow(rainbowState) //繪制彩虹
…
drawEmoji(emojiState) //繪制表情
…
drawFlow(flowState) //繪制煙花
…
}
State 的變化會驅動 Canvas 會自動重繪,無需手動調用 invalidate
之類的方法。那麼接下來針對彩虹、表情、煙花等各種動畫的實現,我們的工作主要有兩個:
- 狀態管理:定義相關 State,並在在動畫中驅動其變化,如前所述這主要依靠 Animatable 實現。
- 內容繪制:通過 Canvas API 基於當前狀態繪制圖案
3. 彩虹動畫
3.1 狀態管理
對於彩虹動畫,唯一的動畫狀態就是圓的半徑,其值從 0F 過渡到 screensize,圓形面積鋪滿至整個屏幕。我們使用 Animatable
包裝這個狀態值,調用 animateTo
方法可以驅動狀態變化:
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 在內的繪制所需的的狀態
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
被添加時,為它啟動動畫。關鍵代碼如下:
//MutableStateList 保存 AnimatedRainbow val animatedRainbows = mutableStateListOf<AnimatedRainbow>() //長按屏幕時,向列表加入 AnimtaedRainbow, 意味著增加一個新的彩虹 animatedRainbows.add( AnimatedRainbow( screenHeightPx.coerceAtLeast(screenWidthPx), RainbowColors.random() ) )
我們使用 LaunchedEffect
+ snapshotFlow
觀察 animatedRainbows 的變化,代碼如下:
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 的繪制方法:
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
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 類型可供使用,分別是:
//默認的 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 中監聽新元素的插入,並執行動畫。隻是表情動畫每次會批量增加多個
//MutableStateList 保存 animatedEmojis val animatedEmojis = mutableStateListOf<AnimatedEmoji>() //一次增加 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
繪制表情素材即可
//當前 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 在內的相關狀態。
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 列表
由於煙花動畫設計是表情動畫的延續,所以它緊跟表情動畫執行,共享 CoroutienScope ,不需要借助 LaunchedEffect ,所以使用普通列表定義 animatedFlower 即可:
//animatedFlowers 使用普通列表創建 val animatedFlowers = mutableListOf<AnimatedFlower>() 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個花瓣,各自位置計算如下:
//計算 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 即可:
fun DrawScope.draw() { points.forEachIndexed { index, point -> drawCircle(color = color[index % 2], center = point, radius = radius.value) } }
6. 合體效果
最後我們定義一個 AnimatedLike
的 Composable ,整合上面代碼
@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 的使用,瞭解 Compose 動畫開發的基本思想,這之後再學習其他 API 就是水到渠成的事情瞭。
到此這篇關於Android Jetpack結構運用Compose實現微博長按點贊彩虹效果的文章就介紹到這瞭,更多相關Android Jetpack Compose內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!
推薦閱讀:
- Android動效Compose貝塞爾曲線動畫規格詳解
- Jetpack Compose實現動畫效果的方法詳解
- 利用Jetpack Compose繪制可愛的天氣動畫
- JS實現紙牌發牌動畫
- Android Flutter繪制扇形圖詳解