《HarmonyOS技术精讲-UI开发》第8篇:动画进阶实战

一个常见的动画误区

HarmonyOS NEXT 开发里,动画相关 API 经常被用错。很多人第一次接触 animateToTransition 时,发现官方示例能运行,但放到 List 组件或者多页面场景下,动画效果要么不生效,要么出现明显的闪烁。

这个问题在社区里反复出现。根本原因不在于 API 本身复杂,而在于很多人没有搞清楚属性动画显式动画的使用边界,以及页面转场时状态同步的时机。

这篇文章直接解决三个核心问题:卡片的点击缩放、列表项的删除渐隐、页面滑动转场。每个功能对应一个动画机制,代码完整可运行。

动画机制解决的场景

动画类型 核心 API 适用场景 不适用场景
属性动画 animateTo 组件属性的平滑变化(宽高、位置、透明度) 组件出现/消失的过渡效果
显式动画 Transition 组件挂载/卸载时的出场进场效果 属性值的连续变化
页面转场 pageTransition 页面间的跳转动画 组件内部的状态变化

实际开发中,这三个 API 经常需要配合使用,但很多人把它们混为一谈。

环境说明

text 复制代码
DevEco Studio 版本:DevEco Studio 6.1.0 及以上
HarmonyOS SDK 版本:HarmonyOS 6.1.0(23) 及以上
目标设备:手机

完整功能实现

下面实现一个交互卡片列表。点击卡片缩放显示详情,滑动删除卡片后渐隐消失,点击跳转后页面滑动进入。

1. 卡片组件:属性动画控制点击缩放

这一段代码用于实现卡片的点击缩放效果。核心思路是通过 @State 管理缩放状态,调用 animateTo 触发动画。

typescript 复制代码
// CardItem.ets
@Component
export struct CardItem {
  @State private scaleValue: number = 1.0;

  build() {
    Column() {
      Text('可交互卡片')
        .fontSize(20)
        .fontWeight(FontWeight.Bold)
      Text('点击此处触发缩放动画')
        .fontSize(14)
        .margin({ top: 8 })
    }
    .width('100%')
    .height(120)
    .backgroundColor(Color.White)
    .borderRadius(12)
    .shadow({ radius: 8, color: '#33000000' })
    .scale({ x: this.scaleValue, y: this.scaleValue })
    .onClick(() => {
      // 关键点:animateTo 必须包裹在事件回调里
      animateTo({ duration: 300, curve: Curve.FastOutSlowIn }, () => {
        this.scaleValue = this.scaleValue === 1.0 ? 0.95 : 1.0;
      });
    })
  }
}

注意事项:

  • animateTo 只能在事件回调中调用,不能放在 build()aboutToAppear()
  • scaleValue 状态变化时,ArkUI 会自动触发重绘,动画由 animateTo 管理
  • 如果不调用 animateTo 而是直接修改 scaleValue,组件会直接跳变

2. 列表页面:Transition 控制删除渐隐

这一段代码用于实现列表项的删除动画。Transition 需要配合 if 条件渲染才能生效。

typescript 复制代码
// CardListPage.ets
import { CardItem } from './CardItem';

interface ListData {
  id: number;
  visible: boolean;
}

@Entry
@Component
struct CardListPage {
  @State private items: ListData[] = [];
  @State private count: number = 0;

  aboutToAppear(): void {
    // 初始化列表数据
    this.items = [
      { id: 1, visible: true },
      { id: 2, visible: true },
      { id: 3, visible: true },
    ];
    this.count = this.items.length;
  }

  build() {
    Column() {
      // 操作按钮区
      Button('添加卡片')
        .onClick(() => {
          this.count++;
          this.items.push({ id: this.count, visible: true });
        })
        .margin({ bottom: 16 })

      List() {
        ForEach(this.items, (item: ListData) => {
          ListItem() {
            if (item.visible) {
              CardItem()
                // Transition 必须直接作用于条件渲染的元素
                .transition(TransitionEffect.opacity(1.0))
                .swipeAction({ end: this.createDeleteAction(item) })
            }
          }
        }, (item: ListData) => item.id.toString())
      }
      .width('100%')
      .layoutWeight(1)
    }
    .padding(16)
    .width('100%')
    .height('100%')
  }

  createDeleteAction(item: ListData): SwipeActionItem {
    return {
      builder: () => {
        Button('删除')
          .backgroundColor(Color.Red)
          .fontColor(Color.White)
          .onClick(() => {
            // 先隐藏,Transition 会触发淡出动画
            item.visible = false;
            setTimeout(() => {
              // 动画完成后移除数据
              const index = this.items.indexOf(item);
              if (index > -1) {
                this.items.splice(index, 1);
              }
            }, 300); // 与 transition 动画时长保持一致
          })
      }
    }
  }
}

关键点:

  • Transition 必须配合 if 条件渲染。如果使用 .visibility() 属性来控制显隐,Transition 不会生效
  • 删除流程需要两步:先设置 visible = false 触发淡出动画,再在动画完成后移除数据
  • 动画时长必须和 TransitionEffect 中的参数保持一致,否则会出现视觉断层

3. 页面转场:滑动切换效果

这一段代码用于实现页面间的滑动跳转。pageTransition 需要在两个页面都配置才能平滑过渡。

typescript 复制代码
// DetailPage.ets
@Entry
@Component
struct DetailPage {
  @State private message: string = '详情页';

