大家好,我是[晚风依旧似温柔],新人一枚,欢迎大家关注~

前言

商品列表进入详情页,如果只是直接切换页面,用户看到的通常是“旧页面消失,新页面出现”。页面本身没有问题,但视觉上缺少一层关系:刚刚点击的那张商品图,和详情页顶部的大图到底是不是同一个对象?

ArkUI 的 geometryTransition 正好适合处理这类场景。它通过相同 ID 关联切换前后的两个组件,让系统根据组件前后的位置、尺寸等几何信息建立过渡关系,从而形成常说的“共享元素”或“一镜到底”效果。

这次不做复杂商城页面,只用一个最小场景:商品列表中的缩略图,点击后放大成为详情态顶部大图;返回时,大图再回到原来的商品卡片位置。

需要先说明版本背景:华为官方升级适配资料明确,HarmonyOS 7.0 对应 API version 26.0.0,官方建议升级到对应开发套件进行适配。本文代码按当前 ArkUI 声明式开发方式组织,但没有在真实 HarmonyOS 工程中执行编译,因此实际接入项目后仍应使用目标 SDK 和设备进一步验证。

一、普通页面跳转为什么容易显得“断”

先看最普通的交互过程:

商品列表
  ↓ 点击卡片
列表页退出
  ↓
详情页进入

从路由角度,这个过程完全正确。但从视觉关系看,列表中的 120×120 商品缩略图和详情中的大图是两个独立组件。

如果只使用页面默认转场,用户感知到的是“换了一个页面”;如果让商品图片从卡片位置移动、放大到详情顶部,用户感知到的则更接近“刚才点击的商品被展开了”。

华为官方将共享元素转场描述为:界面切换时,对相同或相似元素进行位置和大小匹配的过渡动画。官方关于动画感知流畅度的资料也将“一镜到底”单独归入共享元素转场这一类。

这就是本文真正要解决的问题:不是给页面额外加一个炫技动画,而是保持列表元素和详情元素之间的视觉上下文。

【建议插图1:普通列表→详情直接切换,与商品图片一镜到底转场的效果对比】

二、先把 geometryTransition 的边界弄清楚

ArkUI 当前仍提供 geometryTransition,官方 API 目录将它归类为“组件内隐式共享元素转场”。

它最核心的使用方式很简单:

.geometryTransition('product-image-1001')

在两个参与视图切换的组件上设置相同 ID,系统便可以建立对应关系。

这里有几个比代码本身更重要的规则。

1. 它解决的是两个组件之间的几何连续性

一镜到底的重点不是手工计算:

x 从多少移动到多少
y 从多少移动到多少
width 从多少变成多少
height 从多少变成多少

而是分别描述切换前后的布局,让 ArkUI 建立两者之间的联系。华为官方 Native ArkUI 动画资料对相同机制的解释也很明确:为两个组件绑定同一 ID,在移除一个组件并添加另一个组件时,系统为二者切换建立一镜到底效果。

因此列表图可以是小图:

.width(112)
.height(112)

详情图可以变成:

.width('100%')
.height(360)

开发者负责描述两个状态,转场负责连接两个状态。

2. 状态切换要放进显式动画

geometryTransition 需要和状态变化产生的动画过程配合使用。当前官方示例推荐通过 UIContext.animateTo() 把状态修改放进动画闭包;官方 FAQ 中同样采用 this.getUIContext().animateTo(...) 触发状态变化。

也就是说,不建议只写:

this.detailVisible = true;

而是:

this.getUIContext().animateTo({
  duration: 360,
  curve: Curve.Friction
}, () => {
  this.detailVisible = true;
});

3. 这个场景不需要额外权限

本文只使用 ArkUI 的组件、状态管理和动画能力,不涉及相机、位置、网络、振动等受权限控制的系统能力,因此不需要为了 geometryTransition 单独增加 module.json5 权限。

同样也没有三方库依赖,不需要增加 OHPM 包。

三、搭一个最小商品列表实践

我们把页面控制在两个 UI 状态:

detailVisible = false
        │
        ▼
┌─────────────────┐
│ 商品 A  [小图]   │
│ 商品 B  [小图]   │
│ 商品 C  [小图]   │
└─────────────────┘
        │ 点击 B
        ▼
detailVisible = true
        │
        ▼
┌─────────────────┐
│    [商品 B 大图] │
│    商品名称       │
│    商品价格       │
└─────────────────┘

为了让示例聚焦 geometryTransition,这里先不引入网络图片、数据库和真实商城业务。

在 resources/base/media 中准备三张演示图片,例如:

