React Native for OpenHarmony 实战:三方库 react-native-raw-bottom-sheet 的鸿蒙化适配指南
本文记录把 react-native-raw-bottom-sheet(底部弹出面板 / Bottom Sheet)适配到 HarmonyOS 的完整过程。
它做的事很专一:从屏幕底部滑出一块指定高度的面板(带半透明遮罩),点遮罩关闭、可选支持下拉手势与顶部"拖拽条"。
结论:这是目前遇到的最干净的一个库 —— 零依赖、零 peer、九项老写法全空、函数组件、四组几何像素级精确命中,<Modal> 在 RNOH 上完全可用。
只有一个小行为值得注意:close() 不"幂等"(没打开时调用它也会触发 onClose)。
★★★★★ 一、四组几何像素级命中(height 是布局属性,dump 直接可量)
关键设计(src/index.js):
const {height = 260, openDuration = 300, closeDuration = 200, useNativeDriver = false, …} = props;
const animatedHeight = useRef(new Animated.Value(0)).current;
// 打开
Animated.timing(animatedHeight, {useNativeDriver, toValue: height, duration: openDuration}).start();
// 关闭
Animated.timing(animatedHeight, {useNativeDriver, toValue: 0, duration: closeDuration}).start(cb);
…
<Animated.View style={[styles.container, {height: animatedHeight}, …]}>
而 styles.container 是:
container: { backgroundColor: '#fff', width: '100%', height: 0, overflow: 'hidden' }
⇒ 面板高度 = height prop(vp)、宽度 = '100%' = 屏宽 ✓
⇒ 而且 useNativeDriver 默认是 false ⇒ 高度变化【进布局】⇒ 布局 dump 直接可量 ✓✓
⇒ 拖拽条:draggableIcon: {width: 35, height: 5, borderRadius: 5, margin: 10} ✓
实测(density 3;屏 1320×2232):
| 对象 | 参数 | 公式 | 预言 | 实测(布局 dump) |
|---|---|---|---|---|
| S1 面板 | height={200} | 200vp × 3 / '100%' | 600 × 1320px | ✅ h=600 w=1320 y=1632 |
| S2 面板 | height={300} | 300vp × 3 / '100%' | 900 × 1320px | ✅ h=900 w=1320 y=1332 |
拖拽条(draggable) | draggableIcon 35×5 | 35vp/5vp × 3 | 105 × 15px | ✅ 105x15 y=1662 |
| 遮罩 | styles.wrapper flex:1 | 全屏 | 1320 × 2232px | ✅ 1320x2232 |
⇒ 四组全部精确命中 ✓✓
⇒ 顺带一个"合理"的现象:关闭状态下也能量到 1320×2232 的全屏块 —— 那是测试页自己的根 View(flex:1),不是 Modal 的遮罩(遮罩只在 Modal visible 时存在)✓
★★★★ 二、<Modal> 在 RNOH 上完全可用(本轮最值得验证的一点)
这个库的整棵子树都挂在 RN 的 <Modal> 里:
<Modal testID="Modal" transparent visible={modalVisible} onRequestClose={…}>
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : 'height'} …>
<TouchableOpacity style={styles.mask} … /> {/* 遮罩 */}
<Animated.View style={[styles.container, {height: animatedHeight}]}>…</Animated.View>
</KeyboardAvoidingView>
</Modal>
实测(相位驱动):
| 相位 | 动作 | dump 结果 |
|---|---|---|
| P0 | 两个实例都关闭 | 没有面板 ✓ |
| P1 | S1.open() | 面板 600×1320 + 拖拽条 105×15 ✓ |
| P2 | S1.close() | 面板消失 ✓ |
| P3 | S2.open() | 面板 900×1320 ✓ |
| P4 | S2.close() | 面板消失 ✓ |
⇒ <Modal> 的 transparent / visible 开关、子树渲染、层级都正常 ✓✓
⇒ 而且 Modal 里的节点坐标是屏幕坐标(y=1632 正好在屏幕底部上方 600px)⇒ 量起来很直接 ✓
★★★★ 三、useNativeDriver 写对了:显式 false,且没有告警
useNativeDriver = false, // ★ 默认值就是 false(props 里可覆盖)
…
Animated.timing(animatedHeight, {useNativeDriver, toValue: height, duration: openDuration}).start();
Animated.spring(pan, {toValue: {x: 0, y: 0}, useNativeDriver}).start();
Animated.event([null, {dy: pan.y}], {useNativeDriver})(e, gestureState);
⇒ 5 处用到 useNativeDriver 的地方全部显式传值 ✓
⇒ 实测:LogBox 里【没有任何通知】 ✓✓
⇒ 这与"Animated 未指定 useNativeDriver 会常驻一条通知"的情况正好相反 —— 这个库的处理方式是正面的样本 ✓
⇒ 也正因为 false,高度动画才会进布局(才能被 dump 量到)✓
★★★ 四、一个值得注意的行为:close() 不"幂等"
const handleSetVisible = visible => {
if (visible) { … } else {
Animated.timing(animatedHeight, {useNativeDriver, toValue: 0, duration: closeDuration}).start(() => {
setModalVisible(false);
pan.setValue({x: 0, y: 0});
if (typeof onClose === 'function') { onClose(); } // ★ 无条件调用
});
}
};
useImperativeHandle(ref, () => ({
open: () => handleSetVisible(true),
close: () => handleSetVisible(false),
}));
⇒ close() 分支里没有任何"当前是否已打开"的判断 ⇒ 在从未打开过的实例上调用 close(),也会走完动画并触发 onClose ✓
实测(P0 就是"两个实例都关闭"的相位,而我的 P0 动作是对二者各调一次 close()):
===== P0:全部关闭(dump 里不应有面板) =====
EVENT | S1 onClose(第 1 次) ← S1 从未打开过,却触发了 onClose
EVENT | S2 onClose ← 同上
…
EVENT | S1 onOpen(第 1 次) ← P1
EVENT | S1 onClose(第 2 次) ← P2
EVENT | S2 onOpen ← P3
EVENT | S2 onClose ← P4
⇒ 回调的次数会比你预期的多("从没开过 ⇒ 不会有 close"这个直觉不成立)✓
⇒ 实践建议:在 onClose 里做收尾逻辑时,自己记一个"之前是否真的打开过"的标志位,别默认它只在"真的关闭"时触发 ✓
★★★ 五、其余预检结论(都很干净)
| 预检项 | 结果 |
|---|---|
| 依赖 | ✅✅ dependencies 与 peerDependencies 皆空;import 图只有 react + react-native |
| 老写法九项 | ✅✅ 全部为空(现代函数组件:forwardRef + useImperativeHandle + useState + useRef) |
measure() / measureInWindow / onLayout / findNodeHandle / NativeModules | ✅ 全空 ⇒ 零环境坑 |
| 平台分支 | ✅ 仅 1 处且安全:behavior={Platform.OS === 'ios' ? 'padding' : 'height'} ⇒ 鸿蒙取 'height' |
useNativeDriver | ✅ 显式 false(5 处全传)⇒ 无告警 |
| 手势 | ✅ 用 RN 核心的 PanResponder(onStartShouldSetPanResponder: () => draggable)⇒ 纯 JS、无原生依赖 |
scripts | ✅ 无 prepare/postinstall |
契约(反射):
NOTE | 默认导出的 typeof = object
PASS | ★ 默认导出是 forwardRef 结果(typeof 为 object,不是 function)
NOTE | $$typeof 的字符串 = Symbol(react.forward_ref)
PASS | ★ render 存在(forwardRef 的渲染函数)
PROBE | S1 ref 可得 = true / typeof open = function / typeof close = function
⇒ forwardRef 的结果 typeof 是 'object' 而不是 'function' —— 如果你按"组件必须是 function"写断言,这条会误判 ✓




