做PC端应用的时候,有个问题一直困扰着我——屏幕大了,内容反而更容易堆成一坨。用户看到满屏的文字,第一反应就是关掉。

后来我想明白了,PC端的设置页、FAQ页、帮助文档这些场景,其实特别需要一种"按需展示"的交互方式。用户点一下标题,内容平滑地展开;再点一下,优雅地收回去。这就是我们常说的手风琴效果。

今天我们就来聊聊,在HarmonyOS6 PC端开发中,怎么用ArkUI的动画能力实现一个体验不错的展开折叠效果。

先看效果:点击即展开,再点即收起

我们要做的效果是这样的:页面上有几个卡片,每个卡片有一个标题栏。点击标题栏,下方的详细内容区域会以一个"从上往下滑入+淡入"的组合动画展开。再点击,内容区域以"向下滑出+淡出"的动画收起。

同时,标题栏右侧会显示"展开 ▼"或"收起 ▲"的文字提示,给用户明确的状态反馈。

这个效果在PC端的设置页面里非常常见。比如Windows的设置页、macOS的系统偏好设置,到处都是这种交互。HarmonyOS6 PC端的应用也该有。

核心思路:条件渲染 + 过渡动画

说白了,展开折叠的核心就两件事:

  1. 用一个布尔状态控制内容区域的显示/隐藏
  2. 在内容出现和消失的时候,加上过渡动画

ArkUI里有个非常方便的机制——if 条件渲染配合 .transition() 修饰器,就能搞定这件事。当 if 条件从 false 变为 true 时,组件会被创建并执行"入场"过渡动画;从 true 变为 false 时,组件执行"出场"过渡动画然后被销毁。

我们再配合 .animation() 修饰器来让高度变化也有一个平滑过渡,整个效果就出来了。

状态管理:每个折叠项一个布尔值

先来看状态定义部分。

@Entry
@Component
struct AccordionDemo {
  @State isExpanded1: boolean = false
  @State isExpanded2: boolean = false
  @State isExpanded3: boolean = false
  // ...
}

三个折叠项,三个 @State 布尔值。简单粗暴但很管用。

说实话,如果折叠项很多(比如FAQ页面有二三十个问答),这种一个个定义的方式肯定不合适。那时候更好的做法是用一个数组来管理状态,或者把每个折叠项封装成独立的子组件,让它自己管理自己的展开状态。但作为学习示例,三个状态变量已经足够把原理讲清楚了。

我们还提供了两个辅助方法来读写状态:

_getState(index: number): boolean {
  if (index === 0) return this.isExpanded1
  if (index === 1) return this.isExpanded2
  return this.isExpanded3
}

_toggleState(index: number) {
  if (index === 0) this.isExpanded1 = !this.isExpanded1
  else if (index === 1) this.isExpanded2 = !this.isExpanded2
  else this.isExpanded3 = !this.isExpanded3
}

这样做的好处是把索引到状态的映射集中管理,在 @Builder 里调用起来很干净。

标题栏:点击切换的关键

标题栏部分没什么黑魔法,就是一个 Row 布局,左边放标题文字,右边放状态提示:

@Builder
ExpandItem(title: string, index: number) {
  Column() {
    Row() {
      Text(title)
        .fontSize(14)
        .fontWeight(FontWeight.Medium)
        .layoutWeight(1)
      Text(this._getState(index) ? '收起 ▲' : '展开 ▼')
        .fontSize(12)
        .fontColor('#007DFF')
    }
    .width('100%')
    .padding(12)
    .onClick(() => { this._toggleState(index) })

    // 内容区域在下面...
  }
}

几个值得注意的细节:

  • layoutWeight(1) 让标题文字占据剩余空间,状态提示自然靠右
  • 状态提示用三元表达式根据当前展开状态动态显示"收起 ▲"或"展开 ▼"
  • onClick 直接调用 _toggleState 切换布尔值,ArkUI的响应式系统会自动触发UI更新

重头戏:TransitionEffect.asymmetric 非对称过渡

这是今天最核心的知识点。

我们来看看内容区域的代码:

if (this._getState(index)) {
  Column() {
    Text(`这是 ${title} 的详细内容区域。\n展开折叠动画可以让页面更具交互性,\n用户体验更加流畅。`)
      .fontSize(12)
      .fontColor('#999999')
      .padding(12)
  }
  .width('100%')
  .backgroundColor('#F5F6FA')
  .borderRadius(8)
  .animation({ duration: 300, curve: Curve.EaseInOut })
  .transition(TransitionEffect.asymmetric(
    // 入场动画:淡入 + 从上方滑入
    TransitionEffect.OPACITY
      .animation({ duration: 300 })
      .combine(
        TransitionEffect.translate({ y: -20 })
          .animation({ duration: 300 })
      ),
    // 出场动画:淡出 + 向下方滑出
    TransitionEffect.OPACITY
      .animation({ duration: 200 })
      .combine(
        TransitionEffect.translate({ y: 20 })
          .animation({ duration: 200 })
      )
  ))
}

