欢迎光临
我们一直在努力

鸿蒙实战:基于Navigation自定义转场动画 —— 一镜到底

完整源码: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)
    未经允许不得转载:171主机测评 » 鸿蒙实战:基于Navigation自定义转场动画 —— 一镜到底
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址