鸿蒙实战:基于Navigation自定义转场动画 —— 一镜到底
本文介绍了在鸿蒙应用中实现页面转场动画的技术方案,重点讲解了如何通过Navigation组件的customNavContentTransition API实现卡片到详情页的"一镜到底"效果。该方案基于四大核心模块:TransitionManager负责转场调度,SharedElementAnimator处理动画参数计算与执行,列表页和详情页分别负责截图捕获与动画回调注册。通过组件截图、节点位置计算
完整源码:RedNoteApp
在鸿蒙应用中,页面转场动画是提升用户体验的利器。Navigation 组件提供了 customNavContentTransition API,允许开发者完全接管页面切换动画。本文将基于一个真实的图文社区 App 代码,手把手教你实现从卡片列表到详情页的“一镜到底”(Shared Element Transition)效果:点击卡片,卡片逐渐放大、平移至全屏成为详情页的图片区域;返回时,详情页平滑缩小并回到卡片原位置。
效果预览
- 个人主页:双列瀑布流展示图文卡片。
- 详情页:大图轮播、标题、正文、评论。
- 转场:点击卡片 → 卡片截图开始放大/平移动画 → 详情页内容淡入 → 返回时逆向动画。

实现原理
整个方案基于以下关键能力:
- 组件截图(ComponentSnapshot):在跳转前捕获卡片视图为
PixelMap,作为动画占位图。 - 节点位置计算:获取卡片和目标图片在窗口中的精确位置与尺寸,计算出缩放比例、平移距离和裁剪区域。
- 自定义转场接管:通过
Navigation的customNavContentTransition钩子,在页面 Push/Pop 时执行自定义动画,而非系统默认动画。 - 状态驱动 UI:动画器使用
@ObservedV2+@Trace将动画参数(scale、translate、clip、opacity 等)与 UI 绑定,通过animateTo改变状态触发 UI 平滑过渡。
核心模块有四部分:
TransitionManager:全局单例,管理页面转场回调。SharedElementAnimator:核心动画器,负责计算动画参数并执行入场/退场动画。- 列表页(
ProfilePage):捕获截图,传递参数并跳转。 - 详情页(
ImageTextDetailPage):初始化动画器,注册转场回调。 - 主页面(
MainPage):配置customNavContentTransition,调度转场。
一、TransitionManager:转场调度中心
TransitionManager 用于存储每个详情页的转场回调,供 Navigation 在转场时调用。
type TransitionCallback = (proxy: NavigationTransitionProxy, isPush: boolean) => void;
export class TransitionManager {
private static instance: TransitionManager;
private animationMap: Map<string, TransitionCallback> = new Map();
private currentProxy: NavigationTransitionProxy | null = null;
private isTransitionActive: boolean = false;
private constructor() {}
public static getInstance(): TransitionManager {
if (!TransitionManager.instance) {
TransitionManager.instance = new TransitionManager();
}
return TransitionManager.instance;
}
public register(pageId: string, callback: TransitionCallback): void {
this.animationMap.set(pageId, callback);
}
public unregister(pageId: string): void {
this.animationMap.delete(pageId);
}
public getCallback(pageId: string): TransitionCallback | undefined {
return this.animationMap.get(pageId);
}
public startTransition(proxy: NavigationTransitionProxy): void {
this.currentProxy = proxy;
this.isTransitionActive = true;
}
public finishTransition(): void {
if (this.currentProxy && this.isTransitionActive) {
this.currentProxy.finishTransition();
this.currentProxy = null;
this.isTransitionActive = false;
}
}
public cancelTransition(): void {
if (this.currentProxy && this.isTransitionActive && this.currentProxy.cancelTransition) {
this.currentProxy.cancelTransition();
this.currentProxy = null;
this.isTransitionActive = false;
}
}
public getCurrentProxy(): NavigationTransitionProxy | null {
return this.currentProxy;
}
public isActive(): boolean {
return this.isTransitionActive;
}
}
pageId使用NavDestination的navDestinationId,保证每个页面实例唯一。- 回调中携带
isPush标志,让详情页知道当前是入场还是退场,从而调用动画器的不同方法。
二、SharedElementAnimator:核心动画器
动画器使用 @ObservedV2 和 @Trace,将所有动画相关的状态变量变为可观察,UI 会自动响应变化。
2.1 动画状态变量
@ObservedV2
export class SharedElementAnimator {
@Trace scale: number = 1; // 外层缩放
@Trace translateX: number = 0; // 外层水平平移
@Trace translateY: number = 0; // 外层垂直平移
@Trace clipWidth: string | number = '100%'; // 外层裁剪宽度
@Trace clipHeight: string | number = '100%'; // 外层裁剪高度
@Trace borderRadius: number = 0; // 圆角
@Trace positionXValue: number = 0; // 内部内容偏移 X
@Trace positionYValue: number = 0; // 内部内容偏移 Y
@Trace snapshotSize: SizeOptions = { width: 0, height: 0 }; // 截图尺寸
@Trace snapshotPositionX: number = 0; // 截图位置 X
@Trace snapshotPositionY: number = 0; // 截图位置 Y
@Trace snapshotOpacity: number = 1; // 截图透明度
@Trace contentOpacity: number = 0; // 真实内容透明度
@Trace navDestinationBgColor: ResourceColor = '#00000000'; // 背景色
// ... 其他内部属性
}
2.2 初始化与位置计算
init 方法接收截图、卡片组件 ID、目标图片组件 ID 等,并在第一次动画前调用 prepare() 计算所有初始动画值。
public init(uiContext: UIContext, snapshot: image.PixelMap, cardComponentId: string, targetId: string, recoverCallback?: () => void) {
this.uiContext = uiContext;
this.snapshotPixelMap = snapshot;
this.savedCardComponentId = cardComponentId;
this.savedTargetId = targetId;
this.recoverCallback = recoverCallback || (() => {});
this.isPrepared = false;
}
prepare() 方法中关键计算步骤(仅展示核心逻辑):
// 1. 获取卡片真实可视区域(考虑组件自身的 scale/translate)
const cardInfo = uiContext.getComponentUtils().getRectangleById(cardComponentId);
// 修正缩放影响,得到实际卡片位置和宽高(px)
// 2. 获取目标图片节点位置和尺寸
const targetNode = uiContext.getFrameNodeById(targetId);
const targetPos = targetNode.getPositionToWindowWithTransform();
const targetSize = targetNode.getMeasuredSize();
// 3. 计算缩放比例
initScale = cardWidth_px / targetSize.width;
// 4. 计算初始裁剪尺寸(vp)
initClipWidth = px2vp(cardWidth_px / initScale);
initClipHeight = px2vp(cardHeight_px / initScale);
// 5. 计算平移量
initTranslateX = px2vp(cardLeft_px - (windowWidthPx/2 - cardWidth_px/2));
initTranslateY = px2vp(cardTop_px - ((vp2px(initClipHeight) - vp2px(initClipHeight)*initScale)/2));
// 6. 内部内容偏移
initPositionX = -targetPos.x;
initPositionY = -targetPos.y;
2.3 入场动画
public startEnterAnimation(onFinished?: () => void) {
this.prepare();
const ui = this.uiContext!;
ui.animateTo({ duration: 250, curve: Curve.EaseInOut, onFinish: () => {
this.borderRadius = 0;
onFinished?.();
}}, () => {
this.scale = 1;
this.translateX = 0;
this.translateY = 0;
this.clipWidth = this.screenWidthVp; // 屏幕宽(vp)
this.clipHeight = this.screenHeightVp; // 屏幕高(vp)
this.positionXValue = 0;
this.positionYValue = 0;
this.borderRadius = 34;
this.navDestinationBgColor = Color.White;
});
ui.animateTo({ delay: 100, duration: 100 }, () => { this.snapshotOpacity = 0; });
ui.animateTo({ duration: 100 }, () => { this.contentOpacity = 1; });
}
2.4 退场动画
public startExitAnimation(onFinished?: () => void) {
this.prepare();
const ui = this.uiContext!;
ui.animateTo({ duration: 300, curve: Curve.EaseInOut, onFinish: () => {
this.borderRadius = 12;
this.recoverCallback(); // 恢复原卡片可见
this.releaseSnapshot(); // 释放 PixelMap
onFinished?.();
}}, () => {
this.scale = this.initScale;
this.translateX = this.initTranslateX;
this.translateY = this.initTranslateY;
this.clipWidth = this.initClipWidth;
this.clipHeight = this.initClipHeight;
this.positionXValue = this.initPositionX;
this.positionYValue = this.initPositionY;
this.borderRadius = 16;
this.navDestinationBgColor = Color.Transparent;
});
ui.animateTo({ delay: 150, duration: 50 }, () => { this.snapshotOpacity = 1; });
ui.animateTo({ delay: 200, duration: 50 }, () => { this.contentOpacity = 0; });
}
三、列表页:截图捕获与跳转(以 ProfilePage 为例)
在 ProfilePage 的卡片点击事件中:
onItemClick: (item: FeedItem) => {
if (item.type !== FeedItemType.IMAGE_TEXT) return;
const cardId = "card_" + item.id;
this.getUIContext().getComponentSnapshot().get(cardId, (err, pixelMap) => {
if (err || !pixelMap) {
// 降级:无截图直接跳转
this.navPathStack?.pushPathByName('ImageTextDetailPage', item);
return;
}
// 隐藏原卡片,避免视觉重叠
item.isHidden = true;
const param: Record<string, Object> = {
'clickedComponentId': cardId,
'item': item,
'snapshot': pixelMap,
'doDefaultTransition': () => { item.isHidden = false; }
};
this.navPathStack?.pushPathByName('ImageTextDetailPage', param);
});
},
- 使用
getComponentSnapshot().get()异步捕获卡片视图的PixelMap。 - 跳转前设置
item.isHidden = true,卡片组件需根据该状态隐藏(如.visibility(Visibility.Hidden))。 - 将截图、组件 ID、恢复回调等通过路由参数传递给详情页。
四、详情页集成动画
详情页通过 @Builder 函数导出,供系统路由使用。
import { CommentItem } from '../model/CommentItem';
import { ImageItem, ImageTextFeedItem } from '../model/FeedItem';
import { fetchCommentsByNoteId } from '../model/MockImageTextDetailData';
import { SafeAreaState } from '../model/SafeAreaState';
import { AppStorageV2 } from '@kit.ArkUI';
import { TransitionManager } from '../manager/TransitionManager';
import { SharedElementAnimator } from '../utils/SharedElementAnimator';
import { WindowUtil } from '../utils/WindowUtil';
// 页面构造器
@Builder
export function ImageTextDetailPageBuilder(_: string, param: Record<string, Object>) {
ImageTextDetailPage({ param: param });
}
@ComponentV2
export struct ImageTextDetailPage {
// 页面入参
@Param @Require param: Record<string, Object>;
// 列表传递过来的图文数据
@Local item: ImageTextFeedItem = this.param.item as ImageTextFeedItem;
// 卡片截图
@Local snapshotPixelMap?: PixelMap = this.param.snapshot as PixelMap;
// 点击的卡片组件ID
@Local clickedComponentId?: string = this.param.clickedComponentId as string;
// 图片最大高度(vp)
@Local imageMaxHeightVp: number = 0;
// 评论列表
@Local comments: CommentItem[] = [];
// 是否已点赞
@Local isLiked: boolean = this.item.isLiked;
// 点赞数
@Local likeCount: number = this.item.likeCount;
// 是否已收藏
@Local isCollected: boolean = false;
// 导航栈
@Consumer('navPathStack') navPathStack?: NavPathStack;
// 上一页结束转场的回调
private prePageDoFinishTransition: () => void = this.param['doDefaultTransition'] as () => void;
// 安全区状态
@Local safeArea: SafeAreaState = AppStorageV2.connect<SafeAreaState>(
SafeAreaState, () => new SafeAreaState()
)!;
// 共享元素动画器
@Local animator: SharedElementAnimator = new SharedElementAnimator();
// 转场管理器
private transitionManager = TransitionManager.getInstance();
// 页面ID
private pageId?: string = '';
// 页面即将显示
aboutToAppear() {
this.calculateImageHeight();
this.loadComments();
}
// 计算图片最大显示高度
private calculateImageHeight() {
// 使用第一张图片的宽高比(或封面图的宽高比)
const firstImage = this.item.images[0];
let aspectRatio = 1;
if (firstImage && firstImage.width > 0 && firstImage.height > 0) {
aspectRatio = firstImage.height / firstImage.width;
}
const screenWidthVp = this.getUIContext().px2vp(WindowUtil.getWindowWidthPx());
const swiperWidthVp = screenWidthVp;
this.imageMaxHeightVp = swiperWidthVp * aspectRatio;
console.log("imageMaxHeightVp = " + this.imageMaxHeightVp)
}
// 加载评论数据
private async loadComments() {
this.comments = await fetchCommentsByNoteId(this.item.id);
}
// 初始化共享元素动画
private initAnimation() {
if (!this.snapshotPixelMap || !this.clickedComponentId) return;
try {
this.animator.init(
this.getUIContext(),
this.snapshotPixelMap,
this.clickedComponentId,
'swiperID' + this.clickedComponentId,
this.prePageDoFinishTransition
);
} catch (e) {
console.error('Animation init failed', e);
this.animator.contentOpacity = 1;
this.animator.snapshotOpacity = 0;
}
}
build() {
NavDestination() {
Stack({ alignContent: Alignment.TopStart }) {
Stack({ alignContent: Alignment.TopStart }) {
// 卡片截图
Image(this.snapshotPixelMap)
.size(this.animator.snapshotSize)
.syncLoad(true)
.position({ x: this.animator.snapshotPositionX, y: this.animator.snapshotPositionY })
.opacity(this.animator.snapshotOpacity);
// 页面主体内容
Column() {
this.NavigationBarBuilder();
Scroll() {
Column({ space: 0 }) {
// 图片轮播
Swiper() {
ForEach(this.item.images, (item: ImageItem, idx: number) => {
Image(item.url)
.size({ width: '100%' ,height:'100'})
.objectFit(ImageFit.Auto)
.backgroundColor(Color.White)
});
}
.id('swiperID' + this.clickedComponentId)
.width('100%')
.height(this.imageMaxHeightVp)
.indicator(true)
.loop(false);
// 标题部分
// 评论列表
this.CommentListBuilder();
}
.width('100%')
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.scrollBar(BarState.Off)
.width('100%')
.layoutWeight(1)
// 底部工具栏
this.BottomToolBuilder();
}
.width('100%')
.height('100%')
.opacity(this.animator.contentOpacity);
}
.width('100%')
.position({ x: this.animator.positionXValue, y: this.animator.positionYValue });
}
.width(this.animator.clipWidth)
.height(this.animator.clipHeight)
.scale({ x: this.animator.scale, y: this.animator.scale })
.translate({ x: this.animator.translateX, y: this.animator.translateY })
.borderRadius(this.animator.borderRadius)
.clip(true)
.expandSafeArea([SafeAreaType.SYSTEM])
.backgroundColor($r('sys.color.point_color_checked'))
}
.hideTitleBar(true)
.backgroundColor(this.animator.navDestinationBgColor)
.opacity(this.animator.contentOpacity)
.onReady((context) => {
this.pageId = context.navDestinationId;
this.navPathStack = context.pathStack;
this.initAnimation();
if (this.pageId) {
this.transitionManager.register(this.pageId, (proxy, isPush) => {
if (isPush) {
this.animator.startEnterAnimation(() => proxy.finishTransition());
} else {
this.animator.startExitAnimation(() => proxy.finishTransition());
}
});
} else {
this.animator.contentOpacity = 1;
this.animator.snapshotOpacity = 0;
}
})
.onDisAppear(() => {
if (this.pageId) this.transitionManager.unregister(this.pageId);
})
.onBackPressed(() => {
this.navPathStack?.pop();
return true;
});
}
// 导航栏构建器
@Builder
NavigationBarBuilder() {}
// 底部操作栏
@Builder
BottomToolBuilder() {}
// 评论列表
@Builder
CommentListBuilder() {}
// 单条评论组件
@Builder
commentItemBuilder(comment: CommentItem) {}
}
注意:Swiper 的 id 必须与动画器传入的 targetId 一致,这样 getFrameNodeById 才能找到目标图片的位置。
五、主页面配置 customNavContentTransition
MainPage 是整个应用的入口,它配置了 Navigation 组件并定义了系统路由表(router_map.json)。
@Entry
@ComponentV2
struct MainPage {
private navPathStack: NavPathStack = new NavPathStack();
private transitionManager = TransitionManager.getInstance();
@Local isEnabled: boolean = true;
@Builder
pageMap(name: string, param: Object) {
if (name === 'ImageTextDetailPage') {
ImageTextDetailPage({ param: param as Record<string, Object> });
}
// 其他路由...
}
build() {
Navigation(this.navPathStack) {
// 根内容:TabBar + 各个 Tab 页面
Tabs() {
TabContent() { HomePage() }
TabContent() { PublishPage() }
TabContent() { MessagePage() }
TabContent() { ProfilePage() } // 个人主页作为根内容之一
}
}
.mode(NavigationMode.Stack)
.hideTitleBar(true)
.navDestination(this.pageMap) // 绑定系统路由
.enabled(this.isEnabled)
.customNavContentTransition((from, to, operation) => {
// 获取需要执行动画的页面 ID:PUSH 取 to,POP 取 from
const targetId = operation === NavigationOperation.PUSH ? to?.navDestinationId : from?.navDestinationId;
if (!targetId) return undefined;
const callback = this.transitionManager.getCallback(targetId);
if (!callback) return undefined;
return {
timeout: 2000, // 动画时长不得超过 这里设定值。
transition: (proxy) => {
this.isEnabled = false; // 动画期间禁止用户交互
callback(proxy, operation === NavigationOperation.PUSH);
},
onTransitionEnd: () => {
this.isEnabled = true;
}
};
});
}
}
对应的 router_map.json:
{
"routerMap": [
{
"name": "ImageTextDetailPage",
"pageSourceFile": "src/main/ets/pages/ImageTextDetailPage.ets",
"buildFunction": "ImageTextDetailPageBuilder",
"data": { "description": "图文详情页" }
}
]
}
六、关键注意事项
-
目标组件必须设置固定 id:详情页中需要共享的元素(这里是 Swiper)必须设置一个可通过
clickedComponentId动态拼接的id,以便动画器通过getFrameNodeById获取位置。 -
截图释放:退场动画结束后必须调用
pixelMap.release(),否则会导致内存泄漏。 -
动画并发控制:
SharedElementAnimator内部使用animationCount计数器,防止快速点击导致的动画状态错乱。 -
降级处理:当截图失败或组件找不到时,应直接显示详情页(设置
contentOpacity = 1,snapshotOpacity = 0),保证功能可用。 -
isHidden标志需要卡片组件配合:列表页隐藏原卡片是为了避免动画期间看到两个相同内容,需要在卡片组件内部根据item.isHidden控制可见性。 -
单位转换:所有位置计算使用 px,而 UI 尺寸使用 vp,需要用
px2vp/vp2px转换,确保动画精确。
七、总结
通过以上步骤,我们完整实现了一套基于 Navigation 自定义转场的一镜到底动画。核心思想是:
- 用
ComponentSnapshot捕获卡片外观。 - 用
getRectangleById和getFrameNodeById获取位置信息,计算动画的起始和结束状态。 - 用
@ObservedV2+animateTo驱动 UI 属性变化。 - 用
TransitionManager+customNavContentTransition将动画与页面生命周期绑定。
根据自己的业务需求调整动画曲线、时长,或者扩展支持更多共享元素。希望本文能帮助你打造更精致的鸿蒙应用体验。
更多推荐




所有评论(0)