14:02 ------ 接了个需求。
产品说首页那个 FAQ 区域太丑了,想做成可折叠的面板------点标题展开,再点收起来,带平滑动画。
我心想这不就 Accordion 吗,半小时的事。打开 DevEco Studio,搜 "Collapse"------官方确实有。扫了一眼文档,问题来了:官方的 Collapse 只能塞纯文本,我要放图片、表格、甚至 LazyForEach 的列表。
更坑的是,文档里 Collapse 的示例只展示了一行文本的折叠,没有任何嵌套子组件、动态高度或者动画自定义的说明。我瞄了一眼源码里 Collapse 的实现------本质上就是个 if/else 切换显示隐藏,完全没走动画管道。也就是说"官方折叠面板"跟"带动画的自定义折叠面板"根本是两个东西。
官方的 Collapse 用不了。自己写。
14:35 ------ 「这 animateTo 怎么不管用?」
思路很直白:一个 Column 里放标题行 + 内容区,@State 控制展开/收起,animateTo 做高度过渡。
typescript
// 第一次尝试 ------ 天真版
@Component
struct NaiveAccordion {
@State isExpanded: boolean = false;
@State contentHeight: number = 0;
build() {
Column() {
Row() {
Text('常见问题解答').fontSize(16)
Blank()
Text(this.isExpanded ? '收起 ▲' : '展开 ▼')
.fontSize(14).fontColor('#999')
}
.width('100%').padding(16)
.onClick(() => {
this.isExpanded = !this.isExpanded;
this.contentHeight = this.isExpanded ? 200 : 0;
animateTo({ duration: 300, curve: Curve.EaseInOut }, () => {
// 天真地以为改高度就行
});
})
Column() {
Text('这里是一大段 FAQ 内容...').fontSize(14).lineHeight(22)
Image($r('app.media.faq_illustration')).width('100%').height(150)
// 更多内容...
}
.width('100%').height(this.contentHeight).clip(true)
}
}
}
运行------内容能出现,能消失,但没有任何过渡动画。刷一下出来,刷一下没,跟切幻灯片似的。
我开始怀疑是不是 animateTo 位置不对。换了六种写法:把 animateTo 包在 onClick 外面、放里面、用 attributeModifier、直接改 height------全都不行。
15:20 ------ 开始怀疑人生。
翻 ArkTS 文档里 animateTo 的说明。示例全是改 opacity、translate 这些属性,动得飞起。但高度------尤其是"从 0 到 fit-content"这种------不好意思,没反应。我试了一下把目标高度写死成 350,倒是能动了,可一旦内容区高度不固定------比如 FAQ 条目字数不一样------动画就直接崩了。
我又尝试用 .constraintSize 限制最大高度然后配合 animateTo,也不行。constraintSize 改变时动画管道同样无动于衷。这个发现让我很沮丧,因为它意味着 animateTo 能动画的属性列表是有明确边界的,而"height 从确定值变为不确定值"恰好卡在这个边界之外。
群里问了句「ArkTS 怎么给动态高度做展开动画」,有人回「用 transitionEffect」。兴冲冲试了------
界面直接抽了一下。transitionEffect 只在组件插入/移除时触发一次,你先把 Column 渲染成 height=0,再改成 height=auto,它根本感知不到这个变化。那个"抽一下"就是它执行了一次入场动画,但高度变化本身没被动画覆盖。
等一下,我漏说了一个前提:transitionEffect 和 animateTo 在 ArkTS 内部的动画管线里走的是两条路径。transitionEffect 绑定的是组件的挂载/卸载生命周期,animateTo 绑定的是属性变化。当你同时改变组件的显示状态和高度时,这两个机制会抢同一帧的渲染,产生一种"闪了但没动画"的效果。我试了四五次才搞明白这一点。
16:45 ------ 灵光一现。
翻源码的时候看到过 @Animator。ArkTS 12 引入了这个装饰器,可以手动驱动动画帧。
typescript
// @Animator 帧级控制方案
@Animator('accordionAnim')
animCtrl: AnimatorResult = { value: 0 };
aboutToAppear() {
this.animCtrl = {
duration: 300,
curve: Curve.EaseInOut,
value: 0,
iterations: 1,
playMode: PlayMode.Normal,
onFrame: (progress: number) => {
if (this.isExpanded) {
this.animHeight = progress * this.measuredHeight;
} else {
this.animHeight = (1 - progress) * this.measuredHeight;
}
}
};
}
有门!但新问题紧跟着来了------我怎么知道内容区的"实际高度"?measure.measureText 只能量文字,我这内容区里塞了一堆图片和复杂布局。
做雷达鸭鸿蒙版的时候 FAQ 页面就用了这个套路。我当时想的是"折叠面板而已能有多难",结果整了七个小时。雷达鸭上架华为应用市场了,微信小程序也能搜到,里面好几个页面的折叠面板都是这套动画方案。
解决方案比想象中 low-tech:用 onAreaChange 拿真实渲染高度,缓存下来,展开时当动画目标。
typescript
// 高度测量 + 缓存
.onAreaChange((oldArea: Area, newArea: Area) => {
if (this.measuredHeight === 0 && Number(newArea.height) > 0) {
this.measuredHeight = Number(newArea.height);
}
})
18:15 ------ 死循环,你大爷的。
页面疯狂闪烁,日志里 onAreaChange 被调了上百次。原因很蠢:改了 animHeight → 触发重渲染 → onAreaChange 又触发 → 更新 measuredHeight → 又触发 animHeight → 无限循环。
这是 ArkTS 状态管理的一个经典陷阱------@State 变量一旦变化,整个 build() 重新执行,onAreaChange 在一次渲染后必定触发,如果你在 onAreaChange 里又改了 @State,闭合循环就形成了。
修起来加个守卫:
typescript
// 修复版 ------ 加守卫防死循环
.onAreaChange((oldArea: Area, newArea: Area) => {
const h = Number(newArea.height);
// measuredHeight 只量一次,之后完全由 @Animator 驱动
if (this.measuredHeight === 0 && h > 0) {
this.measuredHeight = h;
this.animHeight = this.isExpanded ? h : 0;
}
// 关键:不再在这里更新 animHeight
})
跑起来------面板丝滑展开,不闪了,不死循环了。
19:10 ------ 收工。
从下午两点的天真自信,到晚上九点终于搞定,中间建了 6 个测试页面,改了不下 40 个版本,有三版是彻底推倒重来的。茶水间的咖啡机我今天用了四次。
我承认,如果给我重来一次,我可能会直接放弃 animated collapsed height 这条路,改用一个简单粗暴的 visibility + opacity 过渡------用户体验差一点,但代码只要十行。这七个小时值不值?不好说。但至少搞清楚了 ArkTS 动画管道里 animateTo、transitionEffect 和 @Animator 三者的边界,这个认知以后在其他场景里还能复用。
说实话,ArkTS 在声明式 UI 动画这一块跟成熟框架比还有差距。就像你习惯了 React 的 framer-motion 里一行 animate={{ height: isOpen ? 'auto' : 0 }} 就能搞定的事,到这边要手工搭一套帧级控制、高度测量、死循环守卫的管线------这体验确实会让人血压升高。
但换个角度想:正因为它还没那么"开箱即用",所以搞清楚底层机制才更有价值。反正我以后遇到类似的动态高度动画场景,第一反应就是 @Animator,不会再浪费三小时在 animateTo 上了。
关于我
老三,10 年软件开发,软件设计师兼人工智能应用工程师。专注鸿蒙 ArkTS 北向开发与 Web 前端,不定期在 CSDN 分享鸿蒙和 AI 方向的技术文章。
本文遵循 MIT 协议,转载请注明出处。