本文记录把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;constanimatedHeight=useRef(newAnimated.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'}⇒面板高度 =heightprop(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()不"幂等"
consthandleSetVisible=visible=>{if(visible){…}else{Animated.timing(animatedHeight,{useNativeDriver,toValue:0,duration:closeDuration}).start(()=>{setModalVisible(false);pan.setValue({x:0,y:0});if(typeofonClose==='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+ 鸿蒙契约测试(契约测试只校验包名/版本/入口/中文说明) |
关于基线:本次验证在RN0.84.1+ RNOH0.84.3上完成。这个库没有任何已移除的写法,
行为只依赖Modal/Animated/PanResponder/KeyboardAvoidingView⇒结论跨基线成立。
七、已知限制与本次验证边界
7.1 用这个库要知道的
| 事项 | 说明 |
|---|---|
★★★close()不幂等 | 没打开时调用也会走完动画并触发onClose⇒onClose里做收尾要自己记标志位 |
★★★★ 面板尺寸就是heightprop | 高度 =heightvp;宽度 ='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:面板高度怎么控制?
就是heightprop(单位 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) |
# 从适配仓库安装(零依赖)npminstall"git+https://atomgit.com/oh-react-native/react-native-raw-bottom-sheet.git#3.0.0-ohos-1.0.0"--ignore-scriptsimport 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-bcom.rnoh084.demo-aEntryAbility--psrnAppKey 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 for OpenHarmony(RNOH)。从零准备开发环境(DevEco Studio、HarmonyOS SDK API 26、Node.js、ohpm、hvigorw、模拟器与hdc调试)请按 CPF-RN 开发者指南的「环境初始化」操作:
- CPF-RN 开发者指南 · 02-搭建准备 · 环境初始化:https://atomgit.com/CPF-RN/docs/blob/main/开发者指南/02-搭建准备/环境初始化.md
环境就绪后,本次验证使用的具体版本组合见文末「验证环境」一节。
验证环境
| 项 | 版本 |
|---|---|
| React Native | 0.84.1 |
| React | 19.2.3 |
| RNOH(npm / ohpm) | @react-native-oh/react-native-harmony/@rnoh/react-native-openharmony0.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 |
| 本次构建 | assembleHap3 分 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