React Native鸿蒙适配实战:企业级折叠面板组件设计
1. 项目背景与需求拆解1.1 为什么要做 React Native for Harmony 的企业级折叠面板先交代一下背景。这个项目的标题很长拆开其实就三个关键词React Native、Harmony、企业级折叠面板 Accordion。技术栈是 React Native运行目标平台是 Harmony 生态业务对象是企业级场景下的折叠面板组件。三者缺一不可任何一个词落空了整个项目都会跑偏。先说平台选型。很多团队现在已经在 HarmonyOS NEXT 上跑业务了但历史资产大多是 React Native 的尤其是中后台项目和混合 App 内的动态业务模块。如果重新用 ArkUI 写一遍成本高、周期长而且后续两套逻辑很难统一维护。react-native-harmony 这类兼容层方案的价值就在这里让现有 RN 代码尽可能低成本地跑通到鸿蒙设备上。但要注意兼容不是白拿的平台差异、组件生命周期、原生渲染节点管理都跟原先的 Android/iOS 不完全一样。拿 Accordion 这种看似简单的组件来说在 Harmony 上做不了这么简单输入事件、布局测量、动画刷新这些环节都跟原生 ArkUI 组件的交互模型有关系。再说折叠面板本身。Accordion 是后台界面里非常高频的 UI 组件设置页的分组、订单列表的状态说明、帮助中心的问答块、配置项的展开收起都是它。企业对这类组件的要求从来不是“能展开能收起”就完了而是要求状态可受控便于表单、筛选器、路由联动动画平滑不像跳变多实例隔离避免全局样式污染刷最长列表时性能不崩支持深色模式、无障碍、不同字号。所以说这个标题不是“做个折叠面板”而是“在 RN 兼容鸿蒙的前提下沉淀一个符合企业级质量要求的 Accordion 组件”。1.2 企业级场景里的 Accordion 形态企业级场景里的折叠面板形态比 demo 要复杂得多。最常见的三种场景第一设置中心/账户页。这类场景的折叠面板通常是“手风琴”模式一次只允许展开一个分组。展开的同时其他分组自动收起。点名要的是焦点不分散、视觉干净。第二复杂表单或筛选区。这里的折叠面板不是单纯展示而是承载表单控件折叠面板里塞一堆 Input、Picker、Switch。这个场景要求组件支持受控值和嵌套表单校验收起时表单状态不能丢展开时里面的子组件得正常加载。第三数据列表优化。比如订单状态时间线、退款进度。当折叠面板作为 FlatList 的行组件出现时同一个界面会有十几个甚至几十个折叠项每次只展开一个。这种场景对性能的挑战不是动画本身而是状态变更后避免整页重渲染。这三种形态对应的组件 API 设计也会不同第一种要 single 模式第二种要受控 外部 state 同步第三种要 React.memo 回调稳定。把 Accordion 定为一套通用组件时模式配置、默认展开项、回调通知、动画时长这些都必须做进去不能只做一个静态原件。2. 组件整体设计与方案选型2.1 受控与非受控的选择策略这个组件我在设计时最先定的不是 UI而是状态模型。关于受控还是非受控网上讨论很多落到企业级组件库里我建议做成“非受控为主支持外部触发重置”的半受控模式。什么意思呢比如 Accordion 组件内部自己维护 expandedIds这是非受控形态适合大多数业务直接使用。企业场景里常见“点击另一个按钮全收起”的需求比如筛选区域在用户点击“重置”按钮时所有折叠面板回到默认状态。如果组件完全不暴露外部操作入口就得靠 key 重新渲染整个组件或者用 ref 调内部方法这很不优雅。我建议的最终 API 是这样export interface AccordionProps { /** * 折叠项数据 */ sections: AccordionSection[]; /** * 默认展开项 id 集合非受控初始值 */ defaultExpandedIds?: string[]; /** * 外部受控的展开项 id 集合 */ expandedIds?: string[]; /** * 展开模式 * single手风琴模式同一时间只展开一个 * multiple允许多个同时展开 * collapseAll默认全部收起可多开 */ mode?: single | multiple; /** * 状态变化回调回传当前展开项 id 集合 */ onExpandedChange?: (expandedIds: string[]) void; /** * 是否开启动画 */ animated?: boolean; animationDuration?: number; }这是“非受控 事件通知”的正常姿势。如果传了 expandedIds 就切换到受控模式由外部负责状态管理内部只上报点击事件如果没传 expandedIds则内部维护 defaultExpandedIds 作为初始展开项。有一个坑必须在这里提前说不要同时维护内部 state 又支持受控 props然后“试图”把它们同步起来。我见过很多组件最后写成这样受控 props 变了先放到内部 state内部 state 一变又回调外部外部再改 props一个组件五个 write 路径根本没法排查。半受控的正确做法就是状态源二选一。要么外部给我 expandedIds 我完全不管状态要么外部不给我就用内部 state回调只作为通知推动外部副作用而不是拿回去再写一遍。2.2 布局测量与内容区动画折叠面板动画的核心不是“显示/隐藏”切换而是“高度从 0 到 contentHeight 再回 0”的平滑过渡。在 React Native for Harmony 上要特别小心内容高度测量。RN 把高度分成两类固定高度和动态高度。企业级 Accordion 的内容几乎都是动态的因为折叠面板内部可能是表单、富文本、图片列表甚至另一个 Accordion。我不能在 JS 里凭空知道内容区应该有多高必须等原生端完成布局之后通过 onLayout 事件拿到实际高度。这类逻辑的标准做法是在内容区包裹一个 View设置 collapsable{false}const Content ({ visible, children, duration }: { visible: boolean; children: React.ReactNode; duration: number; }) { const [contentHeight, setContentHeight] useState(0); const [animatedHeight] useState(new Animated.Value(0)); const handleLayout (event: LayoutChangeEvent) { const height event.nativeEvent.layout.height; setContentHeight(height); }; useEffect(() { Animated.timing(animatedHeight, { toValue: visible ? contentHeight : 0, duration, easing: Easing.inOut(Easing.ease), useNativeDriver: false, }).start(); }, [visible, contentHeight, duration, animatedHeight]); return ( Animated.View style{{ height: animatedHeight, overflow: hidden }} collapsable{false} View onLayout{handleLayout} collapsable{false} {children} /View /Animated.View ); };这里有两个必须注意的点。第一useNativeDriver 必须为 false。因为 height 不是 transform/opacity在 RN 原生动画驱动下没法直接改布局属性。Harmony 端跟 Android/iOS 一样这类属性只能在 JS 驱动下不断回调更新。第二外层动画容器的 height 在动画过程中会从 0 到目标值内层内容区需要真实布局得到 contentHeight所以内层不能通过 overflow 裁剪来影响布局。有些同学为了省事直接让内容区动态切换 display none 模拟动画这在企业级场景里不可取没有过渡动画的折叠面板交互质感会差很多。还有一种常见做法是使用 maxHeight 做近似动画比如 maxHeight 从 0 到一个很大的值比如 2000。这个方案的问题也明显动画时长跟实际内容高度不匹配。高度小则过头高度大则突然跳变企业级体验说不过去所以我最终还是选 onLayout 精确测量方案。2.3 图标旋转、禁用态、无障碍的细节处理企业级组件的成败往往在这些小细节上。标题栏的箭头图标需要用旋转动画表达状态变化旋转角度用 Animated 搞定。这里要注意 React Native for Harmony 对 transform 动画支持正常可以用 useNativeDriver: true。但如果跟布局动画混在同一节点上会触发 “cannot mix native and js driver” 的警告所以箭头动画和内容高度动画不要放在同一个 Animated.View 上要拆开。禁用态的处理比想象中复杂。如果一个折叠面板项 disabled它不只是不能点击还要考虑视觉态灰度图标以及无障碍辅助下跳过该项。企业项目里有权限控制的动作必须禁用相关折叠面板比如未开通某项服务的用户设置里对应的高级配置组应该不可展开且不可见不对应该可见但置灰并提示欠缺权限。我在这个组件里加了 disabled 状态和 headerExtra 插槽让业务可以在标题栏右侧放“前往开通”的按钮。无障碍方面折叠面板需要把“展开/收起”语义暴露给辅助功能。React Native 自带的 accessible、accessibilityRole、accessibilityState 在 Harmony 端需要逐个验证支持度。实际测试中发现 accessibiltyState 中的 expanded 字段在部分 Harmony 版本上存在兼容问题稳妥做法是同时用 accessibilityState 和 accessibilityLabel 把“当前已展开/已收起”拼进标签里给读屏用户明确提示。细节心理企业级组件就是要处理这些看不出来、但真实用户摸得着的边界情况。企业软件的用户不一定会给好评但折叠面板卡顿、读屏读不出来、按钮点了没反应一定会被记录到问题单里。3. 核心实现与实操过程3.1 基础 Accordion 组件骨架在实现之前先约定组件的目录结构。我习惯把 Accordion 做成一个独立 npm 包内部结构是这样的src/ Accordion.tsx AccordionItem.tsx AccordionContext.ts hooks/ useAccordionState.ts useAnimatedContent.ts types.ts __tests__/这样拆分是为了让组件本身可单独测试并且后续接主题系统、国际化都不用改核心逻辑。Accordion.tsx 只负责组装数据把标题栏渲染和内容区渲染交给 AccordionItem。AccordionContext 负责在折叠项之间共享模式配置避免通过 props 层层透传 disabled、duration、theme 这些配置。核心状态逻辑放到 useAccordionStatefunction useAccordionState( mode: single | multiple, defaultExpandedIds?: string[], expandedIdsProp?: string[] ) { const [internalExpandedIds, setInternalExpandedIds] useStatestring[]( defaultExpandedIds ?? [] ); const isControlled expandedIdsProp ! undefined; const expandedIds isControlled ? expandedIdsProp : internalExpandedIds; const toggleExpanded useCallback( (itemId: string) { const nextIds expandedIds.includes(itemId) ? expandedIds.filter((id) id ! itemId) : mode single ? [itemId] : [...expandedIds, itemId]; if (!isControlled) { setInternalExpandedIds(nextIds); } return nextIds; }, [expandedIds, isControlled, mode] ); return { expandedIds, toggleExpanded }; }这里的 toggleExpanded 返回值设计成 nextIds 而不是只返回 void好处是父级受控场景下可以在回调里直接拿到最新值不用从闭包里猜const handleToggle (id: string, nextIds: string[]) { // 埋点上报 track(accordion_toggle, { id, expanded: nextIds.includes(id) }); onExpandedChange?.(nextIds); };3.2 手风琴模式与多展开模式切换手风琴模式single是折叠面板最经典的企业级用法。在 single 模式下点击一个已展开项时收起自己点击另一个收起项时则展开它并收起之前所有展开项。代码逻辑就是const nextIds expandedIds.includes(itemId) ? [] : [itemId];这里有个边界场景如果所有展开项都收起是业务允许的。有些设计稿要求手风琴模式下至少保留一项展开那要在外部回调里拦截掉“全部收起”的请求并强制回弹到某个默认 id。这个逻辑应放在业务侧而不是组件侧。因为组件不知道业务到底允不允许全部收起。multiple 模式则简单一些就是一个 id 数组的增删。要注意企业级表格类页面经常有“展开全部/收起全部”的工具栏需求。这个不要在多个 AccordionItem 上逐个调方法直接通过 props 改变受控 expandedIds 就行。如果组件是非受控的需要外部强制改状态时建议通过 refs 暴露 reset、expandAll、collapseAll 三个命令式方法。这里多说一句命令式方法的实现不要直接改内部 state 完事要跟 toggleExpanded 保持同一出口保证 onExpandedChange 回调也会触发业务埋点不会丢。3.3 组件通信的几个关键路径折叠面板内部的通信实际上就是父传子、子传父两条路径。父传子比较容易理解折叠面板父组件把 items 数组、mode、动画时长通过 props 传给 AccordionAccordion 内部通过 AccordionContext 把 expandedIds 和 toggleExpanded 传给每个 AccordionItemAccordionItem 再通过 children render props 把展开状态传给用户自定义内容区。这些层级在视觉上是嵌套的在数据流上却是自上而下的一根线。子传父要复杂一点因为顺流而下的是 id 字符串逆流而上的是“我点击了这个标题”这个事件。按照 React 的规矩子组件不直接改父组件的数据而是通过 onPress 调用上下文里的 toggleExpanded再由 Accordion 统一计算 nextIds 并回调给最外层的 onExpandedChange。我踩过的坑是在 AccordionItem 内部直接调用 toggleExpanded 然后又在 Accordion 里注册 onExpandedChange 回调而且回调里如果修改了父级的 state 并把它传回 expandedIds props容易触发 “Cannot update a component while rendering a different component” 的警告。解决办法是让回调在事件处理函数里同步调不要包一层 useEffect 延迟不然容易形成渲染死循环。另外还有一个很容易被忽略的通信场景折叠面板在 Modal 或 Form 里面时父组件在某个异步事件后要拿到当前展开状态比如用户填完表单点击下一步。我不建议父组件通过 ref 强行获取 Accordion 内部状态这种命令式读取破坏了单向数据流状态来源会变得混乱。更好的做法是让父组件始终维护一份 expandedIds state通过受控模式把状态提升上来。这也是企业级场景我推荐使用受控模式的原因状态不透明在复杂业务里会变成技术债。3.4 长列表滚动场景优化设置页和帮助中心经常是折叠项 滚动列表的组合。如果折叠面板列表长度超过 20 项建议不要用 ScrollView 包 map 渲染而是用 FlatList。但 FlatList 内部的 item 高度在展开后会变化需要额外处理。一个折中方案是外层 FlatList每个 row 渲染一个受控折叠项当某个折叠项展开时通过 onExpandedChange 拿到 id然后给展开的项设置一张固定高度表配合 calculateItemLayout 或 getItemLayout 避免滚动跳动。具体到 RN for Harmony还需要做性能优化。折叠面板的每个 row 都用 React.memo 包裹并且给 onExpandedChange 一个稳定引用用 useCallback 缓存const AccordionRow React.memo(function AccordionRow({ section, isExpanded, onToggle, }: { section: AccordionSection; isExpanded: boolean; onToggle: (id: string) void; }) { return ( AccordionItem title{section.title} expanded{isExpanded} onPress{() onToggle(section.id)} {section.content} /AccordionItem ); });这里的 onToggle 通过 useCallback 包裹const handleToggle useCallback( (id: string) { const nextIds expandedIds.includes(id) ? expandedIds.filter((item) item ! id) : mode single ? [id] : [...expandedIds, id]; setExpandedIds(nextIds); onExpandedChangeRef.current?.(nextIds); }, [expandedIds, mode] );有件事要解释一下为什么要用 onExpandedChangeRef.current 而不是直接把 onExpandedChange 放进依赖数组。因为在 FlatList 里如果 onExpandedChange 是父组件每次 render 新建的函数useCallback 的依赖会频繁变化memo 的作用就废了。用 ref 保存最新回调既保证调用到最新 props又不破坏 memo。3.5 折叠面板的样式体系与主题适配企业级组件库很少只有一个固定样式。深色模式、品牌主题、高对比度模式都需要适配。我的做法是定义一套 ThemeShape给 Accordion 提供 useTheme hookexport interface AccordionTheme { backgroundColor: string; titleColor: string; contentColor: string; borderColor: string; arrowColor: string; headerHeight: number; iconSize: number; fontWeight: normal | medium | bold; }样式解析时不做全局污染。折叠面板的所有样式都用 StyleSheet.create 生成带前缀的类名并且通过组件级 context 传递主题而不是依赖全局变量。如果是为 App 定制主题可以通过 props 传一个 theme 对象覆盖默认值。这样一套组件在多个业务线间复用才不会打架。Harmony 端某些系统字体渲染宽度和 Android 不同标题栏文字如果写死宽度就会出现文字截断。我的建议是标题栏不设固定高度用 padding 撑开并且标题文字 numberOfLines{1}剩余宽度由 headerExtra 插槽自适应。折叠动画的高度也是测量出来的不依赖固定行高。4. 常见问题与排查技巧实录4.1 动画不执行或高度跳变最常见的现象是展开时内容没有动画直接“啪”一下出来。这种情况几乎都是 onLayout 没有触发或者 contentHeight 为 0 导致的。排查思路先给 Content 容器临时加背景色展开之后如果内容区背景高度直接覆盖整个区域说明 onLayout 拿到了高度但动画容器没生效如果背景色只有一行高度说明内容没有测量成功。内容测量不成功通常是因为内容区在折叠状态时被 overflow 裁剪或者外层容器的 height 为 0导致内部没有被布局。另外一个在 Harmony 上更隐蔽的问题是react-native-harmony 对 Animated.View 的布局属性支持不完整。实测中发现如果 Animated.Value 初始值为 0而动画目标值很大动画时长又短可能出现结束手势结束后仍有几帧残留。解决方案是动画结束时把 animatedHeight 设为精确终点值并在动画结束后用 setNativeProps 强制赋值一次。4.2 点击事件穿透与触摸区域问题折叠面板标题栏点击没反应在 Harmony 上排在问题榜前几名。原因很多最常见的是父级 ScrollView 的 onScroll 手势抢占了子组件的触摸响应在 RN 层表现为 TouchableOpacity 的 onPress 偶发不触发。我的建议是使用 Pressable并搭配 hitSlop 和 pressRetentionOffsetPressable onPress{...} hitSlop{{ top: 8, bottom: 8, left: 12, right: 12 }} accessibilityRolebutton accessibilityState{{ expanded: isExpanded }} ... /Pressable在企业级折叠面板里点击目标太小真的会挨骂。标题栏高度最好不小于 44 点右侧箭头或操作区也不小于 32 点。如果面板放在表格行里还要注意多选/双击事件冲突折叠面板标题点击和行选择事件不要绑定同一个节点。4.3 状态不同步与“幽灵展开”组件在受控和非受控之间切换导致“幽灵展开”明明外部 expandedIds 传的是空数组界面上却有一个面板是展开的。这类问题根源大多是内部 state 没有按外部 props 重置。处理时给出一个确定规则如果组件接收了 expandedIds props就完全不使用内部 state。外部 expandedIds 什么值就渲染什么状态。如果外部希望“从某一时刻重置为全部收起”那么它只需要把 expandedIds 改为 []组件自然回收。不需要在组件内部加“当 props 变化时同步 state”的逻辑这种逻辑只在非受控转受控的过渡期有存在价值写多了只会增加混乱。4.4 启动白屏和初始化阻塞这个热搜词“react native 启动白屏”在我们做 Harmoy 端 RN 混合应用时还是很有现实意义的。折叠面板组件本身不直接导致白屏但企业级页面经常在启动时同时挂载多个高频组件如果每个折叠面板在注册、测量、动画初始化上都做了一堆同步操作会拉长首屏时间。解决方案是组件级 lazy render。折叠面板默认折叠状态时不渲染内容区内容只渲染标题栏直到第一次展开时才挂载内容。这样做能显著降低首屏渲染节点数const renderContent isExpanded || hasExpandedOnce;我习惯用 hasExpandedOnce 做一个“首次展开后保持渲染”的 flag。这样折叠面板在动画收起时不销毁内容后续展开时 onLayout 也能直接拿到缓存高度不需要二次测量。这个策略有些同学会担心内存占用只在折叠面板数量巨大时才考虑卸载比如超过 50 个且每个内容里都有大量图片。如果只有几屏内容首次展开后保持渲染是最优解。4.5 组件化工程里的回归与测试折叠面板是交互状态多的组件容易回归。我在项目里维护了三类测试第一单测。用 Jest React Test Renderer 覆盖状态逻辑single 模式切换、multiple 展开累积、非受控默认展开、受控后外部 props 更新。这里要特别注意模拟 onLayout 事件否则 contentHeight 一直是 0组件永远不展开。第二组件测试。用 React Native Testing Library 模拟点击标题栏断言 onExpandedChange 回调参数。这类测试能抓住“点击事件被内部 useCallback 缓存后拿不到最新状态”的问题。第三真机目测。Harmony 的折叠面板单测过不代表真机过动画流畅度、点击热区、无障碍朗读都要过一遍。我的经验是使用 2~3 台不同尺寸的鸿蒙设备分别测试小字体、大字体、无障碍放大下的布局再跑一遍常见页面滚动场景。5. 经验收尾与扩展思路折叠面板这个组件看起来简单但牵涉到的学问真的不少从状态模式、布局测量、动画驱动到触摸响应、无障碍、长列表性能每一个环节在企业级场景里都能挖出坑来。尤其是 react-native-harmony 这种兼容层方案组件写完之后一定要在真机上反复验证不能在 Android 上跑通就默认鸿蒙没问题。实测下来像 onLayout 触发时机、Pressable 的触摸竞争、Animated 的 JS driver 表现都有过跟真机表现不一致的细节。最后再分享一个小技巧。很多企业级的折叠面板不只是展开收起还要跟路由“联动”。比如帮助中心首页折叠面板的某一个分组展开后用户刷新页面希望还停留在刚才展开的分组。我通常会把 expandedIds 序列化到 URL query 或本地存储。这个需求看起来很简单但如果你不在 Accordion 的回调里统一收口“埋点 持久化”等散落到多个业务函数里再想收回就非常痛苦了。在组件层就把 onExpandedChange 做成一个统一的副作用出口后面接入任何外部系统都是加一个订阅者的事。这个习惯做企业级组件的人都应该养成。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →