请添加图片描述

前言

在上一期的实战中,我们掌握了全场景自适应的响应式栅格布局。今天,我们将迎来 ArkUI 动画系统的“高光时刻”——详情页交错入场与平滑退场动画

在现代高端 App 的交互设计中,页面切换早已不再是生硬的“闪现”。通过精心编排的动画,让图片、标题、正文等元素按照特定的时间顺序依次浮现,能够极大地提升应用的精致感和沉浸感。

这个实战案例虽然代码精炼,但完美诠释了 ArkTS 动画系统的精髓,涵盖了以下核心知识点:

  • 生命周期与路由传参:在 aboutToAppear 中接收列表页传递的数据。
  • 交错动画编排:利用 setTimeout 配合 animateTo,实现多元素的阶梯式入场。
  • 状态驱动属性动画:通过绑定 scale(缩放)和 opacity(透明度),实现丝滑的视觉过渡。
  • 双向动画闭环:不仅实现了进入时的优雅展开,还完美复刻了退出时的平滑收缩。

下面,我们就对这段实现电影级质感的详情页代码进行一次深度解析。


完整代码结构预览

首先,让我们从整体上把握代码结构。它定义了一个 Detail 入口组件,核心是接收参数、编排入场动画、渲染详情页 UI 以及处理退出动画。

import router from '@ohos.router'

interface CardData { ... }

@Entry
@Component
struct Detail {
  // 1. 数据与动画状态定义
  @State cardData: CardData = { ... }
  @State imageScale: number = 0.8; @State imageOpacity: number = 0
  @State titleScale: number = 0.8; @State titleOpacity: number = 0
  @State contentOpacity: number = 0

  // 2. 生命周期与动画逻辑
  aboutToAppear() { ... }
  private startEnterAnimation(): void { ... }
  private startExitAnimation(): void { ... }

  // 3. 页面主体与自定义构建
  build() { ... }
  @Builder InfoRow(label: string, value: string) { ... }
}

第一部分:数据接收与状态初始化

详情页的动画效果完全由几个 @State 变量驱动。在页面加载之初,我们需要先接收列表页传来的数据。

@State cardData: CardData = { id: '', title: '', subtitle: '', color: '#667EEA', image: '' }
@State imageScale: number = 0.8; @State imageOpacity: number = 0
@State titleScale: number = 0.8; @State titleOpacity: number = 0
@State contentOpacity: number = 0

aboutToAppear() {
  const params = router.getParams() as Record<string, string>
  if (params.cardData) {
    this.cardData = JSON.parse(params.cardData)
    setTimeout(() => {
      this.startEnterAnimation()
    }, 50)
  }
}
  • @State 动画变量:我们将图片、标题、正文的缩放和透明度分别定义为独立的状态变量。初始状态下,图片和标题缩小为 0.8 且完全透明(0),正文也处于透明状态。这为后续的“从无到有”动画做好了铺垫。
  • aboutToAppear 生命周期:这是 ArkUI 组件即将出现时触发的生命周期函数。我们在这里通过 router.getParams() 获取列表页传递过来的卡片数据(通过 JSON.parse 反序列化)。
  • 延迟触发动画:在获取数据后,我们使用了 setTimeout(..., 50) 延迟 50 毫秒再执行 startEnterAnimation()。这是一个非常实用的技巧,它能确保页面 UI 已经完成了初次渲染,再开始执行动画,避免动画在页面加载瞬间被“吞掉”。

第二部分:阶梯式入场动画编排 (startEnterAnimation)

这是整个详情页交互的灵魂。为了让用户的视觉焦点能够自然地从图片过渡到文字,我们采用了“阶梯式”的动画编排。

private startEnterAnimation(): void {
  // 1. 图片率先浮现
  animateTo({ duration: 400, curve: Curve.Friction }, () => {
    this.imageScale = 1; this.imageOpacity = 1;
  })

  // 2. 100ms 后,标题开始浮现
  setTimeout(() => {
    animateTo({ duration: 300, curve: Curve.Friction }, () => {
      this.titleScale = 1; this.titleOpacity = 1;
    })
  }, 100)

  // 3. 250ms 后,正文内容开始浮现
  setTimeout(() => {
    animateTo({ duration: 300, curve: Curve.Friction }, () => {
      this.contentOpacity = 1;
    })
  }, 250)
}
  • animateTo 显式动画:这是 ArkTS 中实现属性动画的核心 API。我们将状态变量的变化包裹在它的回调中,系统会自动补间生成平滑的动画。
  • Curve.Friction 摩擦曲线:我们选用了摩擦曲线,这种曲线自带自然的物理减速效果,比线性的动画看起来更加高级和舒适。
  • setTimeout 制造时间差
    • 图片作为视觉重心,最先在 400ms 内从 0.8 倍放大至 1 倍并显现。
    • 标题延迟 100ms 启动,与图片形成微小的错落感。
    • 正文内容延迟 250ms 启动,最后缓缓浮现。这种层层递进的效果,极大地缓解了用户等待页面加载的枯燥感。