这段代码信息量挺大的,我来拆解一下。

什么是 TransitionEffect?

TransitionEffect 是ArkUI专门用来定义组件"出现"和"消失"时的动画效果的。当组件被 if 条件创建出来时,它会从 TransitionEffect 定义的"初始状态"过渡到"正常状态";当组件被销毁时,从"正常状态"过渡到 TransitionEffect 定义的"结束状态"。

为什么要用 asymmetric?

TransitionEffect.asymmetric() 允许我们分别定义入场和出场的动画效果。这个设计非常合理——展开和收起本来就不该是完全相反的过程。

你看我们的实现:

  • 入场(展开):从上方20像素的位置滑下来,同时从透明变为不透明,耗时300ms
  • 出场(收起):向下方20像素的位置滑出去,同时从不透明变为透明,耗时200ms

为什么出场时间更短?这是一个UX细节。用户对"收起"的耐心比"展开"低——收起是个"结束"动作,快一点会让用户觉得更干脆、不拖泥带水。展开稍微慢一点,给用户一个"内容正在呈现"的感知过程。

combine 的作用

.combine() 用来把多个过渡效果叠加在一起。我们的效果是"淡入+滑入"同时进行,所以需要把 OPACITYtranslate 组合起来。

如果不 combine,你就只能定义单一的过渡效果,比如只淡入不滑动,视觉上会单调很多。

.animation() 修饰器的配合

你可能注意到了,在 .transition() 之外,我们还加了一个 .animation({ duration: 300, curve: Curve.EaseInOut })。这个修饰器的作用是让组件的属性变化(比如高度、宽度等布局属性的变化)也有平滑过渡。

坦白讲,transition 管的是组件的出现/消失,animation 管的是组件属性值的变化。两者配合起来,才能保证展开的时候不仅有淡入滑入效果,整个布局的重新排列也是平滑的,而不是"啪"的一下跳过去。

批量操作:全部展开/全部折叠

除了单个卡片的点击切换,我们还加了两个按钮来做批量操作:

Button('全部展开')
  .width('100%')
  .margin({ top: 8 })
  .onClick(() => {
    this.isExpanded1 = true
    this.isExpanded2 = true
    this.isExpanded3 = true
  })

Button('全部折叠')
  .width('100%')
  .margin({ top: 6 })
  .onClick(() => {
    this.isExpanded1 = false
    this.isExpanded2 = false
    this.isExpanded3 = false
  })

点击"全部展开",三个布尔值同时置为 true,三个内容区域几乎同时执行入场动画。因为有 Curve.EaseInOut 缓动曲线,视觉上看起来是整齐划一的展开效果。

这个功能在PC端其实挺实用的。想象一下,用户在设置页面想搜索某个选项,如果所有分组都折叠了,他得一个个点开找。给一个"全部展开"的入口,体验会好很多。

完整代码

把上面的片段组合起来,完整的页面结构如下:

@Entry
@Component
struct AccordionDemo {
  @State isExpanded1: boolean = false
  @State isExpanded2: boolean = false
  @State isExpanded3: boolean = false

  @Builder
  ExpandItem(title: string, index: number) {
    Column() {
      Row() {
        Text(title)
          .fontSize(14)
          .fontWeight(FontWeight.Medium)
          .layoutWeight(1)
        Text(this._getState(index) ? '收起 ▲' : '展开 ▼')
          .fontSize(12)
          .fontColor('#007DFF')
      }
      .width('100%')
      .padding(12)
      .onClick(() => { this._toggleState(index) })

      if (this._getState(index)) {
        Column() {
          Text(`这是 ${title} 的详细内容区域。\n展开折叠动画可以让页面更具交互性,\n用户体验更加流畅。`)
            .fontSize(12)
            .fontColor('#999999')
            .padding(12)
        }
        .width('100%')
        .backgroundColor('#F5F6FA')
        .borderRadius(8)
        .animation({ duration: 300, curve: Curve.EaseInOut })
        .transition(TransitionEffect.asymmetric(
          TransitionEffect.OPACITY
            .animation({ duration: 300 })
            .combine(
              TransitionEffect.translate({ y: -20 })
                .animation({ duration: 300 })
            ),
          TransitionEffect.OPACITY
            .animation({ duration: 200 })
            .combine(
              TransitionEffect.translate({ y: 20 })
                .animation({ duration: 200 })
            )
        ))
      }
    }
    .width('100%')
    .backgroundColor('#FFFFFF')
    .borderRadius(8)
    .margin({ bottom: 8 })
  }

