页面跳转时的动画不只是"好看"——它告诉用户"你从哪来,要到哪去"。没有动画的跳转像电视换台,突然蹦出一个新画面,用户要花半秒才能反应过来。好的转场动画是空间连续的,用户能感知到前后页面的逻辑关系。
Navigation内置转场
Navigation默认有推入/推出动画——新页面从右侧滑入,返回时向右滑出。这是iOS/Android/HarmonyOS通用的标准转场。
但不是所有场景都适合右滑。底部弹出的半模态、从中心展开的卡片、淡入淡出的tab切换,都需要自定义转场。
自定义NavDestination转场
NavDestination支持自定义转场动画:
NavDestination(){// 页面内容}.transition(TransitionEffect.OPACITY)TransitionEffect提供几种预设:
| 预设 | 效果 |
|---|---|
| OPACITY | 淡入淡出 |
| SLIDE(SlideEffect.Right) | 从右滑入 |
| SLIDE(SlideEffect.Left) | 从左滑入 |
| SLIDE(SlideEffect.Up) | 从下方滑入 |
| SLIDE(SlideEffect.Down) | 从上方滑入 |
| NONE | 无动画 |
组合动画:
NavDestination(){// 页面内容}.transition(TransitionEffect.SLIDE(SlideEffect.Right).combine(TransitionEffect.OPACITY))combine把两个动画组合——同时滑动+淡入。
animateTo自定义转场
更灵活的控制用animateTo。在pushPath后手动触发动画:
@StatepageScale:number=0.8@StatepageOpacity:number=0pushToDetail(){this.navPathStack.pushPath({name:'Detail'});animateTo({duration:300,curve:Curve.EaseOut},()=>{this.pageScale=1;this.pageOpacity=1;});}配合NavDestination的属性绑定:
NavDestination(){// 内容}.scale({x:this.pageScale,y:this.pageScale}).opacity(this.pageOpacity)这种方式需要自己管理动画状态,但可以实现任意效果——缩放、旋转、3D翻转都行。
共享元素转场
共享元素转场(Shared Transition)让同一个元素在两个页面间平滑过渡。比如列表页的封面图"飞"到详情页的大图位置。
第一步:给两个页面的对应元素设相同的sharedTransition名称:
// 列表页Image(item.cover).width(120).height(80).sharedTransition('cover_'+item.id,{duration:300,curve:Curve.EaseInOut})// 详情页Image(this.item.cover).width('100%').height(240).sharedTransition('cover_'+this.item.id,{duration:300,curve:Curve.EaseInOut})名称必须完全一致。duration和curve也要匹配,否则进出动画不协调。
sharedTransition的名称必须唯一——如果列表中有多个图片用同一个名称,动画会错乱。所以用’cover_’ + id组合唯一名称。
共享元素动画参数
.sharedTransition('unique_name',{duration:300,curve:Curve.EaseInOut,delay:0,zIndex:0,type:SharedTransitionEffectType.Static})type有两种值:
- Static:静态共享,元素直接从起点飞到终点,中间不跟随手势
- Animated:动画共享,支持更流畅的过渡
zIndex控制共享元素的层级——值越高越在上面。如果共享元素被其他元素遮挡,加大zIndex。
多元素共享
多个元素同时做共享转场:
// 列表页Image(item.cover).sharedTransition('cover_'+item.id,{duration:300})Text(item.title).sharedTransition('title_'+item.id,{duration:300,delay:50})Text(item.author).sharedTransition('author_'+item.id,{duration:300,delay:100})// 详情页Image(this.item.cover).sharedTransition('cover_'+this.item.id,{duration:300})Text(this.item.title).sharedTransition('title_'+this.item.id,{duration:300,delay:50})Text(this.item.author).sharedTransition('author_'+this.item.id,{duration:300,delay:100})delay让各元素依次出发,形成"波浪"效果。50ms间隔不会太密集也不会太分散。
注意:共享元素数量别超过5个。每个共享元素都需要独立的动画通道,太多会掉帧。
共享元素与页面背景
共享元素动画期间,两个页面都在渲染。如果页面背景不协调,会出现"穿帮"——比如列表页白底、详情页灰底,过渡期间背景切换很明显。
解决方式:让两个页面的背景色一致,或者给共享元素加一个背景遮罩:
Image(item.cover).sharedTransition('cover_'+item.id,{duration:300,zIndex:10}).backgroundColor(Color.White).padding(8)给共享元素加白色背景+padding,让它"包裹"内容。动画时白色背景一起移动,遮挡后面的背景切换。
手势返回与共享元素
手势返回时共享元素需要反向动画——从详情页飞回列表页。Navigation默认支持,但有个前提:列表页的共享元素在返回时还必须在屏幕上。
如果列表是LazyForEach,滚动到详情页后列表项可能已经被回收了。返回时找不到对应的共享元素,动画就没了。
解决方式:在pushPath前记录被点击项的索引,返回时先scrollToIndex让该项可见:
privateclickedItemIndex:number=-1;// 跳转前记录onClick(()=>{this.clickedItemIndex=index;this.navPathStack.pushPath({name:'Detail',param:{id:item.id}});})// 返回后恢复aboutToAppear():void{this.navPathStack.on('pop',()=>{if(this.clickedItemIndex>=0){this.scroller.scrollToIndex(this.clickedItemIndex);}});}页面进入/退出动画
除了共享元素,页面本身也有进入和退出动画:
NavDestination(){Column(){// 页面内容}.width('100%').height('100%')}.onShown(()=>{animateTo({duration:300},()=>{this.contentOpacity=1;this.contentTranslateY=0;});}).onHidden(()=>{animateTo({duration:200},()=>{this.contentOpacity=0;this.contentTranslateY=20;});})onShown在页面显示时触发,onHidden在页面被覆盖时触发。用animateTo驱动内容区的出现/消失动画。
关键:onShown/onHidden不等同于页面的mount/unmount。页面被覆盖(新页面push到上面)时触发onHidden但不销毁,返回时触发onShown但不重新mount。所以动画状态要在onShown中重置。
模态转场
全屏模态弹出需要从底部滑入的转场:
@StateshowSheet:boolean=false@StatesheetOffset:number=100Column(){// 内容}.width('100%').height('100%').translate({y:this.showSheet?0:100}).animation({duration:300,curve:Curve.EaseOut})但这不是NavDestination,是同一个页面内的视图切换。真正的模态用bindSheet或bindContentCover,它们自带转场动画。
转场动画性能
转场动画期间两个页面同时渲染,性能压力是平时的两倍。几个优化点:
- 减少共享元素数量:不超过3个
- duration别太长:300ms是舒适区,超过500ms用户会觉得慢
- 共享元素避免shadow/blur:阴影和模糊的离屏渲染开销大
- 关闭不必要的animation:转场期间暂停其他动画
- 用renderGroup:把页面内容标记为渲染组,减少重绘范围
Column(){// 页面内容}.renderGroup(true)renderGroup让组件树整体绘制到离屏缓冲区,转场动画只需要移动/缩放缓冲区,不需要重绘每帧内容。
踩坑清单
| 问题 | 原因 | 解决 |
|---|---|---|
| 共享元素没动画 | 两个页面的名称不一致 | 名称必须完全相同 |
| 动画闪烁 | 两个页面背景色不一致 | 统一背景色或给元素加背景 |
| 返回时动画丢失 | 列表项被LazyForEach回收 | 返回前scrollToIndex |
| 多个元素动画错乱 | 用了相同的sharedTransition名称 | 每个元素用唯一名称 |
| 转场期间掉帧 | 共享元素太多或有shadow | 不超过3个,去掉阴影 |
| onShown中动画不执行 | 页面已显示但状态没重置 | 先重置状态再animateTo |
| combine动画不生效 | transition写法有误 | 用combine链式调用 |
| 模态弹出没有动画 | 直接改@State显示/隐藏 | 用bindSheet或加translate动画 |
| 共享元素被遮挡 | zIndex太低 | 加大sharedTransition的zIndex |
| 手势返回动画卡顿 | 两个页面同时渲染太重 | 加renderGroup(true) |
转场动画的原则:共享元素不超过3个,duration 300ms,curve用EaseInOut,zIndex保证在最上层。好的转场是克制的——只让最关键的视觉元素做动画,其他元素安静地淡入淡出。