HarmonyOS6 PC 开发实战:手风琴式展开折叠动画,让页面告别“一坨文字“
做PC端应用的时候,有个问题一直困扰着我——屏幕大了,内容反而更容易堆成一坨。用户看到满屏的文字,第一反应就是关掉。
后来我想明白了,PC端的设置页、FAQ页、帮助文档这些场景,其实特别需要一种"按需展示"的交互方式。用户点一下标题,内容平滑地展开;再点一下,优雅地收回去。这就是我们常说的手风琴效果。
今天我们就来聊聊,在HarmonyOS6 PC端开发中,怎么用ArkUI的动画能力实现一个体验不错的展开折叠效果。

先看效果:点击即展开,再点即收起
我们要做的效果是这样的:页面上有几个卡片,每个卡片有一个标题栏。点击标题栏,下方的详细内容区域会以一个"从上往下滑入+淡入"的组合动画展开。再点击,内容区域以"向下滑出+淡出"的动画收起。
同时,标题栏右侧会显示"展开 ▼"或"收起 ▲"的文字提示,给用户明确的状态反馈。
这个效果在PC端的设置页面里非常常见。比如Windows的设置页、macOS的系统偏好设置,到处都是这种交互。HarmonyOS6 PC端的应用也该有。
核心思路:条件渲染 + 过渡动画
说白了,展开折叠的核心就两件事:
- 用一个布尔状态控制内容区域的显示/隐藏
- 在内容出现和消失的时候,加上过渡动画
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() 用来把多个过渡效果叠加在一起。我们的效果是"淡入+滑入"同时进行,所以需要把 OPACITY 和 translate 组合起来。
如果不 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)
踩坑记录:过渡动画不生效的几个原因
在调试过程中,我遇到了几个过渡动画不生效的情况,总结一下:
-
忘了加
.animation()修饰器。如果你只写了.transition()但没有给父容器或相关组件加.animation(),布局变化可能不会有平滑过渡,直接"跳"过去了。 -
if条件变化太快。如果在一个事件回调里连续多次切换状态,框架可能会"合并"这些更新,导致过渡动画根本没机会执行。 -
translate的y值太小。如果你设的y: -5,位移太小了肉眼根本看不出来。建议至少y: -15以上,动画才明显。 -
duration设太长。过渡动画最好不要超过400ms。太长会让用户觉得页面"卡"了。展开300ms、收起200ms是个比较舒服的区间。
小结
展开折叠动画是个看起来简单、做起来有细节的交互效果。核心就是三样东西:
@State布尔值控制显隐if条件渲染配合.transition()实现出入场动画TransitionEffect.asymmetric()定义不同的入场和出场效果
在HarmonyOS6 PC端开发中,这个效果的使用频率会非常高。PC端屏幕大、内容多,"按需展示"是信息架构的基本功。把这个模式吃透了,设置页、FAQ页、帮助文档这些场景就都能搞定了。
更多推荐


所有评论(0)