这是我自己在整理鸿蒙中级课程笔记时一直想写的一篇——ArkUI 进阶的第一课,属性动画和转场动画。学鸿蒙开发有一段时间的朋友大概都有这种感觉:基础组件、布局写顺手之后,界面总差点意思,点击按钮没有反馈,页面切换硬邦邦,列表增删像在刷 Excel。问题就出在动画上。ArkUI 的动画不只是“好看”,它直接关系到交互反馈的清晰度和界面的层级表达。这篇笔记我把属性动画和转场动画从 API 用法到实战案例、从参数调优到坑点排查完整梳理了一遍,适合已经能独立写页面、但还没系统玩过动画的开发者。
1. ArkUI 动画体系先搞明白,再动手写
1.1 动画的本质是状态变化的“解释器”
很多人在 ArkUI 里写动画,第一反应是去查 API、抄参数,结果 animation 写了一大堆,动画就是不生效。我刚开始也是这样,后来才意识到问题出在理解上:ArkUI 是声明式框架,UI 是由状态驱动的。你改变了一个@State变量的值,组件会重新渲染,而动画不过是这个“重新渲染”过程的插值器。
说白了,动画不是在“动组件”,而是在“解释状态变化”。当opacity从 1 变成 0,系统不可能瞬间完成,它会在你设定的时长内,把每一帧的透明度都计算出来,逐帧渲染,这个计算过程就是动画引擎的工作。所以你看 ArkUI 动画相关 API,全部围绕“属性值的变化”展开,没有哪个动画 API 是让你直接操作“移动这个组件到那个位置”,而是让你声明“这个组件的位置属性变了,请用动画过渡”。
理解了这一点,后面所有用法都顺理成章:动画要生效,必须有属性值的变化,而且这个变化必须发生在“可被动画引擎捕获”的上下文里。ArkUI 提供了两种上下文,分别是animation(隐式动画)和animateTo(显式动画),它们和状态管理、组件渲染的关系完全不同。
1.2 属性动画与转场动画的分工
ArkUI 的动画体系大致可以分成三类,我习惯这样划分:
- 属性动画(Attribute Animation):同一个组件在不同状态之间切换时,对可动画属性做插值过渡。典型的如点击按钮后透明度变化、卡片旋转、位移等。
- 转场动画(Transition Animation):组件从无到有(出现)、从有到无(消失)时的整体过渡效果。典型的如 if 条件渲染切换、路由页面跳转。
- 绘制与手势动画:偏底层和交互,比如
Canvas绘制动画、手势animationCoordinator等,通常不作为第一条学习路径。
属性动画解决的是“怎么变”的问题,转场动画解决的是“怎么来、怎么走”的问题。两者的实现机制也不一样,属性动画是让组件自身的某个属性在一段时间内连续变化,转场动画则是给组件的“生命周期瞬间”附加一个过渡效果,比如透明度从 0 到 1、从下往上位移、缩放进场等。
1.3 学习动画前的两个前置能力
写动画之前,有两块基础能力建议先打牢,否则后面排查问题会很痛苦。
第一,状态管理的基本功。你要清楚地知道哪些变量的变化会触发 UI 更新,@State和@Prop、@Link的区别是什么,因为动画的起点和终点都来自状态值。有几个常见问题,比如外部传入的值变化了但 UI 没刷新,或者子组件值改了父组件不知道,根上都是状态管理没吃透。
第二,布局和渲染的基础理解。动画动的是渲染层的结果,但影响动画的有布局参数、绘制参数和变换参数。有些属性动了会影响布局重新计算,有些不会。这部分知识在优化动画性能时尤其重要,比如width变化会引起父容器重排,而scale变化只作用于绘制阶段,性能开销差很多。
2. 属性动画:animation 与 animateTo 是使用频率最高的两个 API
2.1 隐式动画与显式动画的代码差异
ArkUI 属性动画的两个核心 API,animation是隐式动画,animateTo是显式动画。它们的触发方式不一样,实际场景里的用法差异也很大。
animation的写法是挂在组件上的,指定这个组件的属性变化要用什么样的动画参数来过渡。一旦这个组件的某个可动画属性因为状态变化而改变,就会自动触发。
@Entry @Component struct ImplicitAnimationDemo { @State scaleValue: number = 1; @State opacityValue: number = 1; build() { Column({ space: 20 }) { Button('点击缩放/透明变化') .scale({ x: this.scaleValue, y: this.scaleValue }) .opacity(this.opacityValue) .animation({ duration: 300, curve: Curve.EaseOut, delay: 0, iterations: 1, playMode: PlayMode.Normal }) .onClick(() => { this.scaleValue = this.scaleValue > 1 ? 1 : 1.2; this.opacityValue = this.opacityValue > 1 ? 1 : 0.6; }) } .width('100%') .height('100%') .justifyContent(FlexAlign.Center) } }注意几个细节:animation是放在属性之后的修饰符,它只管它之前的那些属性。如果组件上有多个属性要动,但animation写在了某个属性中间,那animation之前的属性会有动画,之后的就不会。这个顺序问题我见过不少新人踩坑。
animateTo则是把“状态变化”包在一个闭包里执行,闭包里所有影响到的可动画属性都会以动画形式过渡。它不绑定在组件上,更像是一个命令:
@Entry @Component struct ExplicitAnimationDemo { @State translateX: number = 0; @State rotateAngle: number = 0; build() { Column({ space: 20 }) { Button('移动并旋转') .translate({ x: this.translateX }) .rotate({ angle: this.rotateAngle }) .onClick(() => { animateTo({ duration: 500, curve: Curve.EaseInOut, onFinish: () => { console.info('动画结束'); } }, () => { this.translateX = this.translateX === 0 ? 100 : 0; this.rotateAngle = this.rotateAngle === 0 ? 45 : 0; }); }) } .width('100%') .height('100%') .justifyContent(FlexAlign.Center) } }animateTo的优势是“批量”,闭包里改多个状态变量,所有相关组件会一起动,而且组件的动画参数不用逐个配置。但如果不同组件需要不同的动画时长或者曲线,就得分多个animateTo来包,或者配合animation覆盖组件级参数。
什么时候优先用animation?组件自身属性变化比较固定、而且动画参数不因场景改变的,比如一个常驻按钮的按下反馈。什么时候用animateTo?状态变化涉及多个组件、或者动画是一次性的业务交互,比如点击后整个布局重新排列。我的经验是:项目里 70% 的动画用animateTo,因为它更直观,前后状态一目了然。
2.2 哪些属性可以动,哪些动不了
不是所有属性都能参与动画插值。我整理了一份实际开发中高频用到的“可动画属性表”,按类别分好,大家可以直接收藏。
| 类别 | 可动画属性 | 说明 |
|---|---|---|
| 基础布局 | width、height、padding、margin、position、offset | 这些属性变化会触发布局重算,性能开销相对大 |
| 视觉外观 | opacity、backgroundColor、borderColor、borderWidth、borderRadius、backgroundBlurStyle | 颜色插值在 ArkUI 中做了线性过渡,浅色到深色效果自然 |
| 变换 | rotate、scale、translate | 只影响绘制,不触发布局,性能最优,推荐优先使用 |
| 其他 | shadow、filter、clip、zIndex | filter 和 clip 的开销较大,谨慎用于大面积组件 |
注意,layoutWeight、flexBasis这类由布局系统计算出来的属性,是没法直接做动画的。还有visibility也只能做是否可见,不能“渐变地隐藏”。我一开始天真地想把visibility配animation实现渐隐,结果发现它直接切换,没有任何过渡。后来改成用opacity+hitTestBehavior或者在动画结束后再改visibility才达到效果。
关于自定义属性动画,ArkUI 提供了Animatable接口,可以扩展自定义数据类型的插值。比如你有一个自定义的颜色类或者路径类,实现了Animatable接口,然后某个自定义组件的属性引用了这个类型,就能参与动画。这个在实际业务中用到的不多,但遇到复杂图形场景时会非常有用,例如地图上绘制路径渐变时,属性值从一组经纬度坐标点过渡到另一组。
2.3 动画参数与回调:时长、曲线、循环与结束监听
duration是时长,单位毫秒。经验值:按钮反馈 100-200ms,列表项增删 200-300ms,页面级转场 300-400ms,超过 500ms 的动画除非是强调展示,否则会让人觉得拖沓。
curve是曲线,ArkUI 内置的Curve枚举有Linear、Ease、EaseIn、EaseOut、EaseInOut等,支持Curve.spring()创建弹簧曲线。弹簧曲线在很多场景下比缓动曲线更有“物理感”,比如列表卡片回弹、按钮点击后的回弹效果。它的参数有曲线质量和初速度,我常用的配置是Curve.spring(0.4, 0.9),具体数值需要根据 UI 手感微调,没法给一个通吃所有场景的参数。
delay是延迟启动,单位毫秒。常用于实现“级联动画”,比如多个元素依次进场,各自的delay依次递增 50-100ms。但要注意,delay是相对于动画触发的时刻,不是相对于上一个动画结束,所以级联动画的节奏控制要自己算好。
iterations默认 1,设置-1表示无限循环。循环动画要非常克制,一旦使用,要确保退出逻辑能干净地停掉,否则容易造成性能损耗。
playMode在animation里比较有用,PlayMode.Normal是正向播放,PlayMode.Reverse是反向播放,PlayMode.Alternate是正向然后反向交替。做呼吸灯效果时,Alternate比手动维护两个状态值方便得多。
onFinish回调在动画结束后触发。这里要特别注意:如果动画被新动画打断,onFinish可能不会触发,需要结合finishCallbackType参数来判断是正常结束还是被打断。我在做游戏化任务卡片时踩过这个坑,动画被打断后没收到回调,状态一直卡在中间值。
3. 转场动画:把组件的出现和消失做出仪式感
3.1 一个 if 条件渲染就能玩转的进入和退出动画
转场动画最基础的用法是配合if条件渲染。当条件从 false 变为 true 时,组件创建并进场;从 true 变为 false 时,组件销毁并退场。transition就是用来定义这个进场和退场动画的。
@Entry @Component struct TransitionDemo { @State show: boolean = false; build() { Column({ space: 20 }) { Button(this.show ? '隐藏卡片' : '显示卡片') .onClick(() => { this.show = !this.show; }) if (this.show) { Column({ space: 10 }) { Text('这是一张转场卡片') .fontSize(16) Text('进入时从下方上移并淡入,退出时向下移并淡出') .fontSize(14) .fontColor('#666') } .padding(20) .backgroundColor('#F0F0F0') .borderRadius(12) .width('80%') .transition( TransitionEffect.asymmetric( TransitionEffect.translate({ y: 200 }).animation({ duration: 300, curve: Curve.EaseOut }), TransitionEffect.translate({ y: 200 }).animation({ duration: 200, curve: Curve.EaseIn }) ) .combine(TransitionEffect.opacity(0).animation({ duration: 300 })) ) } } .width('100%') .height('100%') .justifyContent(FlexAlign.Center) } }TransitionEffect.asymmetric可以分别定义进场和退场效果,第一个参数是进场动画,第二个是退场动画。TransitionEffect.opacity(0)表示透明度从 0 开始过渡,TransitionEffect.translate({ y: 200 })表示从下方 200vp 的位置移动进场。transition里的animation参数负责控制转场动画自身的时长和曲线。
有个很重要的细节:transition要写在目标组件上,而不是包裹它的容器上。如果写到了父容器,子组件进场时父容器的属性也会被带动变化。另外,if切换虽然简单,但不要在同级写多个if分支让它们同时切换,那样会出现两个组件交替进出的视觉重叠,最好用if-else结构。
3.2 页面级路由转场:Navigation 与 NavPathStack 的配合
页面跳转动画比组件转场稍微复杂一点,涉及路由栈的压栈和弹栈。鸿蒙推荐的页面路由方案是Navigation+NavPathStack,路由转场动画需要写在pageTransition方法里。
@Entry @Component struct NavigationTransitionDemo { pathStack: NavPathStack = new NavPathStack(); build() { Navigation(this.pathStack) { Column({ space: 20 }) { Button('跳转到详情页') .onClick(() => { this.pathStack.pushPath({ name: 'DetailPage' }); }) } .width('100%') .height('100%') .justifyContent(FlexAlign.Center) } .pageTransition() { PageTransitionEffect.create(120) .translate({ x: 200 }) .opacity(0) .animation({ duration: 300, curve: Curve.EaseOut }) } } }PageTransitionEffect.create后面的参数是转场时长(单位毫秒?这里准确说应该是动画的持续时间参数,配合.animation使用其实系统会自动处理)。实际项目里更常见的是用自定义转场效果,在pageTransition里可以分别定义Push和Pop的效果:
.pageTransition() { PageTransitionEffect.create(300) .translate({ x: 300 }) .animate({ duration: 350, curve: Curve.EaseOut }) .enter() .exit() }关于pageTransition我建议直接按导航栈的语义来理解:Push是压栈进入,Pop是弹栈返回。如果你只定义了进入效果,返回效果会使用系统默认的,两套风格不一致会显得很突兀。所以自定义转场时,最好把 Push 和 Pop 都写了。
3.3 共享元素转场:让两个组件“穿越”起来
共享元素转场是我个人觉得 ArkUI 做得相当漂亮的一个特性。场景很典型:商品列表里有一张图,点击进入详情页,这张图从列表的位置“飞”到详情页的头部,中间过程像穿越一样连贯。它背后的原理是:在路由跳转时,系统识别sharedTransition中相同的id,自动为这两个组件创建一条“视频连线”式的过渡路径。
用法分三步:
第一步,在源页面(列表页)的图片组件上绑定sharedTransition('sharedImage')。第二步,在目标页面(详情页)的图片组件上绑定相同的 id。第三步,保证两个页面都处于同一个Navigation容器下,并且源页面组件在跳转时仍然存在于组件树中。
// 列表页 Image(this.imageUrl) .width(100) .height(100) .sharedTransition('sharedCoverImage', { duration: 400, curve: Curve.EaseInOut }) // 详情页 Image(this.imageUrl) .width('100%') .height(300) .sharedTransition('sharedCoverImage', { duration: 400, curve: Curve.EaseInOut })共享元素转场有一些限制要提前知道:首先,两个页面的容器类型要保持一致,否则缩放计算会出问题。其次,sharedTransition不能用于列表项循环渲染的场景,比如ForEach生成的多个 item 都写了同一个 id,系统会不知道跟谁匹配。我试过在一个商品列表里给每个 item 的图片都写sharedTransition('cover'),结果跳转动画完全失效。正确的做法有两种:一是通过路由参数把目标页面唯一确定,二是给每个 item 的 id 拼接唯一标识,比如'cover_' + item.id,然后在详情页接收路由参数后拼出同样的 id。第二种做法更通用,也是官方推荐的方向。
3.4 自定义转场与“组合拳”
TransitionEffect支持用.combine把多个效果组合起来。实际项目里,纯位移或者纯透明比较少,更多是把位移、缩放、透明度一起用。
.transition( TransitionEffect.asymmetric( TransitionEffect.translate({ y: 50 }) .combine(TransitionEffect.scale({ x: 0.9, y: 0.9 })) .combine(TransitionEffect.opacity(0)) .animation({ duration: 350, curve: Curve.EaseOut }), TransitionEffect.translate({ y: 100 }) .combine(TransitionEffect.opacity(0)) .animation({ duration: 250, curve: Curve.EaseIn }) ) )组合转场对组件自身的布局有一定要求。比如scale直接作用在组件上,会导致它的占位空间和视觉大小不一致,如果父容器是Stack居中的还好说,如果是在Row或Column里,缩放会影响兄弟节点的排列?其实不会,scale不参与布局,它只影响绘制。这一点和width/height动画有本质区别,所以组合里如果用到了scale,可以放心地配合其他属性使用。
另外,transition和animation是可以同时作用的。组件既写了transition,自身又写了animation,那么转场动画负责出现/消失,属性动画负责进场后或离场前的属性变化。这种组合场景通常出现在弹窗和浮层里,比如一个卡片弹出来之后,内部的某个长条进度再做个宽度动画,这时候两个系统各司其职,不会冲突。
4. 三个练手案例,把动画链路一次性跑通
4.1 列表项新增和删除的平滑转场
列表增删是移动端最高频的交互之一。没有动画的列表,新增项是“啪”一下冒出来,删除项是“唰”一下消失,视觉上非常生硬。用了转场动画之后,增删过程有了明显的过渡层次,用户能感知到“这里多了一个东西”或“这里少了一个东西”,操作反馈清晰多了。
实现思路是在ForEach的 item 上直接配置transition,这样每个 item 在创建和销毁时都会走转场逻辑。
@Entry @Component struct ListTransitionDemo { @State items: number[] = [1, 2, 3, 4, 5]; build() { Column({ space: 20 }) { Button(items.length < 8 ? '新增一项' : '删除一项') .onClick(() => { if (this.items.length < 8) { this.items = [...this.items, this.items.length + 1]; } else { this.items = this.items.slice(0, this.items.length - 1); } }) List({ space: 10 }) { ForEach(this.items, (item: number) => { ListItem() { Text(`项目 ${item}`) .width('100%') .padding(16) .backgroundColor('#E8F0FF') .borderRadius(8) } .transition( TransitionEffect.asymmetric( TransitionEffect.translate({ y: 50 }) .combine(TransitionEffect.opacity(0)) .animation({ duration: 300, curve: Curve.EaseOut }), TransitionEffect.translate({ y: -30 }) .combine(TransitionEffect.opacity(0)) .animation({ duration: 250, curve: Curve.EaseIn }) ) ) }, (item: number) => item.toString()) } .width('100%') .layoutWeight(1) } .padding(20) .width('100%') .height('100%') } }这个场景我实际跑下来有几个心得:ForEach的第三个参数keyGenerator一定要给,而且要返回稳定的唯一值。不要用数组下标做 key,因为增删操作会导致下标错位,动画绑定会乱套。另外,当items是数组时,增删要用不可变方式重新赋值,比如展开后追加、slice后赋值,而不是直接用this.items.push(),因为 ArkUI 的状态管理监听的是赋值行为,原数组的push操作不触发 UI 更新,动画自然也不会发生。
4.2 卡片展开和折叠的自然过渡
卡片展开折叠是另一个必练案例。点击卡片头部,下方内容区域展开或收起,要求两部分配合:头部箭头旋转 90 度,内容区高度从 0 到实际高度(或反向),如果内容区里有文字,还要考虑淡入淡出。
@Entry @Component struct ExpandCardDemo { @State expanded: boolean = false; build() { Column({ space: 20 }) { Column() { Row() { Text('订单详情') .fontSize(18) .fontWeight(FontWeight.Bold) Blank() Text('展开') .rotate({ angle: this.expanded ? 45 : 0 }) .animation({ duration: 200, curve: Curve.EaseOut }) } .height(50) .padding({ left: 16, right: 16 }) .onClick(() => { this.expanded = !this.expanded; }) if (this.expanded) { Column() { Text('这里是折叠时看不到的内容') Text('展开后通过透明度渐显,提升层次感') } .padding({ left: 16, right: 16, bottom: 16 }) .opacity(this.expanded ? 1 : 0) .transition( TransitionEffect.asymmetric( TransitionEffect.opacity(0).animation({ duration: 300, delay: 100 }), TransitionEffect.opacity(0).animation({ duration: 100 }) ) ) } } .width('90%') .backgroundColor('#FFFFFF') .borderRadius(12) .shadow({ radius: 10, color: 'rgba(0, 0, 0, 0.08)' }) .clip(true) } .width('100%') .height('100%') .backgroundColor('#F5F5F5') } }卡片类组件做展开动画,flex 布局下高度由内容撑起,直接给height设置一个固定值去做动画并不合适,因为内容是不确定的。我试过两种方案:第一种是用if控制内容区出现/消失,并用transition让内容区淡入淡出,卡片整体的高度变化由布局自然完成——但这样高度变化本身没有动画,是瞬间的;第二种是先测量内容高度,再显式地设置 height 从 0 到测量值。明显第一种在多数场景下够用且更稳定,配合opacity的转场,观感上已经足够“顺”。需要高度动画的场合,可以延迟内容显示,让高度先做动画,内容在半途再淡入,这样看起来更高级,实现上也只要给transition加上delay就行。
调delay的时候我建议先想清楚时间线。在这个例子里,展开时高度变化 200ms,内容出现延迟 100ms,所以内容其实是到一半的时候才开始显示;折叠时内容先消失,100ms 后高度再收回,用户不会看到文字被压缩变形。这套时间差逻辑可以复用到抽屉、手风琴菜单等几乎所有展开收起场景。
4.3 详情页转场:滑动加缩放的组合路线
第三个案例是页面级转场,把前面讲到的Navigation路由转场和共享元素综合起来。场景:商品列表点击后进入详情页,希望封面图有共享转场,同时整个页面从右侧滑入并伴随轻微的缩放。
@Entry @Component struct ProductListDemo { pathStack: NavPathStack = new NavPathStack(); @State productList: string[] = ['商品A', '商品B', '商品C']; build() { Navigation(this.pathStack) { List({ space: 10 }) { ForEach(this.productList, (name: string) => { ListItem() { Row({ space: 12 }) { Image('https://example.com/cover.png') .width(80) .height(80) .borderRadius(8) .sharedTransition('cover_' + name, { duration: 350 }) Text(name) .fontSize(20) } .padding(10) .backgroundColor('#FFFFFF') .borderRadius(12) .onClick(() => { this.pathStack.pushPath({ name: 'ProductDetailPage', param: { productName: name } }); }) } }, (name: string) => name) } .padding(10) .width('100%') .height('100%') } .pageTransition() { PageTransitionEffect.create(120) .translate({ x: 200 }) .opacity(0) .animation({ duration: 400, curve: Curve.EaseOut }) } } }详情页那边,在aboutToAppear里取出路由参数,给图片设置和列表页相同的sharedTransition('cover_' + productName),这样点击跳转时,图片会从列表位置平滑移动到详情页头部。注意,共享元素和pageTransition一起用,共享元素会覆盖普通转场对图片的效果,也就是说图片不走“从右滑入”,而是“跟随页面一起来但独立做位置/大小变化”。目标页面图片的大小如果和源页面差异很大,系统会自动插值计算,效果非常惊艳。
这套路由转场我建议在真机上调试,模拟器有时会因为渲染机制不同导致动画闪烁。
5. 常见问题与排查技巧实录
5.1 为什么动画没有生效
这是个高频问题,我总结了几个最常见的原因,基本能覆盖 80% 的状况。
| 现象 | 原因 | 解决方案 |
|---|---|---|
| 属性变了但无动画 | 组件没有写animation,或状态变化不在animateTo闭包内 | 补上animation,或将状态修改包进animateTo |
| 只有部分属性有动画 | animation写在了几个属性的中间,只对它前面的属性生效 | 把animation移到所有需要动画的属性之后 |
| 动画一闪而过 | duration太短,或状态变化被连续触发 | 检查时长和事件触发频率 |
| 退场没有动画 | transition没写在组件自身上,或使用了visibility控制显隐 | 把transition写到目标组件上,改用if渲染 |
| 转场生效但很生硬 | 使用了Curve.Linear或没有配置曲线 | 使用Curve.EaseOut/EaseInOut |
还有一个隐蔽原因:动画是生效了,但因为目标组件的父容器没有足够的空间,位移被裁剪了,看起来像没移动。比如在List的第一项上做translate({ y: -100 }),可能被List的边界裁剪。排查方法很简单,把clip(false)加到父容器试试,如果能看到组件移出边界,就说明被裁剪了。
状态驱动不生效的问题,我还想强调一点:ArkUI 的@State只监听一层属性变化,如果你直接修改对象里的某个字段,UI 不会更新。比如this.userInfo.name = '新名字',这是无效的,必须this.userInfo = { ...this.userInfo, name: '新名字' }。动画本质依赖于状态更新,这个没搞明白,排查多久都白搭。
5.2 卡顿与性能开销
动画卡顿通常不是动画 API 的问题,而是布局频繁计算和渲染压力过大。
如果动画属性是width、height、padding这种参与布局的属性,每变化一帧都可能触发父组件重新测量和排列,代价很高。特别是列表里的每个 item 都在做宽度动画时,卡顿几乎是必然的。优化思路是,能用scale和translate实现的效果不要用width/height,比如按钮的按下“缩小”效果,用scale({ x: 0.95, y: 0.95 })就比改width和height强得多。
另一个容易踩的坑是在ForEach循环里给每个 item 写独立的animation,并且 item 的 key 不稳定。每次状态变化,ForEach可能重建组件,动画刚启动就重建,不仅效果看不到,还在不停创建销毁节点,性能雪上加霜。正确做法是,尽量让 item 的 key 稳定,动画效果配置在 item 组件根节点上,不要在父容器上搞全局动画。
scale虽然不触发布局,但如果组件数量很多,GPU 的绘制压力依然存在。大型列表不建议让所有 item 同时做缩放动画,可以考虑只对“当前交互的 item”做动画,其他 item 保持静态。
5.3 手势冲突与动画打断
手势场景下,动画和用户手指操作的优先级经常打架。最典型的是:用户正在滑动列表,这时候某个 item 刚好触发了自转动画,结果手势被动画吃掉或者动画被手势打断,体验很怪。
ArkUI 在动画参数上有一个interruptible属性控制动画是否可以被新触发动画打断。默认情况下,新动画会打断未完成的动画并跳到新动画的起点,这在多数场景是合理的,比如用户快速连点按钮,旧动画被打断是应该的。但如果你的动画特别重要,比如引导动画、成就展示动画,就要设置interruptible: false,或者配合手势检测让动画完成后才允许新的交互。
手势和转场动画同时存在时,可以在组件上使用gesture绑定TapGesture或者LongPressGesture,然后在手势的回调里去触发animateTo。不推荐在onTouch里自行判断手势并触发动画,因为 onTouch 的触发频率太高,动画容易被反复打断。
5.4 我踩过的一些值得一提的坑
第一,曲线参数不要用字符串。ArkUI 的curve参数类型是Curve枚举或ICurve,直接写curve: 'ease-out'是不会报错但还是会不生效的(具体取决于版本),现象是动画直接跳到终点。遇到动画“跳变”的时候,先检查曲线参数类型。
第二,TransitionEffect里的.animation()如果没写会怎么样?答案是转场会瞬间完成,没有任何过渡。有时候你以为写了transition就自动有动画,其实转场效果必须显式指定.animation来定义时长和曲线。
第三,共享元素转场期间,源组件和目的组件不能同时绑定点击事件去触发其他动画,否则会出现两个动画同时作用导致的视觉跳变。有一次我给详情页的图片同时写了sharedTransition和点击放大的animation,结果图片在转场过程中就开始缩放,看起来就别扭了。
第四,弹窗类组件如果用visibility控制显隐,transition不会触发。鸿蒙的bindSheet和bindContentCover是专门的半模态组件,它们有自己的显隐动画逻辑,不需要也不能用transition控制。我在做底部弹窗时,一开始也是用 if 控制自定义弹层的显隐,后来发现手势拖拽关闭就很难模拟,最后换成了bindSheet,顺滑很多。
尾声:熟练之后要建立“动画先于代码”的思维
动画这块内容,实践下来的体会是:先想清楚状态和时间线,再写代码,是最高效率的方式。我每次给页面加动画,都会先用手比划一下:这个元素从哪里来到哪里去,应该先动哪个后动哪个,时长大概多少,曲线是快进慢出还是匀速。这些想清楚了,对应的 API 基本是固定的,代码只是把设计意图翻译出来而已。
另外还有两个小建议给正在走鸿蒙进阶路线的朋友。第一,动画参数尽量抽成常量,比如Animations.ButtonFeedback = { duration: 150, curve: Curve.EaseOut },项目里统一使用,不然每个页面手感不一样,领导验收时会觉得细节不到位。第二,多看一眼官方文档里的动画性能建议,ArkUI 的渲染管线和一些性能优化配置(比如合理使用update更新局部组件)在实际项目中非常管用。
如果要把这篇笔记继续往下延伸,下一步值得研究的是属性动画的高级用法(比如关键帧动画和路径动画),以及插值器自定义(实现非线性非内置的动画效果)。我计划在后续的课程笔记里再整理这两块,到时候可以对比着看,动画的底层能力基本就覆盖全面了。