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动画也能在开发中利用动画做出更好的过度效果,使应用更加流畅,自然。