<em>Mac</em>Book项目 2009年学校开始实施<em>Mac</em>Book项目,所有师生配备一本<em>Mac</em>Book,并同步更新了校园无线网络。学校每周进行电脑技术更新,每月发送技术支持资料,极大改变了教学及学习方式。因此2011
2021-06-01 09:32:01
效果高仿效果
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 ,它們都遵循狀態驅動的動畫方式,即目標物件通過觀察狀態變化實現自身的動畫。
長按點讚的動畫乍看之下非常複雜,但是稍加分解後,不難發現它也是由一些常見的動畫形式組合而成,因此我們可以對其拆解後逐個實現:
傳統檢視動畫可以作用在 View 上,通過動畫改變其屬性;也可以在 onDraw
中通過不斷重繪實現逐幀的動畫效果。 Compose 也同樣,我們可以在 Composable 中觀察動畫狀態,通過重組實現動畫效果(本質是改變 UI 元件的佈局屬性),也可以在 Canvas 中觀察動畫狀態,只在重繪中實現動畫(跳過組合)。這個例子的動畫效果也需要通過 Canvas 的不斷重繪來實現。
Compose 的 Canvas 也可以像 Composable 一樣宣告式的呼叫,基本寫法如下:
Canvas {
...
drawRainbow(rainbowState) //繪製彩虹
...
drawEmoji(emojiState) //繪製表情
...
drawFlow(flowState) //繪製煙花
...
}
State 的變化會驅動 Canvas 會自動重繪,無需手動呼叫 invalidate
之類的方法。那麼接下來針對彩虹、表情、煙花等各種動畫的實現,我們的工作主要有兩個:
對於彩虹動畫,唯一的動畫狀態就是圓的半徑,其值從 0F 過渡到 screensize,圓形面積鋪滿至整個螢幕。我們使用 Animatable
包裝這個狀態值,呼叫 animateTo
方法可以驅動狀態變化:
val raduis = Animatable(0f) //初始值 0f radius.animateTo( targetValue = screenSize, //目標值 animationSpec = tween( durationMillis = duration, //動畫時長 easing = FastOutSlowInEasing //動畫衰減效果 ) )
animationSpec
用來指定動畫規格,不同的動畫規格決定了了狀態值變化的節奏。Compose 中常用的建立動畫規格的方法有以下幾種,它們建立不同型別的動畫規格,但都是 AnimationSpec
的子類:
要實現上面這樣多個彩虹疊加的效果,我們還需有多個 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 ) ) }
我們還需要一個集合來管理執行中的 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
有兩個好處:
我們在 Canvas 中遍歷 animatedRainbows 所有的 AnimtaedRainbow 完成彩虹的繪製。彩虹的圖形主要依靠 DrawScope
的 drawCircle
完成,比較簡單。一點需要特別注意,彩虹動畫結束時也要以一個圓形圖案逐漸退出直至漏出底部內容,要實現這個效果,用到一個小技巧,我們的圓形繪製使用空心圓 (Stroke ) 而非 實心圓( Fill )
基於以上原則,我們為 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 倍。
表情動畫又由三個子動畫組成:旋轉動畫、透明度動畫以及拋物線軌跡動畫。像 AnimtaedRainbow 一樣,我們定義 AnimatedEmoji
管理每個表情動畫的狀態,AnimatedEmoji 中通過多個 Animatable 分別管理前面提到的幾個子動畫
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)) ) } ) }
上面程式碼中,旋轉動畫的 AnimationSpec 使用 infiniteRepeatable
建立了一個無限迴圈的動畫,RepeatMode.Restart
表示它的從 0F
過渡到 360F
之後,再次重複這個過程。
除了旋轉動畫之外,其他動畫都會在 duration
之後結束,它們分別在 async
中啟動並行執行,awaitAll
等待它們全部結束。而由於旋轉動畫不會結束,因此不能放到 awaitAll 中,否則 startAnim 的呼叫方將永遠無法恢復執行。
透明度動畫中的 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 曲線互相對稱,符合拋物線規律
像彩虹動畫一樣,我們同樣使用一個 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) //從列表移除 } } } }
單個 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
即可。
煙花動畫緊跟在表情動畫結束時發生,動畫不涉及位置變化,主要是幾個花瓣不斷縮小的過程。花瓣用圓形繪製,動畫狀態值就是圓形半徑,使用 Animatable 包裝。
煙花的繪製還要用到顏色等資訊,我們定義 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() }) }
這裡又出現了一種 AnimationSpec,即幀動畫 keyframes
,相對於 tween ,keyframes
可以更精確指定時間區間內的動畫進度。比如程式碼中 radius / 3 at 0
表示 0 秒時狀態值達到 intial / 3
,相當於以初始值的 1/3
尺寸出現,這是一般的 tween 難以實現的。另外我們希望花瓣可以持久可見,所以使用 keyframe
確保時間進行到 95% 時,radius 的尺寸仍然清晰可見。
由於煙花動畫設計是表情動畫的延續,所以它緊跟表情動畫執行,共用 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) //移除動畫 }
煙花的內容繪製,需要計算每個花瓣的位置,一共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) } }
最後我們定義一個 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內容請搜尋it145.com以前的文章或繼續瀏覽下面的相關文章希望大家以後多多支援it145.com!
相關文章
<em>Mac</em>Book项目 2009年学校开始实施<em>Mac</em>Book项目,所有师生配备一本<em>Mac</em>Book,并同步更新了校园无线网络。学校每周进行电脑技术更新,每月发送技术支持资料,极大改变了教学及学习方式。因此2011
2021-06-01 09:32:01
综合看Anker超能充系列的性价比很高,并且与不仅和iPhone12/苹果<em>Mac</em>Book很配,而且适合多设备充电需求的日常使用或差旅场景,不管是安卓还是Switch同样也能用得上它,希望这次分享能给准备购入充电器的小伙伴们有所
2021-06-01 09:31:42
除了L4WUDU与吴亦凡已经多次共事,成为了明面上的厂牌成员,吴亦凡还曾带领20XXCLUB全队参加2020年的一场音乐节,这也是20XXCLUB首次全员合照,王嗣尧Turbo、陈彦希Regi、<em>Mac</em> Ova Seas、林渝植等人全部出场。然而让
2021-06-01 09:31:34
目前应用IPFS的机构:1 谷歌<em>浏览器</em>支持IPFS分布式协议 2 万维网 (历史档案博物馆)数据库 3 火狐<em>浏览器</em>支持 IPFS分布式协议 4 EOS 等数字货币数据存储 5 美国国会图书馆,历史资料永久保存在 IPFS 6 加
2021-06-01 09:31:24
开拓者的车机是兼容苹果和<em>安卓</em>,虽然我不怎么用,但确实兼顾了我家人的很多需求:副驾的门板还配有解锁开关,有的时候老婆开车,下车的时候偶尔会忘记解锁,我在副驾驶可以自己开门:第二排设计很好,不仅配置了一个很大的
2021-06-01 09:30:48
不仅是<em>安卓</em>手机,苹果手机的降价力度也是前所未有了,iPhone12也“跳水价”了,发布价是6799元,如今已经跌至5308元,降价幅度超过1400元,最新定价确认了。iPhone12是苹果首款5G手机,同时也是全球首款5nm芯片的智能机,它
2021-06-01 09:30:45