1. 手风琴组件在鸿蒙化场景下的技术选型思路
1.1 为什么你会突然需要自己写一个手风琴组件
做 React Native 开发的老手应该都有这种感觉:手风琴(Accordion)这种组件,平时在 App 里存在感不算强,但一旦遇到设置页的折叠菜单、电商筛选条件的分组展开、帮助中心常见问题的点开展开这类需求,就会觉得手头没有一个趁手的轮子确实别扭。
如果只是 iOS 和 Android 双端,直接引第三方库比如 react-native-collapsible 或者 react-native-accessible-accordion 就完事了。但在鸿蒙化这个背景下,事情就没那么简单。HarmonyOS NEXT 已经不能直接兼容 Android APK,React Native 应用要跑上去,依赖的是 React Native for OpenHarmony 这个社区与官方共同推动的三方桥接层。那些第三方折叠组件内部如果引用了原生视图管理器或者原生动画模块,极大可能在新平台上直接崩给你看。我在接入的时候,第一周排查的崩栈里,有一半以上是第三方组件在鸿蒙环境下原生模块缺失导致的。
所以最终方案很清晰:自己封装一个不依赖任何原生代码的纯 JS 手风琴组件,核心机制就是题目里说的那句话——通过管理每个折叠项的 expanded 状态来控制内容的展开与收起。这个方案的好处是,底层只有 View、Text、TouchableOpacity、Animated 这些最基础的跨平台组件,在 iOS、Android、HarmonyOS 三端的行为完全一致,没有差异化维护成本。
1.2 手风琴组件的典型使用场景梳理
在动手写代码之前,先别急着开编辑器。你需要把手风琴组件的使用场景在脑子里过一遍,这样才能定义清楚组件的 API 形态和状态模型。据我实际项目走访,手风琴组件出现频率最高的场景有这么几类。
第一类是设置页分组。App 的设置页通常有很多个功能区块,比如「账号与安全」「通知与隐私」「通用设置」。如果不做折叠,整个页面拉到底可能要好几个屏幕的高度,用户看着也烦躁。第二类是电商平台的筛选面板。商品列表页的筛选弹层里,品牌、价格区间、分类、颜色这些筛选条件各自展开收起,互相之间要联动,选定条件后收起当前分组再自动展开下一组。第三类是帮助中心 FAQ。这时候手风琴组件反而不需要复杂的动画,点击问题标题展开答案,多个问题同时展开也是常有的需求。
有意思的是,这三类场景对 expanded 状态的需求完全不同。设置页和 FAQ 通常允许多个面板同时展开,而筛选面板一般会约束成一次只展开一个。这就是为什么状态模型不能写死,必须由使用方来决定。
1.3 第三方折叠组件在鸿蒙环境下的三个坑
先说结论:我并不是彻底否定所有第三方折叠组件,但在鸿蒙化项目中,用它们的成本可能远比想象高。
第一个坑是原生模块依赖。很多折叠组件为了做流畅的高度动画,会用 NativeDriver 驱动 Animated 节点,甚至直接挂一个原生自定义视图来测量内容高度。在 React Native for OpenHarmony 的兼容层里,这类原生模块支持度参差不齐。我的实测情况是,iOS 上丝滑的 300ms 展开动画,在鸿蒙真机上会出现内容直接闪现、动画完全失效的情况。
第二个坑是样式兼容。鸿蒙端对很多 CSS 属性的支持深度和 iOS 不完全一致,比如 overflow: hidden 配合圆角裁剪在某些版本上有渲染问题,zIndex 的层级表现也和 Android 不同。第三方组件为了视觉统一,往往写了很多复杂的嵌套样式,一旦某个样式在鸿蒙上渲染异常,你连排查的抓手都没有。
第三个坑,也是最具隐蔽性的,是组件维护者根本没把鸿蒙放在兼容列表里。很多折叠组件的 Issues 区已经几个月甚至一年没有人回复了,你提个鸿蒙兼容的 issue,大概率石沉大海。与其等一个不确定的修复版本,不如自己维护一套几十行代码就能搞定的核心逻辑。
基于以上原因,我最终选择了用 TypeScript 从零封装一个纯 JS 手风琴组件,依赖极简,行为可控,三端表现完全一致。接下来的章节,我会把这个组件的核心设计、状态管理、动画实现、鸿蒙踩坑全部拆开来讲。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心机制拆解:expanded 状态的前世今生
2.1 先定义清楚你的交互规则:手风琴式还是多开式
这是设计手风琴组件时第一个要拍板的问题。所谓手风琴式(Accordion 模式),类比乐器手风琴的推拉,同一时间最多只有一个面板处于展开状态,打开一个新的入口,之前展开的入口自动收起。多开式(Expandable 模式)没有这个限制,每个面板独立记忆自己的展开收起状态。
两种模式对应的状态结构完全不同。手风琴式只需要一个值:
tsx复制// 记录当前展开的面板 id,null 表示全部折叠
const [activeId, setActiveId] = useState<string | null>(null);
多开式需要每个面板各自的布尔状态,最常用的结构是用对象,以面板 id 作为 key:
tsx复制type ExpandedMap = Record<string, boolean>;
const [expandedMap, setExpandedMap] = useState<ExpandedMap>({
'panel-a': false,
'panel-b': true,
'panel-c': false,
});
我的建议是,把这两种模式统一收进同一个组件里,通过一个 multiple 属性切换。内部结构用 expandedMap 存储,手风琴式模式下切换逻辑里多做一步——把其他所有面板的 expanded 置为 false。这样状态模型统一了,代码分支也简单。
2.2 直接能用的状态管理代码
明确了模式之后,核心状态管理逻辑可以精简到二十行以内。我给出一份我在鸿蒙真机上验证过的代码片段,不要急着复制,先搞清楚每一步的意图。
tsx复制// Accordion.tsx 核心抽象
interface Ac