第三部分:动态 UI 渲染与样式绑定

build 函数中,我们将动画状态变量精确地绑定到了各个 UI 组件的属性上。

// 图片区域
Image(this.cardData.image)
  .width('100%').height(400).objectFit(ImageFit.Cover)
  .scale({ x: this.imageScale, y: this.imageScale })
  .opacity(this.imageOpacity)

// 标题区域
Text(this.cardData.title)
  .fontSize(40).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
  .scale({ x: this.titleScale, y: this.titleScale })
  .opacity(this.titleOpacity)

// 正文与按钮区域
Text(this.cardData.subtitle).opacity(0.9 * this.contentOpacity)
Button('立即体验')
  .scale({ x: this.contentOpacity > 0 ? 1 : 0.8, y: ... })
  .opacity(this.contentOpacity)
  • 动态缩放与透明度ImageText 直接绑定了各自的 scaleopacity 状态。
  • 整体淡入效果:副标题、信息行(InfoRow)和底部按钮共享 contentOpacity 状态。
  • 按钮的微动画:底部的“立即体验”按钮不仅跟随 contentOpacity 改变透明度,还通过三元运算符 this.contentOpacity > 0 ? 1 : 0.8 绑定了缩放。这意味着在正文淡入之前,按钮会保持在 0.8 倍的隐藏状态,随正文一起平滑放大至 1 倍。

第四部分:平滑退场动画 (startExitAnimation)

一个优秀的交互体验不仅要有华丽的入场,还要有得体的退场。当用户点击左上角的返回按钮时,我们需要执行与入场相反的动画。

Button() { Text('←') ... }
.onClick(() => { this.startExitAnimation() })

private startExitAnimation(): void {
  animateTo({
    duration: 300,
    curve: Curve.Friction,
    onFinish: () => { router.back() } // 动画结束后再执行路由返回
  }, () => {
    // 所有元素同时缩小并淡出
    this.imageScale = 0.8; this.imageOpacity = 0;
    this.titleScale = 0.8; this.titleOpacity = 0;
    this.contentOpacity = 0;
  })
}
  • onFinish 回调的妙用:在退出动画中,我们绝对不能在点击按钮时直接调用 router.back(),否则页面会瞬间销毁,动画根本来不及播放。正确的做法是将 router.back() 放在 animateToonFinish 回调中,确保 300ms 的收缩淡出动画完整播放完毕后,再销毁当前页面。
  • 同步收缩:与入场时的“阶梯式”不同,退场时我们将所有元素的缩放和透明度同时还原到初始值,营造出一种“页面整体收缩回列表”的连贯视觉体验。

️ 第五部分:自定义信息行组件 (@Builder)

为了保持代码的整洁,详情页底部的元数据(日期、分类、阅读量)被封装成了一个独立的 @Builder 函数。

@Builder InfoRow(label: string, value: string) {
  Row() {
    Text(label).fontSize(16).fontColor('#FFFFFF').opacity(0.6)
    Blank() // 弹性空白,将左右文字推到两端
    Text(value).fontSize(16).fontColor('#FFFFFF').fontWeight(FontWeight.Medium)
  }
  .width('100%')
  .padding({ top: 8, bottom: 8 })
  .borderWidth({ bottom: 1 })
  .borderColor('rgba(255,255,255,0.1)')
}
  • Blank() 组件:在 Row 布局中,Blank() 会自动填充左右两个 Text 之间的剩余空间,轻松实现“左侧标签、右侧数值”的两端对齐效果。
  • 半透明边框:通过 rgba(255,255,255,0.1) 设置极淡的白色底边框,在深色背景下增加了界面的精致层次感。

完整代码

import router from '@ohos.router'

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



struct Detail {
   cardData: CardData = { id: '', title: '', subtitle: '', color: '#667EEA', image: '' }
   isLoaded: boolean = false
   imageScale: number = 0.8
   imageOpacity: number = 0
   titleScale: number = 0.8
   titleOpacity: number = 0
   contentOpacity: number = 0

  aboutToAppear() {
    const params = router.getParams() as Record<string, string>
    if (params.cardData) {
      this.cardData = JSON.parse(params.cardData)
      setTimeout(() => {
        this.startEnterAnimation()
      }, 50)
    }
  }

