用 HarmonyOS6 PC 开发做过列表页面的朋友应该都有这种感觉——一个列表如果所有项同时"砰"地弹出来,看着就很廉价。但如果让每一项从侧面滑入,彼此之间有个几十毫秒的延迟,形成一种"鱼贯而入"的效果,整个页面的质感立刻就上去了。

这种交错入场动画(Staggered Animation)在 iOS 和 Android 上已经被用烂了,但在 HarmonyOS 生态里,很多人还不知道怎么做。

今天就把这个效果从头到尾讲清楚。核心思路其实不复杂:ForEach 渲染列表 + animateTo 控制每项的动画状态 + setTimeout 控制延迟

先看完整代码

@Entry
@Component
struct ListItemAnimationDemo {
  @State itemStates: number[] = [0, 0, 0, 0, 0, 0, 0, 0]
  @State hasAnimStarted: boolean = false

  build() {
    Column() {
      Text('列表项入场动画')
        .fontSize(18).fontWeight(FontWeight.Bold).margin({ bottom: 8 })

      Column() {
        ForEach([0, 1, 2, 3, 4, 5, 6, 7], (idx: number) => {
          Row() {
            Text(`${idx + 1}`)
              .width(36).height(36)
              .backgroundColor(this.getColor(idx % 8))
              .borderRadius(18).fontSize(14).fontColor('#FFFFFF')
              .textAlign(TextAlign.Center)

            Column() {
              Text(`列表项 ${idx + 1}`).fontSize(14).fontWeight(FontWeight.Medium)
              Text('滑动进入的动画效果').fontSize(11).fontColor('#999999')
            }
            .alignItems(HorizontalAlign.Start).margin({ left: 12 }).layoutWeight(1)
          }
          .width('100%').padding(12)
          .backgroundColor('#F8F9FA').borderRadius(8)
          .opacity(this.itemStates[idx])
          .translate({ x: (1 - this.itemStates[idx]) * 50 })
          .animation({ duration: 350, curve: Curve.EaseOut })
          .margin({ bottom: 6 })
        })

        Button(this.hasAnimStarted ? '重新播放' : '播放入场动画')
          .width('100%').margin({ top: 12 })
          .onClick(() => {
            this.hasAnimStarted = true
            // 先重置所有项的状态
            for (let i = 0; i < 8; i++) { this.itemStates[i] = 0 }
            // 然后依次触发每项的动画
            for (let i = 0; i < 8; i++) {
              setTimeout(() => {
                animateTo({ duration: 350, curve: Curve.EaseOut }, () => {
                  this.itemStates[i] = 1
                })
              }, i * 100)
            }
          })
      }
      .width('100%').backgroundColor('#FFFFFF').borderRadius(12).padding(16)
    }
    .width('100%').height('100%').backgroundColor('#F5F6FA').padding(16)
  }

  getColor(index: number): string {
    const colors = ['#FF6B6B', '#FFA500', '#FFD93D', '#6BCB77', '#4ECDC4', '#4D96FF', '#9B59B6', '#FF6B9D']
    return colors[index]
  }
}

代码拆解

状态设计:一个数组管所有项

@State itemStates: number[] = [0, 0, 0, 0, 0, 0, 0, 0]

8 个列表项,8 个动画状态值。0 代表"未入场"(透明 + 偏移),1 代表"已入场"(不透明 + 原位)。

用一个数组而不是 8 个独立变量,是因为列表项的数量可能不固定。后面讲到动态数据源的时候会展开说。

opacity + translateX 的组合

每个列表项绑定了两个动画属性:

.opacity(this.itemStates[idx])
.translate({ x: (1 - this.itemStates[idx]) * 50 })

itemStates[idx] 为 0 时:

  • opacity = 0(完全透明)
  • translateX = (1-0) * 50 = 50px(向右偏移 50px)

itemStates[idx] 为 1 时:

  • opacity = 1(完全不透明)
  • translateX = (1-1) * 50 = 0(回到原位)