  build() {
    Column() {
      Text(this.message)
        .fontSize(24)
      Button('返回')
        .onClick(() => {
          // 返回上一页时自动触发 pageTransition
          router.back();
        })
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .pageTransition() // 子页面必须配置 pageTransition
  }
}

主页面配置滑动转场:

typescript 复制代码
// 在 CardListPage 中添加页面转场配置
build() {
  Column() {
    // 跳转按钮
    Button('跳转详情页')
      .onClick(() => {
        router.pushUrl({ url: 'pages/DetailPage' });
      })

    // 列表部分...
  }
  .width('100%')
  .height('100%')
  .pageTransition(
    // 入场:从右侧滑入
    TransitionEffect.slide(SlideEffect.Right),
    // 出场:向左侧滑出
    TransitionEffect.slide(SlideEffect.Left)
  )
}

注意事项:

  • pageTransition 需要主页面和子页面都配置,否则转场动画不完整
  • 页面返回时,子页面和主页面的出场动画都会执行
  • SlideEffect.Right 表示从右侧进入,SlideEffect.Left 表示从左侧退出

完整入口文件

typescript 复制代码
// Index.ets
import { CardListPage } from './CardListPage';

@Entry
@Component
struct Index {
  build() {
    Column() {
      CardListPage()
    }
    .width('100%')
    .height('100%')
  }
}

常见问题与解决方案

问题 1:Transition 不生效

现象: 设置了 .transition() 但组件出现或消失时没有动画效果。

原因: Transition 只能在 if 条件渲染中生效。使用 .visibility(Visibility.None).opacity(0) 控制显隐时,Transition 不会触发。

解决方案: 改用 ifForEach 的增删来控制组件显隐。

typescript 复制代码
// 错误写法
Text('示例')
  .visibility(this.isVisible ? Visibility.Visible : Visibility.None)
  .transition(TransitionEffect.opacity(1.0)) // 不生效

// 正确写法
if (this.isVisible) {
  Text('示例')
    .transition(TransitionEffect.opacity(1.0)) // 生效
}

问题 2:页面转场动画闪白

现象: 页面跳转时,目标页面先显示白色背景,再出现转场动画。

原因: 目标页面在转场动画开始前已经完成渲染,页面背景色覆盖了动画效果。

解决方案: 给目标页面的根容器设置透明背景。

typescript 复制代码
// DetailPage.ets
build() {
  Column() {
    // 页面内容...
  }
  .width('100%')
  .height('100%')
  .backgroundColor(Color.Transparent) // 关键:透明背景
  .pageTransition()
}

问题 3:多次快速点击导致动画冲突

现象: 快速点击卡片时,动画出现多次缩放叠加,最终缩放比例不正确。

原因: animateTo 在执行过程中被多次触发,状态没有同步。

解决方案: 使用标志位控制动画执行。

typescript 复制代码
@Component
export struct CardItem {
  @State private scaleValue: number = 1.0;
  private isAnimating: boolean = false;

  build() {
    Column() {
      // 内容省略
    }
    .scale({ x: this.scaleValue, y: this.scaleValue })
    .onClick(() => {
      if (this.isAnimating) return;
      this.isAnimating = true;
      animateTo({ duration: 300 }, () => {
        this.scaleValue = this.scaleValue === 1.0 ? 0.95 : 1.0;
      });
      setTimeout(() => {
        this.isAnimating = false;
      }, 300);
    })
  }
}

最佳实践

1. 动画时长保持一致性

同一个交互流程中的动画时长建议保持一致。比如删除动画的 Transition 时长和 setTimeout 的延时必须匹配,否则会出现组件闪烁或数据残留。

2. 避免在 build() 中创建动画状态

不要在 build()aboutToAppear() 中直接调用 animateTo。动画触发必须放在用户交互事件的回调中,否则 ArkUI 会无法正确分配动画的起点和终点。

3. 页面转场尽量使用系统提供的过渡效果

pageTransition 提供了 SlideEffect.RightSlideEffect.LeftSlideEffect.TopSlideEffect.Bottom 四种效果。如果需要自定义复杂的转场动画,建议先使用系统效果,测试通过后再替换,减少排查时间。

FAQ

Q:为什么模拟器上转场动画正常,真机上出现卡顿?

A:模拟器的渲染性能优于部分低端真机。真机上的卡顿通常是因为动画时长过短(小于 200ms)或者页面内容过于复杂。建议将动画时长控制在 250-400ms 之间,同时减少页面初始渲染的组件数量。

Q:页面返回后,列表的状态为什么会丢失?

A:HarmonyOS NEXT 中,页面离开后会被销毁,状态全部释放。如果需要保留列表状态,建议使用 AppStoragePersistentStorage 将关键数据持久化,页面返回后重新读取。

Q:为什么 animateToForEach 中偶尔不生效?

A:ForEach 的 key 生成规则可能会影响动画效果。如果 key 值重复或生成不稳定,ArkUI 会误判组件是新建而非更新,导致动画丢失。建议使用唯一且稳定的 key,例如后台返回的 id 字段。

Q:Transition 和 animateTo 可以同时使用吗?

A:可以,但需要注意优先级。如果同一个组件同时配置了 TransitionanimateToTransition 的优先级更高。如果需要精细控制,建议分别测试两种方案,选择最适合场景的方式。

如果你在实际开发中遇到类似的动画问题,可以重点检查生命周期和状态同步逻辑。官方文档对这个行为描述得比较简单,建议结合实际运行结果一起验证。不同设备上的行为可能存在差异,建议真机测试。