product_phone.png
product_watch.png
product_headset.png

真正需要解决的只有三件事:

  1. 记住用户点击的是哪一个商品;
  2. 列表图和详情图生成完全相同的 geometry ID;
  3. 在 animateTo 中切换列表态和详情态。

【建议插图2:DevEco Studio 工程中 resources/base/media 下三张商品演示图片的位置】

四、核心代码:让商品图从卡片“飞”到详情

下面代码按照官方 geometryTransition、UIContext.animateTo 等接口组织为最小示例。资源名称需要替换成工程实际存在的图片。

interface ProductItem {
  id: string;
  name: string;
  price: string;
  image: Resource;
}

@Entry
@Component
struct ProductGeometryDemo {
  @State private detailVisible: boolean = false;
  @State private selectedIndex: number = 0;

  private products: ProductItem[] = [
    {
      id: '1001',
      name: 'Harmony Phone',
      price: '¥ 3999',
      image: $r('app.media.product_phone')
    },
    {
      id: '1002',
      name: 'Harmony Watch',
      price: '¥ 1299',
      image: $r('app.media.product_watch')
    },
    {
      id: '1003',
      name: 'Harmony Headset',
      price: '¥ 699',
      image: $r('app.media.product_headset')
    }
  ];

  private imageGeometryId(productId: string): string {
    return `product-image-${productId}`;
  }

  private openDetail(index: number): void {
    this.selectedIndex = index;

    this.getUIContext().animateTo({
      duration: 360,
      curve: Curve.Friction
    }, () => {
      this.detailVisible = true;
    });
  }

  private closeDetail(): void {
    this.getUIContext().animateTo({
      duration: 360,
      curve: Curve.Friction
    }, () => {
      this.detailVisible = false;
    });
  }

  build() {
    Stack() {
      if (!this.detailVisible) {
        List({ space: 12 }) {
          ForEach(this.products, (item: ProductItem, index: number) => {
            ListItem() {
              Row({ space: 16 }) {
                Image(item.image)
                  .width(112)
                  .height(112)
                  .objectFit(ImageFit.Cover)
                  .borderRadius(16)
                  .geometryTransition(this.imageGeometryId(item.id))
                  .transition(TransitionEffect.OPACITY)

                Column({ space: 8 }) {
                  Text(item.name)
                    .fontSize(18)
                    .fontWeight(FontWeight.Medium)

                  Text(item.price)
                    .fontSize(16)
                    .fontColor('#E84026')
                }
                .alignItems(HorizontalAlign.Start)
                .layoutWeight(1)
              }
              .width('100%')
              .padding(12)
              .backgroundColor(Color.White)
              .borderRadius(20)
              .onClick(() => {
                this.openDetail(index);
              })
            }
          }, (item: ProductItem) => item.id)
        }
        .width('100%')
        .height('100%')
        .padding(16)
        .backgroundColor('#F5F5F5')
      } else {
        Column() {
          Image(this.products[this.selectedIndex].image)
            .width('100%')
            .height(360)
            .objectFit(ImageFit.Cover)
            .geometryTransition(
              this.imageGeometryId(this.products[this.selectedIndex].id)
            )
            .transition(TransitionEffect.OPACITY)

          Column({ space: 12 }) {
            Text(this.products[this.selectedIndex].name)
              .fontSize(28)
              .fontWeight(FontWeight.Bold)

            Text(this.products[this.selectedIndex].price)
              .fontSize(22)
              .fontColor('#E84026')

            Text('这里可以继续放商品介绍、规格和操作区域。')
              .fontSize(15)
              .fontColor('#666666')

            Button('返回商品列表')
              .margin({ top: 20 })
              .onClick(() => {
                this.closeDetail();
              })
          }
          .width('100%')
          .alignItems(HorizontalAlign.Start)
          .padding(20)
        }
        .width('100%')
        .height('100%')
        .backgroundColor(Color.White)
      }
    }
    .width('100%')
    .height('100%')
  }
}

这段代码真正需要关注的不是列表样式,而是下面这一对调用:

// 列表缩略图
.geometryTransition(
  this.imageGeometryId(item.id)
)

以及:

// 详情大图
.geometryTransition(
  this.imageGeometryId(
    this.products[this.selectedIndex].id
  )
)

假设点击商品 1002,两边最终得到的都是:

product-image-1002

这才建立了共享元素的对应关系。

五、图片为什么能够一边移动、一边变大

列表图片的几何状态是:

.width(112)
.height(112)

详情图片则是:

.width('100%')
.height(360)