所以每个列表项的入场效果是:从右侧 50px 的位置,伴随着透明度从 0 到 1,滑动到原位

这个 translate 的公式 (1 - state) * offset 是个很好用的小技巧。state 从 0 到 1 线性变化,translateX 从 offset 到 0 线性变化,正好形成平滑的位移过渡。

.animation() 修饰器

.animation({ duration: 350, curve: Curve.EaseOut })

挂在每个 Row 上,意味着当 itemStates[idx] 变化时,opacity 和 translateX 的过渡动画自动执行。这里用 EaseOut 曲线是因为入场动画需要"快速启动、缓慢停下"的感觉。

350ms 的时长是个经过反复测试的值——太短(200ms)看不出滑动效果,太长(500ms+)会让整体入场太拖沓。

触发逻辑:重置 + 依次启动

.onClick(() => {
  this.hasAnimStarted = true
  // 第一步:重置
  for (let i = 0; i < 8; i++) { this.itemStates[i] = 0 }
  // 第二步:依次触发
  for (let i = 0; i < 8; i++) {
    setTimeout(() => {
      animateTo({ duration: 350, curve: Curve.EaseOut }, () => {
        this.itemStates[i] = 1
      })
    }, i * 100)
  }
})

第一步的"重置"没有用 animateTo 包裹,所以是瞬间生效的——所有列表项立刻变成透明+偏移状态。

第二步用 for 循环 + setTimeout 实现交错延迟。第 0 项延迟 0ms,第 1 项延迟 100ms,第 2 项延迟 200ms……以此类推。

时间轴长这样:

时间线(ms): 0 ---100 ---200 ---300 ---400 ---500 ---600 ---700 ---800 ---900 ---1050
项1:        |====350ms 动画====|
项2:              |====350ms 动画====|
项3:                     |====350ms 动画====|
项4:                            |====350ms 动画====|
项5:                                   |====350ms 动画====|
项6:                                          |====350ms 动画====|
项7:                                                 |====350ms 动画====|
项8:                                                        |====350ms 动画====|

每项间隔 100ms,每项动画 350ms。这意味着相邻两项之间有 250ms 的重叠——前一项还在动的时候,后一项已经开始了。这种重叠是故意设计的,它让整个入场过程有连贯的流动感,而不是"一个完了一个再来"的断裂感。

从开始到最后一项动画结束,总耗时 = 7 * 100 + 350 = 1050ms。大约 1 秒,对于 8 个列表项来说是个很舒服的节奏。

调整参数:不同风格的入场效果

改变延迟间隔和动画时长,可以获得完全不同风格的入场效果:

快速紧凑风格

// 间隔 50ms,动画 250ms
setTimeout(() => {
  animateTo({ duration: 250, curve: Curve.EaseOut }, () => {
    this.itemStates[i] = 1
  })
}, i * 50)

总耗时 = 7 * 50 + 250 = 600ms。适合数据刷新、搜索结果等"快速呈现"的场景。

优雅从容风格

// 间隔 150ms,动画 500ms
setTimeout(() => {
  animateTo({ duration: 500, curve: Curve.EaseInOut }, () => {
    this.itemStates[i] = 1
  })
}, i * 150)

总耗时 = 7 * 150 + 500 = 1550ms。适合首页、品牌展示等"需要观赏性"的场景。

波浪弹性风格

// 间隔 80ms,动画 600ms,使用 Friction 曲线(有轻微回弹)
setTimeout(() => {
  animateTo({ duration: 600, curve: Curve.Friction }, () => {
    this.itemStates[i] = 1
  })
}, i * 80)

Friction 曲线会给每个列表项一个轻微的"弹到位"的感觉,整体效果更有活力。

换一种入场方向

不一定非要从右边滑入。改一下 translate 的参数就行:

从下方滑入

.translate({ y: (1 - this.itemStates[idx]) * 30 })

每项从下方 30px 处向上滑入。这种效果在 HarmonyOS6 PC 端的长列表里特别好——用户的视线是从上往下扫的,从下方进入刚好引导视线流动。

从左侧滑入

