请添加图片描述

前言

在上一期的音乐播放器实战中,我们掌握了复杂的页面布局。今天,我们将挑战一个更高级的交互效果——共享元素转场动画

这个效果在现代 App 中非常常见:当你点击列表中的某个卡片时,它会以一个流畅的动画“变形”并过渡到下一个页面的对应元素上,为用户带来无缝、连贯的视觉体验。

这个项目虽然代码量不大,但含金量极高,涵盖了以下核心知识点:

  • 页面路由传参:使用 router.pushUrl 在页面间传递复杂数据。
  • 显式动画:利用 animateTo 实现点击后的退出动画。
  • 状态驱动 UI:通过 @State 变量精确控制单个组件的动画状态。
  • 动态样式绑定:根据状态动态改变组件的 scale(缩放)和 opacity(透明度)。

下面,我们就对这段实现丝滑动画的代码进行一次深度解析。


📜 完整代码结构预览

首先,让我们从整体上把握代码结构。它定义了一个 Index 入口组件,核心是列表的渲染、点击事件的处理以及 CardItem 的自定义构建。

import router from '@ohos.router'

interface CardData {
  id: string
  title: string
  subtitle: string
  color: string
  image: string
}

@Entry
@Component
struct Index {
  // 1. 数据与状态定义
  @State cardList: CardData[] = [...]
  @State exitCardId: string = ''
  @State exitCardScale: number = 1
  @State exitCardOpacity: number = 1

  // 2. 页面主体构建
  build() { ... }

  // 3. 动画与路由逻辑
  private startExitAnimation(card: CardData): void { ... }

  // 4. 自定义卡片组件
  @Builder CardItem(card: CardData) { ... }
}

🧩 第一部分:数据模型与状态管理

代码的开头定义了一个 CardData 接口和几个关键的 @State 变量,它们是驱动整个动画的核心。

interface CardData {
  id: string
  title: string
  subtitle: string
  color: string
  image: string
}

@State cardList: CardData[] = [
  { id: '1', title: '探索之旅', subtitle: '开启一段奇妙的冒险', color: '#667EEA', image: '...' },
  // ... 其他卡片数据
]

@State exitCardId: string = ''
@State exitCardScale: number = 1
@State exitCardOpacity: number = 1
  • CardData 接口:定义了卡片的数据结构,包括唯一的 id、标题、副标题、背景色和图片链接。
  • cardList:一个 CardData 类型的数组,作为列表的数据源。这里的图片链接指向一个可以根据文本描述生成图片的 API,非常巧妙。
  • exitCardId:这是动画的“开关”。它存储了当前正在执行退出动画的卡片的 id。通过判断其他卡片的 id 是否与它相等,来决定是否应用动画效果。
  • exitCardScale / exitCardOpacity:这两个变量分别控制动画过程中的缩放比例和透明度。它们的值会在动画执行时被改变,从而驱动 UI 变化。

🛠️ 第二部分:自定义卡片组件 (@Builder)

@Builder 装饰器将卡片的 UI 封装成一个独立的函数 CardItem,使 build 方法更加简洁。

@Builder
CardItem(card: CardData) {
  Stack({ alignContent: Alignment.Center }) {
    // 背景卡片
    Column()
      .width('100%')
      .height(180)
      .backgroundColor(card.color)
      .borderRadius(20)

    // 内容区域
    Row({ space: 16 }) {
      Image(card.image)
        .width(120)
        .height(140)
        .borderRadius(16)
        .objectFit(ImageFit.Cover)
        // 动态绑定缩放和透明度
        .scale({ x: this.exitCardId === card.id ? this.exitCardScale : 1, y: this.exitCardId === card.id ? this.exitCardScale : 1 })
        .opacity(this.exitCardId === card.id ? this.exitCardOpacity : 1)

      Column({ space: 8 }) {
        Text(card.title)
          .fontSize(24)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FFFFFF')
          // 标题也绑定动画
          .scale({ x: this.exitCardId === card.id ? this.exitCardScale : 1, y: this.exitCardId === card.id ? this.exitCardScale : 1 })
          .opacity(this.exitCardId === card.id ? this.exitCardOpacity : 1)
        // ... 其他文本
      }
      .layoutWeight(1)
    }
    .width('100')
    .padding(20)
  }
  .width('100%')
  .height(180)
  .shadow({ radius: 15, color: card.color + '40', offsetX: 0, offsetY: 8 })
  // 整个卡片容器也绑定动画,实现整体缩小
  .scale({ x: this.exitCardId === card.id ? this.exitCardScale : 1, y: this.exitCardId === card.id ? this.exitCardScale : 1 })
  .opacity(this.exitCardId === card.id ? this.exitCardOpacity : 1)
}
  • 布局结构:使用 Stack 作为容器,内部是一个带圆角和阴影的 Column 作为卡片背景,再上面是一个 Row 来水平排列图片和文字信息。
  • 动态样式绑定:这是实现动画的关键!
    • .scale(...).opacity(...) 修饰符的值不再是固定的,而是通过三元运算符 this.exitCardId === card.id ? ... : ... 进行动态判断。
    • 只有当卡片的 idexitCardId 匹配时,才会应用 exitCardScaleexitCardOpacity 的值,否则保持默认值(缩放为1,透明度为1)。这确保了动画只作用于被点击的那一个卡片。
  • 阴影效果.shadow() 修饰符为卡片添加了柔和的阴影,card.color + '40' 的写法巧妙地根据卡片背景色生成了半透明的阴影颜色,提升了视觉质感。