两者的位置也完全不同。

普通属性动画的思路往往是维护 width、height、translateX、translateY 等状态,然后逐项修改。

geometryTransition 的思路不同:开发者给出两个组件的最终布局,ArkUI 根据共享关系处理两个几何状态之间的过渡。

官方对一镜到底的定义正是针对“相同或者相似的两个元素”进行位置和大小匹配。

这也是它特别适合商品封面、文章头图、相册缩略图等场景的原因。

这里还有一个容易忽略的细节:

.transition(TransitionEffect.OPACITY)

参与切换的组件本身还存在“离场”和“入场”过程。给组件配置转场效果,可以让组件上下树过程和 geometryTransition 更自然地配合,而不是把所有工作都理解成 geometryTransition 自己完成。

【建议插图3:商品缩略图从列表卡片位置逐渐移动并放大到详情顶部的连续帧效果】

六、返回动画其实不需要再写一套

进入详情时:

false -> true

返回列表时:

true -> false

只要详情大图和列表中的原商品图片仍然使用同一个 ID,对应关系就是反向建立的。

所以返回按钮只是:

private closeDetail(): void {
  this.getUIContext().animateTo({
    duration: 360,
    curve: Curve.Friction
  }, () => {
    this.detailVisible = false;
  });
}

不需要另外计算大图应该缩小到哪个 x、y 坐标。

这也是共享元素方案很有价值的一点:布局仍然由 ArkUI 布局系统管理,业务代码不用额外保存列表卡片的屏幕坐标。

七、多个商品卡片,千万别共用一个固定 ID

如果列表只有一个商品,下面这种写法暂时看不出问题:

.geometryTransition('product-image')

一旦变成几十个商品,这个 ID 就失去了“配对”的意义。

官方一镜到底机制本质上依赖 ID 建立两个组件之间的对应关系,因此列表场景应该把稳定的业务标识拼入 ID。官方 Native ArkUI 对同类机制也明确描述为“为两个组件绑定同一 id”。

推荐:

private imageGeometryId(productId: string): string {
  return `product-image-${productId}`;
}

然后得到:

product-image-1001
product-image-1002
product-image-1003

这和 ForEach 的 key 思路很像,但两者解决的不是同一个问题:

ForEach(..., (item: ProductItem) => item.id)

负责帮助框架稳定识别列表数据对应的 UI 节点;

.geometryTransition(`product-image-${item.id}`)

负责标识共享元素转场中的组件对应关系。

实际项目中建议直接复用商品 ID、文章 ID 等稳定业务主键,不要用当前数组下标拼 geometry ID。排序、筛选之后,数组下标可能变化,而商品本身的身份并没有变化。

八、Navigation 页面里使用时,要先区分两件事

这一点很容易理解错。

geometryTransition 在官方 API 分类中叫做组件内隐式共享元素转场;而 Navigation 是页面路由体系,两者不能简单理解成“给两个 NavDestination 中的 Image 写相同 ID,就自动获得完整的跨页面导航转场”。

当前官方 Navigation 推荐使用 NavPathStack 管理页面跳转;从 API version 10 开始,相比早期 Navigation + NavRouter 方式,更推荐使用 NavPathStack。

对于真正的两个 NavDestination 页面,如果业务需要控制整个导航过程,Navigation 还提供了自定义导航转场能力。华为当前最佳实践中也通过 customNavContentTransition 配置 Navigation 的自定义页面转场。

因此可以把实现策略分成两层:

同一组件树里的“列表态 → 详情态”
        ↓
geometryTransition 很直接

真正的 NavDestination A → NavDestination B
        ↓
先处理 Navigation 路由/导航转场
        ↓
再设计共享元素如何参与整个转场

本文选择第一种方式作为最小可复现场景,原因就在这里:它能够把 geometryTransition 自身的机制讲清楚,而不会把 Navigation 生命周期、自定义导航转场、路由栈等另外一组问题混在一起。

如果项目本身已经采用 Navigation,不建议为了做动画退回旧的页面路由方案。应继续使用 Navigation/NavPathStack,再根据页面结构决定是一镜到底、Navigation 自定义转场,还是两者协同。

【建议插图4:同一组件树状态切换与 Navigation 跨 NavDestination 路由的结构区别示意图】

九、几个容易出现的理解偏差

ID 相同,不代表图片资源可以随便换

共享元素表达的是“这是同一个视觉对象的两个状态”。

商品 A 的缩略图如果错误地和商品 B 的详情图使用相同 geometry ID,即使代码层面建立了对应关系,视觉语义也是错误的。