.translate({ x: -(1 - this.itemStates[idx]) * 50 })

加个负号就行。从左侧滑入适合从右到左排列的元素(比如阿拉伯语布局),或者配合某个从左侧展开的交互。

纯缩放进入(无位移)

.opacity(this.itemStates[idx])
.scale({
  x: 0.8 + this.itemStates[idx] * 0.2,
  y: 0.8 + this.itemStates[idx] * 0.2
})

从 80% 大小 + 透明 → 正常大小 + 不透明。没有位移,更有"原地弹出"的感觉。适合网格布局的卡片入场。

动态数据源的入场动画

Demo 里用了固定的 8 个项。但真实项目里,列表数据通常是从后端获取的,数量不固定。

这时候代码要做些调整:

@State dataList: string[] = []
@State itemStates: number[] = []

// 数据加载完成后调用
onDataLoaded(data: string[]) {
  this.dataList = data
  // 初始化动画状态数组,跟数据等长
  this.itemStates = new Array(data.length).fill(0)

  // 播放入场动画
  const maxAnimatedItems = Math.min(data.length, 15) // 最多给15项做动画
  for (let i = 0; i < maxAnimatedItems; i++) {
    setTimeout(() => {
      animateTo({ duration: 350, curve: Curve.EaseOut }, () => {
        const newState = [...this.itemStates]
        newState[i] = 1
        this.itemStates = newState
      })
    }, i * 100)
  }

  // 超过15项的直接设为可见状态,不做动画
  if (data.length > 15) {
    for (let i = 15; i < data.length; i++) {
      this.itemStates[i] = 1
    }
  }
}

这里有几个关键点:

1. 限制做动画的项数

如果列表有 100 项,每项间隔 100ms,光动画就要播 10 秒——用户等不起。所以设一个上限(比如 15 项),超过的部分直接显示。

2. 用数组替换触发响应式更新

直接 this.itemStates[i] = 1 在某些 ArkUI 版本里可能不会触发 UI 更新。用 const newState = [...this.itemStates] 创建新数组再赋值更保险。

3. 在 build 中使用动态数组

ForEach(this.dataList, (item: string, idx: number) => {
  Row() {
    // 渲染列表项内容...
  }
  .opacity(this.itemStates[idx] ?? 1)  // 兜底值为1,防止数组越界
  .translate({ x: (1 - (this.itemStates[idx] ?? 1)) * 50 })
  .animation({ duration: 350, curve: Curve.EaseOut })
})

兜底值 ?? 1 很重要——当数据比 itemStates 多(比如分页加载了更多数据),新项不会有对应的动画状态,用兜底值 1 让它们直接显示。

LazyForEach 与动画的兼容问题

如果你的列表用的是 LazyForEach(懒加载),那入场动画就有点麻烦了。

LazyForEach 的特性是只创建可视区域内的组件。滚动时新进入可视区域的组件会被即时创建。这就导致一个问题——当你触发入场动画时,只有当前屏幕内的几项会做动画,滚下去才出现的项不会做(因为它们是在滚动过程中才创建的)。

解决方案有两种:

方案一:入场动画只在首次加载时做

首次加载的数据量通常不会超出屏幕太多(一般首屏 5-10 项),入场动画做完后,后续滚动加载的项直接显示,不做动画。这个方案最简单,实际效果也还不错。

方案二:监听 onAppear 做单个项的入场

ForEach(this.dataList, (item: string, idx: number) => {
  Row() { /* ... */ }
    .opacity(this.itemStates[idx])
    .translate({ x: (1 - this.itemStates[idx]) * 50 })
    .animation({ duration: 350, curve: Curve.EaseOut })
    .onAppear(() => {
      // 每个项被创建时自动触发自己的入场动画
      animateTo({ duration: 350, curve: Curve.EaseOut }, () => {
        const newState = [...this.itemStates]
        newState[idx] = 1
        this.itemStates = newState
      })
    })
})

