完整源码RedNoteApp

在鸿蒙应用中,页面转场动画是提升用户体验的利器。Navigation 组件提供了 customNavContentTransition API,允许开发者完全接管页面切换动画。本文将基于一个真实的图文社区 App 代码,手把手教你实现从卡片列表到详情页的“一镜到底”(Shared Element Transition)效果:点击卡片,卡片逐渐放大、平移至全屏成为详情页的图片区域;返回时,详情页平滑缩小并回到卡片原位置。

效果预览

  • 个人主页:双列瀑布流展示图文卡片。
  • 详情页:大图轮播、标题、正文、评论。
  • 转场:点击卡片 → 卡片截图开始放大/平移动画 → 详情页内容淡入 → 返回时逆向动画。
    外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

实现原理

整个方案基于以下关键能力:

  1. 组件截图(ComponentSnapshot):在跳转前捕获卡片视图为 PixelMap,作为动画占位图。
  2. 节点位置计算:获取卡片和目标图片在窗口中的精确位置与尺寸,计算出缩放比例、平移距离和裁剪区域。
  3. 自定义转场接管:通过 NavigationcustomNavContentTransition 钩子,在页面 Push/Pop 时执行自定义动画,而非系统默认动画。
  4. 状态驱动 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 使用 NavDestinationnavDestinationId,保证每个页面实例唯一。
  • 回调中携带 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) {}
}

注意Swiperid 必须与动画器传入的 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": "图文详情页" }
    }
  ]
}

六、关键注意事项

  1. 目标组件必须设置固定 id:详情页中需要共享的元素(这里是 Swiper)必须设置一个可通过 clickedComponentId 动态拼接的 id,以便动画器通过 getFrameNodeById 获取位置。

  2. 截图释放:退场动画结束后必须调用 pixelMap.release(),否则会导致内存泄漏。

  3. 动画并发控制SharedElementAnimator 内部使用 animationCount 计数器,防止快速点击导致的动画状态错乱。

  4. 降级处理:当截图失败或组件找不到时,应直接显示详情页(设置 contentOpacity = 1snapshotOpacity = 0),保证功能可用。

  5. isHidden 标志需要卡片组件配合:列表页隐藏原卡片是为了避免动画期间看到两个相同内容,需要在卡片组件内部根据 item.isHidden 控制可见性。

  6. 单位转换:所有位置计算使用 px,而 UI 尺寸使用 vp,需要用 px2vp / vp2px 转换,确保动画精确。

七、总结

通过以上步骤,我们完整实现了一套基于 Navigation 自定义转场的一镜到底动画。核心思想是:

  • ComponentSnapshot 捕获卡片外观。
  • getRectangleByIdgetFrameNodeById 获取位置信息,计算动画的起始和结束状态。
  • @ObservedV2 + animateTo 驱动 UI 属性变化。
  • TransitionManager + customNavContentTransition 将动画与页面生命周期绑定。

根据自己的业务需求调整动画曲线、时长,或者扩展支持更多共享元素。希望本文能帮助你打造更精致的鸿蒙应用体验。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