所以应该同时保证:

业务对象一致
图片内容一致
geometryTransition ID 一致

不要把状态修改放在动画闭包外

下面这种写法只是直接修改状态:

this.detailVisible = true;

更合适的是:

this.getUIContext().animateTo({
  duration: 360
}, () => {
  this.detailVisible = true;
});

官方目前的 ArkUI 动画示例也是通过 UIContext.animateTo() 对闭包中的状态变化插入过渡动效。

不要为了一个共享元素手工维护四五个动画变量

如果需求本身就是“同一个元素从 A 布局变成 B 布局”,先判断 geometryTransition 是否能够表达,而不是一开始就维护:

imageX
imageY
imageWidth
imageHeight
imageScale

官方动画优化指南建议能够使用系统动画接口实现时优先采用系统提供的能力,同时建议动画参数相同时尽量合并状态变化,减少重复布局和绘制开销。

列表里的 ID 必须稳定

不要写:

`product-image-${index}`

更建议:

`product-image-${item.id}`

这是商品列表、搜索结果、瀑布流等动态数据场景尤其需要检查的一项。

十、实际项目中怎么排查

如果一镜到底没有达到预期,可以按下面顺序检查:

  1. 先看 SDK/API 版本。 HarmonyOS 7.0 对应 API 26.0.0,升级工程时应同时检查 compileSdkVersion、targetSdkVersion 和兼容版本配置,不要只看系统名称。
  2. 检查两个组件是不是同一个业务对象。 不要只检查图片长得像不像。
  3. 打印或直接核对 geometry ID。 列表端和详情端必须生成一致、稳定的 ID。
  4. 确认状态变化是否发生在 animateTo 闭包中。
  5. 确认切换前后的两个组件确实存在进入/退出关系。 如果只是修改一个始终存在组件的普通属性,不要机械套用共享元素思路。
  6. 检查组件层级。 外层 if/else、Stack、列表节点以及其他遮挡组件都会影响最终视觉表现。
  7. 最后再检查 Navigation。 如果已经跨越两个 NavDestination,问题就不再只是一个 geometry ID,需要把 Navigation 自身的路由和转场一起分析。

【建议插图5:点击商品前后打印 selectedIndex、productId、geometryTransition ID 的调试信息】

开发经验总结

geometryTransition 本身并不复杂,真正需要关注的是“两个状态之间有没有建立正确的共享关系”。

商品卡片进入详情这个场景可以归纳成四点:

第一,先确定共享的到底是什么。 对电商详情而言,通常商品主图最适合作为视觉锚点,不必把标题、价格、按钮全部做成共享元素。

第二,用业务 ID 建立稳定配对。 多卡片场景不要使用一个固定 geometry ID,更不要依赖数组下标。

第三,把 UI 状态变化交给 animateTo。 列表负责描述缩略图最终布局,详情负责描述大图最终布局,避免自己维护大量坐标动画。

第四,区分组件状态切换和 Navigation 页面路由。 geometryTransition 解决共享元素的空间连续性;Navigation/NavPathStack 解决页面栈和导航。页面结构复杂以后,这两个问题要分别设计。

从这个角度看,一镜到底并不是“让图片飞一下”。它真正解决的是:当界面结构发生明显变化时,让用户仍然知道自己正在看的内容从哪里来。

如果正在做商品、相册、文章流或媒体列表,可以检查一下现有详情页:列表里的视觉焦点到了详情页以后,是被直接替换掉了,还是仍然保留着清晰的上下文关系?

参考资料

  1. 《组件内隐式共享元素转场 (geometryTransition)》
    华为开发者联盟
    链接:华为 HarmonyOS geometryTransition API 参考

  2. 《Application Upgrade and Adaptation Guide — Upgrading to 26.0.0》
    HUAWEI Developers
    链接:HarmonyOS 7.0 / API 26.0.0 升级适配指南

  3. 《Navigation页面路由》
    华为开发者联盟
    链接:Navigation 页面路由官方文档

  4. 《优化动画性能》
    华为开发者联盟
    链接:ArkUI 动画性能优化官方指南

  5. 《感知流畅优化》
    华为开发者联盟
    链接:感知流畅优化官方指南

  6. 《动画概述》
    华为开发者联盟
    链接:ArkUI Native 动画概述

  7. 《视频类应用横竖屏切换》
    华为开发者联盟
    链接:Navigation 自定义转场相关官方最佳实践

如果觉得有帮助,别忘了点个赞+关注支持一下~
喜欢记得关注,别让好内容被埋没~

Logo

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

更多推荐