每个列表项在 onAppear 时触发动画。这样不管是首屏加载还是滚动后出现,每个项都有自己的入场效果。但要注意——滚动很快的时候,多个项几乎同时 onAppear,可能造成大量并发动画。可以加个标记,只对首次出现做动画。

性能优化:大量列表项的入场动画

在 HarmonyOS6 PC 端,列表可能很长(尤其是大屏能显示更多项)。如果一次要给 30+ 项做入场动画,要注意性能。

优化一:减少 animateTo 的调用次数

与其每项一个 animateTo,不如分组处理:

// 每5项一组,每组同时入场
const groupSize = 5
for (let g = 0; g < Math.ceil(data.length / groupSize); g++) {
  setTimeout(() => {
    animateTo({ duration: 350, curve: Curve.EaseOut }, () => {
      const newState = [...this.itemStates]
      for (let i = g * groupSize; i < (g + 1) * groupSize && i < data.length; i++) {
        newState[i] = 1
      }
      this.itemStates = newState
    })
  }, g * 150) // 每组间隔 150ms
}

30 项分成 6 组,每组 5 项同时入场,组间间隔 150ms。总耗时只有 5 * 150 + 350 = 1100ms,但 animateTo 只调用了 6 次而不是 30 次。

优化二:避免在动画期间操作数组

每次 this.itemStates = newState 都会触发 ArkUI 的 diff 算法。如果数组很大且频繁替换,性能开销不可忽略。

对于大型列表,推荐用 @Observed + @ObjectLink 的方式,让每个列表项是独立的可观察对象:

@Observed
class ListItemModel {
  title: string
  animationProgress: number = 0

  constructor(title: string) {
    this.title = title
  }
}

// 在组件中使用
@State items: ListItemModel[] = dataList.map(t => new ListItemModel(t))

// 动画触发时直接修改属性
animateTo({ duration: 350 }, () => {
  this.items[i].animationProgress = 1
})

每个项的动画状态是独立的,修改一项不影响其他项,也不需要替换整个数组。

入场动画的时机选择

入场动画什么时候播放,其实也是个设计问题。

页面首次加载时:最常见的时机。数据渲染完毕后自动播放,给用户"页面正在加载完成"的反馈。

下拉刷新后:刷新完成后新数据入场。但说实话,刷新场景不建议做入场动画——用户只是想看看有什么新内容,不想再看一遍动画。

Tab 切换后:切换到某个 Tab 时该 Tab 下的列表入场。适合每个 Tab 内容差异较大的场景。

搜索/筛选后:搜索结果出来时做入场动画,可以让"搜索结果加载完成"这个事件更有感知。但同样注意,如果用户频繁搜索,每次都来一遍动画会很烦。可以加个标记——只在首次搜索时播放。

在 HarmonyOS6 PC 端,用户的操作频率比手机端高(鼠标点击快嘛),所以入场动画的触发条件要更谨慎。我的建议是:只在页面首次加载和重要的数据更新时播放入场动画,其他场景直接显示

跟串联动画的关系

如果你看了之前关于串联动画的文章,会发现列表项入场动画本质上就是一种串联动画——只不过是"有重叠的串联"。

核心模式完全一样:

for 循环遍历 + setTimeout(i * interval) + animateTo()

区别在于串联动画是"前一个做完后一个再做"(间隔 >= 动画时长),而列表项入场是"前一个还没做完后一个就开始了"(间隔 < 动画时长)。后者的效果更连贯,也是实际项目中更常用的模式。

小结

列表项交错入场动画,说白了就是三个要素的组合:

  1. 每项绑定 opacity + translate:控制可见性和位置
  2. .animation() 修饰器:让属性变化自动过渡
  3. setTimeout(i * interval):控制每项的启动延迟

调参的关键在于 interval 和 duration 的比例。interval 小于 duration 产生重叠效果(推荐),interval 等于 duration 产生严格衔接效果,interval 大于 duration 产生"停顿-动-停顿"效果(通常不推荐)。

下次做列表页面的时候,花 5 分钟加上这个入场效果,用户体验提升真的立竿见影。

Logo

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

更多推荐