  _getState(index: number): boolean {
    if (index === 0) return this.isExpanded1
    if (index === 1) return this.isExpanded2
    return this.isExpanded3
  }

  _toggleState(index: number) {
    if (index === 0) this.isExpanded1 = !this.isExpanded1
    else if (index === 1) this.isExpanded2 = !this.isExpanded2
    else this.isExpanded3 = !this.isExpanded3
  }

  build() {
    Column() {
      Scroll() {
        Column() {
          Text('展开折叠动画')
            .fontSize(18)
            .fontWeight(FontWeight.Bold)
            .margin({ bottom: 8 })

          Column() {
            this.ExpandItem('功能介绍', 0)
            this.ExpandItem('使用说明', 1)
            this.ExpandItem('配置选项', 2)
            Button('全部展开')
              .width('100%')
              .margin({ top: 8 })
              .onClick(() => {
                this.isExpanded1 = true
                this.isExpanded2 = true
                this.isExpanded3 = true
              })
            Button('全部折叠')
              .width('100%')
              .margin({ top: 6 })
              .onClick(() => {
                this.isExpanded1 = false
                this.isExpanded2 = false
                this.isExpanded3 = false
              })
          }
          .width('100%')
        }
        .width('100%')
      }
      .layoutWeight(1)
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F5F6FA')
    .padding(16)
  }
}

扩展思考:在PC端的实际应用场景

展开折叠动画在PC端的应用场景比手机端多得多。

设置页面是最典型的。PC端应用的设置项通常很多——显示设置、通知设置、隐私设置、账户设置等等。每个分类下面可能有十几个选项。用折叠面板把不同分类收纳起来,用户需要时再展开,页面整洁、查找高效。

帮助文档/FAQ页面也很适合。用户在PC端看帮助文档的时候,往往是带着具体问题来的。一个问题对应一个折叠项,用户扫一眼标题就知道该不该点开。比把所有回答平铺在页面上强太多。

代码编辑器的文件树也是类似思路。文件夹展开/收起本质上就是手风琴效果。HarmonyOS6 PC端如果要做一个文件管理器,这个动画是基础中的基础。

还有一个场景——数据面板。Dashboard 里经常有多个数据模块,允许用户折叠暂时不关心的模块,把注意力集中在当前关注的数据上。在PC端大屏幕上看数据报表的时候,这个功能特别有用。

进阶优化:用数组管理折叠状态

前面用了三个 @State 变量来管理三个折叠项。如果折叠项是动态的(比如从服务器拉取的FAQ列表),我们可以把状态改成数组:

@State expandStates: boolean[] = [false, false, false]

// 切换某个项的展开状态
_toggleState(index: number) {
  // 注意:直接修改数组元素不会触发UI更新
  // 需要创建新数组来触发响应式
  const newStates = [...this.expandStates]
  newStates[index] = !newStates[index]
  this.expandStates = newStates
}

这里有个坑要提醒一下。ArkUI的 @State 对数组的监听是"引用级别"的,直接修改 this.expandStates[index] 不会触发UI更新。必须替换整个数组引用才行。这个坑我踩过,排查了好一会儿。

用数组管理状态后,"全部展开"和"全部折叠"也可以写得更简洁:

// 全部展开
this.expandStates = this.expandStates.map(() => true)

// 全部折叠
this.expandStates = this.expandStates.map(() => false)

踩坑记录:过渡动画不生效的几个原因

在调试过程中,我遇到了几个过渡动画不生效的情况,总结一下:

  1. 忘了加 .animation() 修饰器。如果你只写了 .transition() 但没有给父容器或相关组件加 .animation(),布局变化可能不会有平滑过渡,直接"跳"过去了。

  2. if 条件变化太快。如果在一个事件回调里连续多次切换状态,框架可能会"合并"这些更新,导致过渡动画根本没机会执行。

  3. translatey 值太小。如果你设的 y: -5,位移太小了肉眼根本看不出来。建议至少 y: -15 以上,动画才明显。

  4. duration 设太长。过渡动画最好不要超过400ms。太长会让用户觉得页面"卡"了。展开300ms、收起200ms是个比较舒服的区间。

小结

展开折叠动画是个看起来简单、做起来有细节的交互效果。核心就是三样东西:

  • @State 布尔值控制显隐
  • if 条件渲染配合 .transition() 实现出入场动画
  • TransitionEffect.asymmetric() 定义不同的入场和出场效果

在HarmonyOS6 PC端开发中,这个效果的使用频率会非常高。PC端屏幕大、内容多,"按需展示"是信息架构的基本功。把这个模式吃透了,设置页、FAQ页、帮助文档这些场景就都能搞定了。

Logo

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

更多推荐