01
背景介紹
隨著新聞客戶端鴻蒙單框架系統適配工作的推進,從原來的基礎功能到現在已經適配全功能的85%以上。與此同時,我們也在持續深入挖掘鴻蒙系統的特性,以提升整體應用的質量與用戶體驗。在這一過程中,動畫作為增強交互與視覺體驗的重要手段,成為不可或缺的一環。本文將通過一個實際案例,詳細介紹鴻蒙 ArkUI 動畫的用法,如何利用ArkUI提供的API及其特性實現相對復雜的動畫,并對比 Android 平臺的實現方式。首先,我們來看一下新聞客戶端在 Android 上直播間點贊動效的效果,見圖1。圖2為利用ArkUI動畫API在HarmonyOS系統上實現的效果:
圖1
圖2
動畫解析:當發生點擊事件時,點贊按鈕會有一個放大動畫,隨之點贊按鈕底部會出現一個飄動的愛心,向上按照一定的曲線進行位移,同時在位移的過程中伴隨有透明度,縮放的變化,同時點贊數加一,這一系列變化是一組動畫;當長按事件觸發時,以固定的頻率連續觸發這一組動畫的播放。
02
ArkUI動畫API簡介
ArkUI提供了全面的動畫實現方式,其中包括屬性動畫、轉場動畫、粒子動畫、組件動畫、幀動畫等。目前在整個適配過程中,我們用的比較多的就是屬性動畫和轉場動畫,而屬性動畫也是最適合為組件定制動效的API。ArkUI提供了三種屬性動畫接口:animateTo、animation和keyframeAniamteTo。
animateTo是一個通用函數,通過對比閉包內狀態變量和閉包前狀態變量的差異通過改變狀態變量實現動畫效果,支持嵌套、能多次調用。animation是組件的一個屬性,只能改變該屬性之前設置的組件屬性,keyframeAniamteTo是關鍵幀動畫,通過設置關鍵幀實現動畫效果。本次動畫使用animateTo實現動畫,該API介紹如下:
animateTo(value: AnimateParam, event:?()?=>?void):?void
AnimateParam可以指定本次動畫的時長、曲線效果(Curve)、重復次數、結束回調等參數,而event閉包則是本次動畫需要改變哪些狀態變量,更多參數可查閱鴻蒙的開發文檔。
03
Android實現
先介紹下Android上實現的方法,向上飄動的愛心所做出的透明度、縮放動畫相對容易實現,位移的曲線動畫是這個動畫比較難實現的點。如何讓飄動的愛心每一次路徑都不重復,并且能夠實現一個平滑的曲線效果呢?這里就要用到強大的貝塞爾曲線了,通過輸入不同的起點和終點以及控制點,就可以繪制不同效果的曲線,從而實現連續且弧度優美的路徑曲線。當完成了路徑的動畫之后,加上透明度、縮放動畫就能實現上述效果了。下面結合代碼講解實現的核心思路:
首先自定義組合View,按照效果圖所示結構進行布局,布局底部放一個用于顯示點贊圖標的ImageView,在圖標頂部放置一個TextView用于顯示點贊數。然后監聽圖標的點擊事件,當點擊事件觸發時,我們利用Android 系統中View的public void addView(View child, int index, LayoutParams params)方法添加一個用于做動畫的ImageView,該ImageView就是接下來用于進行動畫的核心對象。添加完執行動畫的View就可以構造動畫集合執行動畫了。具體添加動畫ImageView的方法如下:
private?fun?addHeartImage()?{mVibrator.vibrate(10)val?moveImage = ImageView(context)if?(mFlyDrawable ==?null) {moveImage.setImageResource(R.drawable.ico_live_new_heart)}?else?{moveImage.setImageDrawable(mFlyDrawable)//服務器下發}addView(moveImage,?0, LayoutParams(mLikedImg.width, mLikedImg.height).apply {addRule(CENTER_HORIZONTAL, TRUE)addRule(ALIGN_PARENT_BOTTOM, TRUE)bottomMargin = DensityUtils.dip2px(context,?24f)})val?animatorSet = AnimatorSet()val?moveAnimator = getBezierAnimator(moveImage)val?scaleXAnimator = getScaleAnimator(moveImage,?"scaleX")val?scaleYAnimator = getScaleAnimator(moveImage,?"scaleY")animatorSet.playTogether(moveAnimator, scaleXAnimator, scaleYAnimator)animatorSet.duration = mAnimationDurationanimatorSet.start()
}
第二步是完善第一步中的getBezierAnimator()方法,該方法會返回一個ValueAnimator對象,這個動畫對象實現的就是開頭介紹的貝塞爾曲線。利用Android動畫框架的屬性動畫、以及自定義估值器,可以實現Android動畫系統規定以外的類型插值。這里自定義一個估值器,因為路徑動畫是通過控制ImageView的x和y屬性實現位移,因此估值器的泛型定義為PointF類型,三次貝塞爾曲線的公式如下:

根據上述公式,我們可以完成估值器的計算過程如下:
/*** 計算貝塞爾曲線路徑,實現自然平滑的動畫效果*/
class?BezierEvaluator(privateval?controlPoint1: PointF,?privateval?controlPoint2: PointF) : TypeEvaluator<PointF> {overridefun?evaluate(fraction:?Float, startValue:?PointF, endValue:?PointF): PointF {val?pathPoint = PointF()// 貝塞爾三次方公式pathPoint.x =startValue.x * (1?- fraction) * (1?- fraction) * (1?- fraction) +3?* controlPoint1.x * fraction * (1?- fraction) * (1?- fraction) +3?* controlPoint2.x * fraction * fraction * (1?- fraction) +endValue.x * fraction * fraction * fractionpathPoint.y =startValue.y * (1?- fraction) * (1?- fraction) * (1?- fraction) +3?* controlPoint1.y * fraction * (1?- fraction) * (1?- fraction) +3?* controlPoint2.y * fraction * fraction * (1?- fraction) +endValue.y * fraction * fraction * fractionreturn?pathPoint}
}
三次貝塞爾曲線一共有四個點,起始點、終點以及兩個控制點,發生位移的ImageView在向上移動的過程中,起始點是固定的,終點是隨機的,要實現下圖擺動曲線的效果,兩個控制點必須控制在圖中黃色區域內,當控制點也隨機產生之后,動畫的曲線就不再重合,從而實現向上移動并隨機擺動的效果。

根據上述思路以及三次貝塞爾曲線計算View的x、y屬性的插值器,完善獲取貝塞爾曲線位移效果的動畫代碼如下:
/*** 注:* DensityUtils.dip2px(context, 35f):點贊按鈕圖片的大小* DensityUtils.dip2px(context, 24f):點贊按鈕距父控件底部的 Margin*/
privatefun?getBezierAnimator(targetView:?View): ValueAnimator {//計算隨機控制點val?pointF1 = PointF(Random.nextInt(width - DensityUtils.dip2px(context,?35f)).toFloat(),Random.nextInt(height /?2) + height /?2f?- DensityUtils.dip2px(context,?35f?+?24f).toFloat())val?pointF2 = PointF(width /?2?+ Random.nextInt(width).toFloat() /?2?- DensityUtils.dip2px(context,?35f),Random.nextInt(height /?2).toFloat())Log.d(TAG,?"pointF1 = (${pointF1.x},${pointF1.y})")Log.d(TAG,?"pointF2 = (${pointF2.x},${pointF2.y})")//計算起始點和終點val?startPoint = PointF((width /?2?- DensityUtils.dip2px(context,?35f) /?2).toFloat(),height - DensityUtils.dip2px(context,?35f?+?24f).toFloat())val?endPoint = PointF(Random.nextInt(width - DensityUtils.dip2px(context,?35f)).toFloat(),?0f)Log.d(TAG,?"startPoint = (${startPoint.x},${startPoint.y})")Log.d(TAG,?"endPoint = (${endPoint.x},${endPoint.y})")val?bezierEvaluator = BezierEvaluator(pointF1, pointF2)val?valueAnimator = ObjectAnimator.ofObject(bezierEvaluator, startPoint, endPoint)valueAnimator.duration = mAnimationDurationvalueAnimator.interpolator = DecelerateInterpolator()valueAnimator.addListener(object?: AnimatorListenerAdapter() {overridefun?onAnimationStart(animation:?Animator)?{addLikedNum()}overridefun?onAnimationEnd(animation:?Animator)?{removeView(targetView)}})valueAnimator.addUpdateListener { animator: ValueAnimator ->// 自定義估值器BezierEvaluator的貝塞爾公式算出的 pointval?bezierPoint = animator.animatedValue?as?PointFtargetView.x = bezierPoint.xtargetView.y = bezierPoint.ytargetView.alpha = (1?- animator.animatedFraction +?0.1).toFloat()}return?valueAnimator
}
04
HarmonyOS實現
HarmonyOS系統上,ArkUI動畫框架提供的API與Android系統的動畫框架差別比較大,在HarmonyOS系統中,動畫的實現方式有屬性動畫、幀動畫、粒子動畫等,從Android上實現的經驗來看使用屬性動畫實現該案例比較合適。ArkUI是聲明式UI,并沒有類似Android中可以在運行時直接添加組件的方法,所以需要找到代替方案替代Android上的addView() 和 removeView()方法。
在ArkUI中,控制渲染流程可以用到if/else、ForEach 以及LazyForEach,該動畫需要考慮到用戶連續點擊,多個向上位移動畫的組件同時渲染,因此if/else并不合適,ForEach需要搭配List容器組件使用,因此只剩下LazyForEach。Ark UI中,UI的變化是通過狀態變量控制的,由此需要設計一個數組,初始化為空數組,當觸發一次動畫操作時,向數組中添加一個數據,此時系統會根據數組的數量自動渲染對應的組件,當組件準備完成時執行屬性動畫,控制動畫的狀態變量放在數組里的對象中,當動畫執行結束時,從數組中移除該數據,相應的組件也隨之移除。
首先還是構建組件的布局,按照效果圖依然封裝自定義組件,采用Image組件進行點贊按鈕的渲染,同時使用Text組件進行點贊數的展示,通過DevcoStudio中的ArkUI Inspector可以得到布局效果如下圖所示:

組件布局結構代碼實現如下,同時利用LazyForEach的特性為后面動態添加組件做渲染流程控制,build函數內代碼如下:
build() {Stack({ alignContent: Alignment.Bottom }) {LazyForEach(this.animaList,?(item: AnimationState) =>?{//當animaList中添加數據時,可以在這里渲染對應的UI組件,即一個向上飄動的Image組件,//向上飄動的過程由動畫實現},?(item: AnimationState) =>JSON.stringify(item))Column({ space:?2?}) {Text(this.liveRoomViewModel.liveData.likeCount.toString()).fontSize(9).fontColor($r('app.color.text5')).fontWeight(FontWeight.Bold).width('100%').textAlign(TextAlign.Center)Image($r('app.media.ico_live_new_heart')).width(35).height(35).borderRadius(35).scale({ x:?this.likedButtonScale, y:?this.likedButtonScale }).backgroundColor('#40ffffff').draggable(false).onClick(()=>{//當點擊時間觸發時,向this.animaList中添加一個數據,對應會渲染一個動畫組件})}}.width(65).height(200)
}
上述代碼中,Image組件上設置點擊事件,當點擊事件觸發時,向數組中添加一條數據,而該數組所綁定的LazyForEach組件會執行對應的渲染邏輯,當一次點擊發生時,需要對應渲染一個Image組件,同時進行對應的動畫,動畫結束時將該數據從數組中移除,執行完動畫的組件隨即從組件樹中移除。多次點擊產生的多個動畫對象不能相互影響,因此將控制動畫的狀態變量保存在數組對應的對象中,因此設計如下類保存動畫所需數據:
@ObservedV2
exportclass?AnimationState {
@Trace?P0:?number[] = [0,?0];?// 起點(通常為 View 的初始位置)
// 控制點1
@Trace?P1:?number[] = [this.getRandomInt(0,?65),?this.getRandomInt(0,?-80)];
// 控制點2?
@Trace?P2:?number[] = [this.getRandomInt(0,?65),?this.getRandomInt(-80,?-165)];?
@Trace?P3:?number[] = [this.getRandomInt(-15,?15),?-140];?// 終點
@Trace?progress:?number?=?0;?// 動畫進度 0~1
@Trace?scale:?number?=?0.3;?//縮放動畫
@Trace?alpha:?number?=?0.8;?//透明度動畫id:?string?=?''//數據時間戳用于唯一標識constructor(id:?string) {this.id = id}getRandomInt(min:?number, max:?number):?number?{returnMath.floor(Math.random() * (max - min +?1)) + min;}
}
AnimationState 類中一些常數是組件的尺寸大小,通過計算起點、控制點、終點需要限制在一定的范圍內,類似于Android中實現的那樣。變量progress是動畫的一個核心變化因子,類似于Android估值器實現類evaluate方法中的fraction變量,progress隨著動畫的時間變化由0到1變化,變化的效果由動畫設置的曲線決定。scale、alpha分別控制動畫的縮放、透明度變化。
如何啟動動畫呢?首先在Image組件的點擊事件方法中向數組中添加一個對象,同時設置好動畫參數的初始值,代碼如下:
this.animaList.pushData(new?AnimationState(Date.now().toString()))
當this.animaList數組中有數據添加時,LazyForEach即開始渲染對應的組件,因此在對應處渲染一個Image組件,設置好業務需要的圖片,當組件準備完成即將送顯時,利用ArkUI提供的動畫API animateTo()方法開啟動畫,animateTo()方法可參考官方文檔鏈接(https://developer.huawei.com/consumer/cn/doc/harmonyos-references/ts-explicit-animation),該方法主要設置動畫的時長、曲線、重復方式以及哪些屬性要做動畫,比如在該方法的閉包中將progress的值賦值為1,時長設置為1000毫秒,那么progress的值會在設置的時間內根據曲線的效果進行不斷的改變,時間結束時會變成賦值的1。具體代碼如下,在閉包中同時開啟了縮放動畫和透明度動畫,也就是progress的變化與縮放動畫、透明度變化同時開始,具體代碼如下:
LazyForEach(this.animaList,?(item: AnimationState) =>?{Image(this.flyImageLoadFailed ? $r('app.media.ico_zan_v6') :this.liveRoomViewModel.liveInfoModel.likeAnimation || $r('app.media.ico_zan_v6')).width(35).height(35).opacity(item.alpha).onError(()?=>?{this.flyImageLoadFailed =?true}).scale({x: item.scale,y: item.scale}).translate({x:?this.calculateCubicBezier(item.P0, item.P1, item.P2, item.P3, item.progress)[0],y:?this.calculateCubicBezier(item.P0, item.P1, item.P2, item.P3, item.progress)[1]}).onAppear(()?=>?{this.liveRoomViewModel.localCacheLikedNum ++animateTo({duration:?1000,?// 動畫時長curve: Curve.Ease,?//動畫以低速開始,然后加快,在結束前變慢iterations:?1,?// 播放次數(-1 表示無限循環)playMode: PlayMode.Normal,onFinish:?()?=>?{this.animaList.deleteData(this.animaList.findIndex((findItem) =>?findItem.id === item.id))}},?()?=>?{//縮放動畫animateTo({duration:?500,?// 動畫時長curve: Curve.EaseIn,?//動畫以低速開始iterations:?1,?// 播放次數(-1 表示無限循環)playMode: PlayMode.Normal,onFinish:?()?=>?{animateTo({duration:?500,?// 動畫時長curve: Curve.EaseOut,?//動畫以低速結束iterations:?1,?// 播放次數(-1 表示無限循環)playMode: PlayMode.Normal,},?()?=>?{item.scale =?0.3})}},?()?=>?{item.scale =?1.2})//透明度動畫animateTo({duration:?800,?// 動畫時長curve: Curve.EaseOut,?//動畫以低速結束iterations:?1,?// 播放次數(-1 表示無限循環)playMode: PlayMode.Normal,delay:?200,},?()?=>?{item.alpha =?0})vibrationV2(50,'alarm')item.progress =?1;?// 驅動 progress 從 0 到 1})})
},?(item: AnimationState) =>JSON.stringify(item))
隨著動畫的開始,組件還需要進行位移,設置的progress會從0變到1,那么就可以利用progress通過貝塞爾曲線計算動畫組件的路徑,通過公式可以得到如下方法:
// 三次貝塞爾計算公式,用于計算路徑
private?calculateCubicBezier(P0:?number[], P1:?number[], P2:?number[], P3:?number[], t:?number):?number[] {const?x = (1?- t)**3?* P0[0] +3?* t * (1?- t)**2?* P1[0] +3?* t**2?* (1?- t) * P2[0] +t**3?* P3[0];const?y = (1?- t)**3?* P0[1] +3?* t * (1?- t)**2?* P1[1] +3?* t**2?* (1?- t) * P2[1] +t**3?* P3[1];return?[x, y];
}
該方法返回一個數組,同時該方法計算過程利用到數組中對象的狀態變量,因此給組件設置translate時,利用該方法計算對應的x、y值,UI會隨著progress的變化從而引起位置變化,從而達到觸發位移動畫的目的。同時在animateTo()方法的onFinish回調中移除這條數據,動畫結束時組件自動從組件樹移除,實現效果如本篇開頭ArkUI實現效果所示,滿足動效設計要求。
05
總結
本文主要介紹實現復雜動畫的思路以及同樣的動畫HarmonyOS系統與Android系統的區別,其中一些業務代碼未給出,這里只給出核心代碼。ArkUI的動畫框架對比Android系統的動畫框架區別還是很大的,ArkUI是聲明式UI,動畫也是由狀態變量驅動的,并且ArkUI引擎對動畫渲染做了很多技術革新,掌握了ArkUI動畫也能在開發中利用動畫做出更好的過度效果,使應用更加流暢,自然。