✨ 第三部分:动画与路由逻辑 (animateTo + router)

这是整个交互的灵魂所在。startExitAnimation 函数处理了从点击到页面跳转的全过程。

private startExitAnimation(card: CardData): void {
  this.exitCardId = card.id // 1. 标记当前要动画的卡片

  // 2. 执行显式动画
  animateTo({
    duration: 300,
    curve: Curve.Friction,
    onFinish: () => {
      // 3. 动画结束后,进行页面跳转
      router.pushUrl({
        url: 'pages/Detail',
        params: { cardData: JSON.stringify(card) }
      })
      // 4. 跳转后,重置状态,为下一次动画做准备
      setTimeout(() => {
        this.exitCardId = ''
        this.exitCardScale = 1
        this.exitCardOpacity = 1
      }, 100)
    }
  }, () => {
    // 5. 动画内容:改变状态变量的值
    this.exitCardScale = 0.8
    this.exitCardOpacity = 0
  })
}
  • this.exitCardId = card.id:首先,记录下被点击卡片的 id。这个操作会触发 UI 的重新渲染,CardItem 中的动态样式绑定会检测到这个变化。
  • animateTo:这是鸿蒙提供的显式动画 API。它会将第二个回调函数中状态变量的变化,以动画的形式呈现出来。
    • duration: 300:动画持续时间为 300 毫秒。
    • curve: Curve.Friction:使用摩擦曲线,让动画有自然的减速感。
    • 在动画回调中,我们将 exitCardScale 改为 0.8exitCardOpacity 改为 0。这会驱动被选中的卡片在 300ms 内缩小到 80% 并淡出。
  • onFinish:动画结束后的回调。
    • router.pushUrl:使用路由跳转到详情页。关键点在于 params,我们将整个 card 对象通过 JSON.stringify 序列化成字符串后传递过去。详情页可以通过 router.getParams() 获取并反序列化,从而实现数据的传递。
    • setTimeout:在跳转后,使用一个短暂的延时来重置所有状态变量。这是为了在用户从详情页返回时,列表能恢复到初始状态,避免动画错乱。

📝 第四部分:页面主体构建 (build)

build 函数负责搭建页面的基本骨架,结构非常清晰。

build() {
  Column() {
    Text('共享元素动效')
      .fontSize(32)
      .fontWeight(FontWeight.Bold)
      .fontColor('#FFFFFF')
      .margin({ top: 60, bottom: 30 })

    List({ space: 20 }) {
      ForEach(this.cardList, (card: CardData) => {
        ListItem() {
          this.CardItem(card)
        }
        .onClick(() => {
          this.startExitAnimation(card) // 绑定点击事件
        })
      })
    }
    .width('100%')
    .padding({ left: 20, right: 20 })
    .layoutWeight(1)
  }
  .width('100%')
  .height('100%')
  .backgroundColor('#1A1A2E')
}
  • ListForEach:使用 List 组件来高效地渲染长列表,并通过 ForEach 循环 cardList 数据源,为每一项生成一个 CardItem
  • .onClick:为每个 ListItem 绑定点击事件,触发我们之前定义的 startExitAnimation 函数,并将当前卡片的数据 card 作为参数传入。

🎨 第五部分:视觉设计与色彩搭配

整个页面采用了深邃的暗色背景,与卡片鲜艳的色彩形成强烈对比,突出了内容本身。

视觉区域 颜色代码 设计意图
整体背景 #1A1A2E 深蓝色背景,营造沉静、专注的氛围,让卡片更突出。
卡片背景 #667EEA, #F093FB 每张卡片使用不同的渐变色或亮色,充满活力,区分不同主题。
文字颜色 #FFFFFF 白色文字在深色背景上保证了极佳的可读性。
阴影颜色 card.color + '40' 动态生成与卡片同色系的半透明阴影,细节感满满。

📸 页面展示

在这里插入图片描述
在这里插入图片描述

  • 列表页:展示四个不同主题的卡片,布局清晰,色彩鲜明。
  • 点击动画:点击任一卡片,该卡片会平滑地缩小并淡出,随后跳转到详情页。

📌 总结与实战建议

通过这个共享元素转场动画的实战,我们掌握了以下 ArkTS 高阶技能:

  1. 显式动画 animateTo:学会了如何使用 animateTo 包裹状态变化,轻松实现复杂的属性动画。
  2. 精细化状态控制:通过一个 id 状态变量,精确地控制列表中单个元素的样式和行为,这是处理列表交互的常用技巧。
  3. 页面间通信:掌握了使用 router.pushUrlparams 参数进行页面间数据传递的方法,特别是对象的序列化与反序列化。
  4. 动态样式绑定:深刻理解了如何将组件的样式属性(如 scale, opacity)与状态变量绑定,实现数据驱动 UI 的强大能力。
Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