六、交付包质量与基线说明
| 类别 | 结果 |
|---|---|
| 交付包内容 | index.js(57 B,re-export)+ src/index.js(5290 B)+ src/style.js(603 B) + index.d.ts ⇒ 源码可读 ✓ |
spec.json | ⚠️ 缺 upstreamCommit |
validation | ⚠️ "build-pass-device-pending" |
| ⚠️ 声明的基线 | reactNative: "0.88.0" + rnoh: "0.84.4"(verification: evidence/batch50/…)⇒ 两字段自相矛盾,且既不是当前宿主(0.84.1/0.84.3)也不是权威 0.88 基线 |
| 自带测试 | __tests__/RBSheet.test.js + 鸿蒙契约测试(契约测试只校验包名/版本/入口/中文说明) |
关于基线:本次验证在 RN 0.84.1 + RNOH 0.84.3 上完成。这个库没有任何已移除的写法,
行为只依赖 Modal / Animated / PanResponder / KeyboardAvoidingView ⇒ 结论跨基线成立。
七、已知限制与本次验证边界
7.1 用这个库要知道的
| 事项 | 说明 |
|---|---|
★★★ close() 不幂等 | 没打开时调用也会走完动画并触发 onClose ⇒ onClose 里做收尾要自己记标志位 |
★★★★ 面板尺寸就是 height prop | 高度 = height vp;宽度 = '100%'(屏宽);默认 height=260 |
★ useNativeDriver 默认 false | 所以高度动画进布局;想改成原生驱动要自己确认驱动的是不是纯 transform/opacity |
| ★ 打开时机 | open() 会先 setModalVisible(true)(Modal 才挂载),再跑高度动画;onOpen 在动画【之前】同步触发 |
| ★ 关闭是"动画完再卸载" | close() 的 onClose 在动画结束回调里触发(closeDuration 默认 200ms) |
★ draggableIcon 固定 35×5、margin 10 | 可用 customStyles.draggableIcon 覆盖 |
| ★ 拖拽默认不生效 | draggable 默认 false;onStartShouldSetPanResponder: () => draggable |
| ★ 拖拽条与内容只能二选一挂手势 | dragOnContent 决定 panHandlers 挂在内容上还是拖拽条上 |
| ★ 一处平台分支 | KeyboardAvoidingView 的 behavior:iOS 用 'padding',其余(含鸿蒙)用 'height' |
★ customStyles 只能覆盖 4 个槽位 | wrapper / container / draggableIcon(另有 mask 未接) |
7.2 本次验证的边界
- ★★ 手动按钮(
Text+onPress)点击后没有产生任何事件 ⇒ 可能是 RNOH 上Text.onPress不生效,也可能是我的点击未命中按钮 ⇒ 未验证; - ★ 拖拽手势未验证(
draggable的渲染部分已验证:拖拽条 105×15px 出现 ✓;但下拉关闭/回弹需要真实手势); - 未验证
closeOnPressMask(点遮罩关闭); - 未验证
closeOnPressBack(Android 硬件返回键 —— 鸿蒙上本就无此键); - 未验证
dragOnContent; - 未验证
customStyles/customModalProps/customAvoidingViewProps的覆盖效果; - 未验证
openDuration/closeDuration的精确时序(只知onClose在动画后触发); - 未验证
height默认值 260 的实际渲染(我显式传了 200 / 300);也未验证children默认<View />; - 未验证 同时打开两个实例时的层叠行为;
- 未在
RN 0.88.0/RNOH 0.84.4上复验(交付包声明的基线); - 未测其它屏幕宽度;
- 未做跨平台对照。
未改动库代码。
八、常见问题
Q1:它在 RN 0.84 上能跑吗?
能,而且很干净。 <Modal> 正常、面板几何四组精确命中、onOpen/onClose 回调正常、LogBox 零通知。
Q2:面板高度怎么控制?
就是 height prop(单位 vp):
<RBSheet ref={ref} height={200} /> // ⇒ 面板 200vp 高(density 3 ⇒ 600px)、宽为屏宽
Q3:onClose 好像被多调用了一次?
对 —— close() 不幂等:即使实例从未打开过,调用 close() 也会走完 closeDuration 的动画并触发 onClose。
⇒ 在 onClose 里做收尾时,自己维护一个"是否真的打开过"的标志位。
Q4:为什么我调的 open() 没反应?
检查 ref 有没有真的接上(组件是 forwardRef,只暴露 open() / close() 两个方法);
也可以用 ref 的可取性做判据:typeof ref.current?.open === 'function'。
Q5:怎么让底部出现"拖拽条"?
<RBSheet draggable={true} … />
拖拽条是 draggableIcon:35×5vp、圆角 5、margin 10(density 3 ⇒ 105×15px,实测一致 ✓)。
Q6:下拉手势为什么不生效?
draggable 默认 false;而且真正挂 panHandlers 的位置取决于 dragOnContent:
dragOnContent=false(默认)⇒ 手势挂在拖拽条上;dragOnContent=true⇒ 手势挂在内容容器上(且要求gestureState.dy > 0)。
另外useNativeDriver默认false⇒ 手势更新走 JS 侧。
Q7:为什么布局 dump 里能量到面板?
因为 useNativeDriver 默认 false,而它驱动的是 height(布局属性) ⇒ 高度变化会反映到布局上。
⚠️ 反之,如果某个库用 transform 或 opacity 做动效,dump 就量不到。
Q8:想换成原生驱动可以吗?
可以传 useNativeDriver={true},但要确认被驱动的属性支持原生驱动(本库驱动 height ⇒ 不建议)。
小结
react-native-raw-bottom-sheet 是零依赖、零 peer、现代函数组件的底部面板库(forwardRef + useImperativeHandle),
九项老写法全空,设备侧表现完全正常,四组几何像素级命中,LogBox 零通知。
最值得记下来的是这几条:
-
★★★★★ 四组几何全部精确命中。
面板height=200⇒ 600×1320px、height=300⇒ 900×1320px、拖拽条 ⇒ 105×15px、遮罩 ⇒ 1320×2232px ✓✓
⇒ 关键在于height是布局属性 +useNativeDriver为false⇒ 高度变化进布局、dump 可量 ✓ -
★★★★
<Modal>在 RNOH 上完全可用。
transparent/visible开关、子树渲染、层级都正常;而且 Modal 内节点用屏幕坐标,量起来很直接 ✓ -
★★★★
useNativeDriver显式传false的正面样本。
5 处全传 ⇒ LogBox 零通知 ✓ —— 与"未指定useNativeDriver会常驻一条通知"的情况正好相反 ✓ -
★★★ 一个真实的行为发现:
close()不幂等。
源码close分支里没有"当前是否已打开"的判断 ⇒ 在从未打开过的实例上调用close()也会触发onClose
(实测 P0 阶段两个实例都没开过,却各触发了一次onClose)⇒onClose里做收尾要自己记标志位 ✓ -
★★★ 平台分支只 1 处且安全:
KeyboardAvoidingView的behavior用Platform.OS === 'ios' ? 'padding' : 'height'⇒
鸿蒙自然落到'height'✓ —— 二元平台判断在"第四平台"上天然安全 ✓ -
★★ 契约反射要记得
forwardRef的typeof是'object'。
Symbol(react.forward_ref)+render才是它的特征;按"组件必须是function"写断言会误判 ✓ -
★ 一次时序漂移的教训与补救:自动时间轴采样时会漂(我第一次漏掉了 900px 那一档),
补救办法是重启后在目标相位的窗口内密集采样(本次采到两次P3,都给出h=900 w=1320)✓ -
⚠️ 一处未验证要如实写:页面上的手动按钮(
Text+onPress)点击后没有产生任何事件 ⇒
可能是 RNOH 上Text.onPress不生效、也可能是我点击未命中 ⇒ 未验证,不写成结论 ✓
本篇用到的库
| 项 | 内容 |
|---|---|
| 三方库 | react-native-raw-bottom-sheet(上游 3.0.0 的鸿蒙适配版) |
| 适配仓库 | https://atomgit.com/oh-react-native/react-native-raw-bottom-sheet |
| 适配 TAG | 3.0.0-ohos-1.0.0 |
| 需要 HAR / 权限 / ohpm | 都不需要(纯 JS,手势用 RN 核心 PanResponder) |
| 库类型 | 底部弹出面板(Bottom Sheet) |
| 运行时依赖 | ✅✅ 无(dependencies 与 peerDependencies 皆空) |
prepare / postinstall | ✅ 都没有 |
| 许可证 | MIT |
| 上游仓库 | https://github.com/nysamnang/react-native-raw-bottom-sheet(★1203) |
| 宿主工程 | RNOH084Demo(测试页 rnAppKey = RawBottomSheetTestApp) |
# 从适配仓库安装(零依赖)
npm install "git+https://atomgit.com/oh-react-native/react-native-raw-bottom-sheet.git#3.0.0-ohos-1.0.0" --ignore-scripts
import RBSheet from 'react-native-raw-bottom-sheet';
const ref = useRef(null);
<RBSheet
ref={ref}
height={260} // 面板高度(vp)⇒ density 3 时 ×3 得像素
openDuration={300}
closeDuration={200}
closeOnPressMask // 点遮罩关闭(默认 true)
closeOnPressBack={false} // Android 硬件返回键(默认 false)
draggable={false} // 打开后顶部出现拖拽条(35×5vp)
dragOnContent={false} // 手势挂在拖拽条还是内容上
useNativeDriver={false} // 默认 false ⇒ 高度动画进布局
customStyles={{container: {borderTopLeftRadius: 12, borderTopRightRadius: 12}}}
onOpen={() => console.log('打开了')}
onClose={() => console.log('关闭了 —— ⚠️ 未打开过时调用 close() 也会触发')}>
<View style={{padding: 16}}><Text>面板内容</Text></View>
</RBSheet>
// ref.current.open(); // 打开
// ref.current.close(); // 关闭(⚠️ 不幂等)
// ⚠️ 面板尺寸 = height vp(高)× 屏宽('100%')
// ⚠️ onOpen 在高度动画【之前】同步触发;onClose 在【动画结束后】触发
// ⚠️ close() 未做"是否已打开"判断 ⇒ onClose 可能被多触发
// ⚠️ 一处平台分支:KeyboardAvoidingView 的 behavior(iOS=padding、其余=height)
# 换页启动测试页 —— force-stop 不能省
hdc shell aa force-stop com.rnoh084.demo
hdc shell aa start -b com.rnoh084.demo -a EntryAbility --ps rnAppKey RawBottomSheetTestApp
判据的复现方式
# ① 契约 / 回调:逐条 PASS/FAIL 日志
hdc shell "hilog -x | grep rbs-test" | Select-String 'PASS|FAIL|SUMMARY|NOTE|PROBE|EVENT'
# ⇒ forwardRef(typeof=object、$$typeof=Symbol(react.forward_ref))
# ⇒ S1 onOpen/onClose、S2 onOpen/onClose 的顺序
# ② 几何:面板高 = height prop × 3,宽 = 1320(屏宽)
devecocli ui layout | Select-String '\[(\d+),(\d+),(\d+),(\d+)\]'
# ⇒ S1(height=200)→ h=600 w=1320;S2(height=300)→ h=900 w=1320
# ⇒ 拖拽条 105×15;遮罩 1320×2232
# ③ LogBox:本库【零通知】(useNativeDriver 显式 false)
devecocli ui layout | Select-String 'logbox'
验证环境
| 项 | 版本 |
|---|---|
| React Native | 0.84.1 |
| React | 19.2.3 |
| RNOH(npm / ohpm) | @react-native-oh/react-native-harmony / @rnoh/react-native-openharmony 0.84.3 |
| Node.js / npm | v24.14.0 / 11.9.0 |
| DevEco Studio | 26.0.0.621 |
| HarmonyOS SDK | API 26(26.0.0.32) |
| 设备 | HarmonyOS 7.0.0(26.0.0) Beta2 模拟器 Pura X View(ohos-x64),1320×2232,density 3 |
| 本机状态栏高度 | 117px = 39vp |
| 宿主依赖 | ✅ 无需补装(零依赖) |
| 宿主 HAP 产物 | entry-default-signed.hap |
| 本次构建 | assembleHap 3 分 34 秒 ⇒ 一次成功(打包与构建退出码都检查了) |
| 验证规模 | 几何 4 组像素级命中(600/900×1320 面板、105×15 拖拽条、1320×2232 遮罩);契约反射(forwardRef 特征 + ref.open/close);回调链路 5 个相位全对;LogBox 零通知;4 张截图互异 |
⚠️ 基线说明:交付包
spec.json声明RN 0.88.0 + RNOH 0.84.4(两字段自相矛盾),本次验证在RN 0.84.1 + RNOH 0.84.3上完成。
本库无任何已移除写法,行为只依赖Modal/Animated/PanResponder⇒ 结论跨基线成立。
相关社区与项目地址
| 项目 | 地址 |
|---|---|
| React Native 鸿蒙官方社区(CPF-RN) | https://atomgit.com/CPF-RN |
| RNOH 框架仓库 | https://atomgit.com/openharmony-RN/ohos_react_native |
| RNOH 三方库使用文档 | https://atomgit.com/OpenHarmony-RN/usage-docs |
| React Native for OpenHarmony 组织 | https://atomgit.com/oh-react-native |
| RN 三方库鸿蒙适配清单 | https://atomgit.com/oh-react-native/rn-ohos-adaptation-overview |
欢迎加入 CPF-RN 鸿蒙社区:https://atomgit.com/CPF-RN
React Native for OpenHarmony 组织:https://atomgit.com/oh-react-native
RN 三方库鸿蒙适配清单:https://atomgit.com/oh-react-native/rn-ohos-adaptation-overview
更多推荐


所有评论(0)