  private startEnterAnimation(): void {
    animateTo({
      duration: 400,
      curve: Curve.Friction
    }, () => {
      this.imageScale = 1
      this.imageOpacity = 1
    })

    setTimeout(() => {
      animateTo({
        duration: 300,
        curve: Curve.Friction
      }, () => {
        this.titleScale = 1
        this.titleOpacity = 1
      })
    }, 100)

    setTimeout(() => {
      animateTo({
        duration: 300,
        curve: Curve.Friction
      }, () => {
        this.contentOpacity = 1
      })
    }, 250)
  }

  build() {
    Column() {
      Stack({ alignContent: Alignment.TopStart }) {
        Column()
          .width('100%')
          .height('100%')
          .backgroundColor(this.cardData.color)

        Button() {
          Text('←')
            .fontSize(28)
            .fontColor('#FFFFFF')
        }
        .type(ButtonType.Circle)
        .width(50)
        .height(50)
        .backgroundColor('rgba(255,255,255,0.2)')
        .margin({ top: 50, left: 20 })
        .onClick(() => {
          this.startExitAnimation()
        })

        Column() {
          Image(this.cardData.image)
            .width('100%')
            .height(400)
            .objectFit(ImageFit.Cover)
            .scale({ x: this.imageScale, y: this.imageScale })
            .opacity(this.imageOpacity)

          Column({ space: 16 }) {
            Text(this.cardData.title)
              .fontSize(40)
              .fontWeight(FontWeight.Bold)
              .fontColor('#FFFFFF')
              .scale({ x: this.titleScale, y: this.titleScale })
              .opacity(this.titleOpacity)

            Text(this.cardData.subtitle)
              .fontSize(20)
              .fontColor('#FFFFFF')
              .opacity(0.9 * this.contentOpacity)

            Text('这是一篇关于' + this.cardData.title + '的详细内容。在这里,您可以深入了解更多精彩信息,探索未知的领域,发现更多有趣的故事和知识。')
              .fontSize(18)
              .fontColor('#FFFFFF')
              .opacity(0.8 * this.contentOpacity)
              .textAlign(TextAlign.JUSTIFY)
              .lineHeight(32)

            Column({ space: 12 }) {
              this.InfoRow('日期', '2026年7月5日')
              this.InfoRow('分类', '精选推荐')
              this.InfoRow('阅读', '1.2万次')
            }
            .margin({ top: 20 })
            .opacity(this.contentOpacity)

            Button() {
              Text('立即体验')
                .fontSize(18)
                .fontColor(this.cardData.color)
                .fontWeight(FontWeight.Bold)
            }
            .width('100%')
            .height(56)
            .backgroundColor('#FFFFFF')
            .borderRadius(28)
            .margin({ top: 30 })
            .scale({ x: this.contentOpacity > 0 ? 1 : 0.8, y: this.contentOpacity > 0 ? 1 : 0.8 })
            .opacity(this.contentOpacity)
          }
          .width('100%')
          .padding(30)
        }
      }
      .width('100%')
      .height('100%')
    }
    .width('100%')
    .height('100%')
    .backgroundColor(this.cardData.color)
  }

  private startExitAnimation(): void {
    animateTo({
      duration: 300,
      curve: Curve.Friction,
      onFinish: () => {
        router.back()
      }
    }, () => {
      this.imageScale = 0.8
      this.imageOpacity = 0
      this.titleScale = 0.8
      this.titleOpacity = 0
      this.contentOpacity = 0
    })
  }

   InfoRow(label: string, value: string) {
    Row() {
      Text(label)
        .fontSize(16)
        .fontColor('#FFFFFF')
        .opacity(0.6)

      Blank()

      Text(value)
        .fontSize(16)
        .fontColor('#FFFFFF')
        .fontWeight(FontWeight.Medium)
    }
    .width('100%')
    .padding({ top: 8, bottom: 8 })
    .borderWidth({ bottom: 1 })
    .borderColor('rgba(255,255,255,0.1)')
  }
}

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

总结与实战建议

通过这个详情页交错入场动画的实战,我们掌握了以下 ArkTS 高阶动画技能:

  1. 动画编排思维:学会了如何使用 setTimeout 错开多个 animateTo 的执行时间,创造出富有节奏感的阶梯式动画。
  2. 生命周期结合动画:掌握了在 aboutToAppear 中延迟触发动画的技巧,确保 UI 渲染与动画执行的完美同步。
  3. 路由与动画的协同:深刻理解了在页面退出时,必须将 router.back() 放入 onFinish 回调,这是实现平滑转场的关键细节。
  4. 精细化状态绑定:能够根据业务需求,将不同的 UI 元素绑定到独立的或共享的动画状态变量上,实现复杂的组合动画效果。

希望这篇详细的代码解析能帮你彻底掌握鸿蒙 ArkTS 的动画编排技巧!如果你觉得有用,欢迎点赞、收藏,我们下期再见!

Logo

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

更多推荐