本文记录把 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×535vp/5vp × 3105 × 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两个实例都关闭没有面板 ✓
P1S1.open()面板 600×1320 + 拖拽条 105×15 ✓
P2S1.close()面板消失 ✓
P3S2.open()面板 900×1320 ✓
P4S2.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 本次验证的边界

  1. ★★ 手动按钮(Text + onPress)点击后没有产生任何事件 ⇒ 可能是 RNOH 上 Text.onPress 不生效,也可能是我的点击未命中按钮 ⇒ 未验证;
  2. ★ 拖拽手势未验证(draggable 的渲染部分已验证:拖拽条 105×15px 出现 ✓;但下拉关闭/回弹需要真实手势);
  3. 未验证 closeOnPressMask(点遮罩关闭);
  4. 未验证 closeOnPressBack(Android 硬件返回键 —— 鸿蒙上本就无此键);
  5. 未验证 dragOnContent;
  6. 未验证 customStyles / customModalProps / customAvoidingViewProps 的覆盖效果;
  7. 未验证 openDuration / closeDuration 的精确时序(只知 onClose 在动画后触发);
  8. 未验证 height 默认值 260 的实际渲染(我显式传了 200 / 300);也未验证 children 默认 <View />;
  9. 未验证 同时打开两个实例时的层叠行为;
  10. 未在 RN 0.88.0 / RNOH 0.84.4 上复验(交付包声明的基线);
  11. 未测其它屏幕宽度;
  12. 未做跨平台对照。

未改动库代码。


八、常见问题

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 零通知。

最值得记下来的是这几条:

  1. ★★★★★ 四组几何全部精确命中。
    面板 height=200 ⇒ 600×1320px、height=300 ⇒ 900×1320px、拖拽条 ⇒ 105×15px、遮罩 ⇒ 1320×2232px ✓✓
    ⇒ 关键在于 height 是布局属性 + useNativeDriver 为 false ⇒ 高度变化进布局、dump 可量 ✓

  2. ★★★★ <Modal> 在 RNOH 上完全可用。
    transparent / visible 开关、子树渲染、层级都正常;而且 Modal 内节点用屏幕坐标,量起来很直接 ✓

  3. ★★★★ useNativeDriver 显式传 false 的正面样本。
    5 处全传 ⇒ LogBox 零通知 ✓ —— 与"未指定 useNativeDriver 会常驻一条通知"的情况正好相反 ✓

  4. ★★★ 一个真实的行为发现:close() 不幂等。
    源码 close 分支里没有"当前是否已打开"的判断 ⇒ 在从未打开过的实例上调用 close() 也会触发 onClose
    (实测 P0 阶段两个实例都没开过,却各触发了一次 onClose)⇒ onClose 里做收尾要自己记标志位 ✓

  5. ★★★ 平台分支只 1 处且安全:KeyboardAvoidingView 的 behavior 用 Platform.OS === 'ios' ? 'padding' : 'height' ⇒
    鸿蒙自然落到 'height' ✓ —— 二元平台判断在"第四平台"上天然安全 ✓

  6. ★★ 契约反射要记得 forwardRef 的 typeof 是 'object'。
    Symbol(react.forward_ref) + render 才是它的特征;按"组件必须是 function"写断言会误判 ✓

  7. ★ 一次时序漂移的教训与补救:自动时间轴采样时会漂(我第一次漏掉了 900px 那一档),
    补救办法是重启后在目标相位的窗口内密集采样(本次采到两次 P3,都给出 h=900 w=1320)✓

  8. ⚠️ 一处未验证要如实写:页面上的手动按钮(Text + onPress)点击后没有产生任何事件 ⇒
    可能是 RNOH 上 Text.onPress 不生效、也可能是我点击未命中 ⇒ 未验证,不写成结论 ✓


本篇用到的库

项内容
三方库react-native-raw-bottom-sheet(上游 3.0.0 的鸿蒙适配版)
适配仓库https://atomgit.com/oh-react-native/react-native-raw-bottom-sheet
适配 TAG3.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 Native0.84.1
React19.2.3
RNOH(npm / ohpm)@react-native-oh/react-native-harmony / @rnoh/react-native-openharmony 0.84.3
Node.js / npmv24.14.0 / 11.9.0
DevEco Studio26.0.0.621
HarmonyOS SDKAPI 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

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