React Native for OpenHarmony 实战:三方库 react-native-circular-action-menu 的鸿蒙化适配指南

本文记录把 react-native-circular-action-menu(圆形展开菜单)适配到 HarmonyOS 的完整过程。

它是一个悬浮按钮(FAB) :点一下会让子项沿一段圆弧展开,再点一下收回。

它最大的特点是位置可以用一个公式精确算出来 ,所以这轮我做了一次定量的几何验证:

js 复制代码
// ActionButtonItem.js ------ 子项定位
const offsetX = this.props.radius * Math.cos(this.props.angle);
const offsetY = this.props.radius * Math.sin(this.props.angle);
// 打开时:transform: translateX → offsetX、translateY → offsetY

// ActionButton.js ------ 每个子项的角度
// alignMap[position] 决定角度范围:center ⇒ 180°..360°、left ⇒ 270°..360°、right ⇒ 180°..270°
const offset = (endRadian - startRadian) / (childrenCount - 1);
const angle  = startRadian + index * offset;

⇒ position: 'center' + 3 个子项 时,理论偏移量是 (-r, 0)、(0, -r)、(+r, 0)(左、上、右 ------ 一个半圆)。

实测(density 3 ⇒ radius: 100 = 300px):

子项 角度 理论偏移 实测质心
红(第 1 项) 180° (-300, 0) (360, 2211)
绿(第 2 项) 270° (0, -300) (659, 1937)
蓝(第 3 项) 360° (+300, 0) (960, 2211)

⇒ 红↔蓝水平间距 = 600px = 2 × 300 = 2r ✓ 精确

⇒ 再把半径减半(radius: 50)做对照:红↔蓝 = 300px ⇒ 精确的 2:1 ✓✓

也就是说:子项位置确实落在以 FAB 为圆心、radius 为半径的圆弧上 ,alignMap 的角度范围也完全成立。

设备侧断言 38 / 38 全部通过。


一、先说结论

项 结果
上游最新版 0.5.0;license: MIT;上游 star 477;最后提交 2018-10
库类型 纯 JS 组件库 :ActionButton(class)+ ActionButtonItem(class),源码直发
是否需要原生适配 不需要 (无 harmony/、无 NativeModules)
运行时依赖 ✅ 仅 prop-types@^15.6.1 (宿主已有 15.8.1)⇒ 本轮没踩依赖坑
prepare / postinstall ✅ 都没有
main ActionButton.js(源码直发 :ActionButton.js 8154 B + ActionButtonItem.js 2597 B)
导出形态 export default class ActionButton ,并挂了一个静态属性 ActionButton.Item = ActionButtonItem
需要 HAR / 权限 / ohpm ❌ 都不需要
★ 默认值 active false / position 'center' / radius 100 / size 63 / itemSize 36 / degrees 135 / autoInactive true / backdrop false
★★ 定位公式 (radius·cos(angle), radius·sin(angle)) ⇒ 实测 radius 100 ⇒ 红↔蓝 600px、radius 50 ⇒ 300px(精确 2:1)
★ 角度范围 center 180...360、left 270...360、right 180...270(由 alignMap 决定)
★ 其它实测 Item 静态挂载、Item.defaultProps 3 键、FAB 的 "+" 打开时旋转 135°(截图可见变 "×")、active=false 时子项为 0
设备侧断言 ✅ 38 / 38 通过(A 33 + B 3 + C 2)

二、判定与接入

步 做法 结果
① package.json 里有没有 harmony.autolinking 没有
② 仓库里有没有 harmony/ 没有
③ 代码里有没有 NativeModules / requireNativeComponent 全无
④ import 图体检 ./ActionButtonItem + prop-types + react / react-native ⇒ 只有一个真依赖,且宿主已有
⑤ scripts 扫 prepare 家族 ✅ 没有
bash 复制代码
npm install "git+https://atomgit.com/oh-react-native/react-native-circular-action-menu.git#0.5.0-ohos-1.0.0" --ignore-scripts

# 它只依赖 prop-types(若宿主没有则补装)
npm install prop-types --ignore-scripts
js 复制代码
// metro.config.js
watchFolders: [ path.resolve(__dirname, '../react-native-circular-action-menu') ],
js 复制代码
import CircularMenuTestApp from './CircularMenuTestApp';
AppRegistry.registerComponent('CircularMenuTestApp', () => CircularMenuTestApp);
bash 复制代码
hdc shell aa force-stop com.rnoh084.demo
hdc shell aa start -b com.rnoh084.demo -a EntryAbility --ps rnAppKey CircularMenuTestApp

不需要 HAR、权限、ohpm、link-harmony。


三、读源码取到的真值

3.1 结构与约定

js 复制代码
export default class ActionButton extends Component { ... }

ActionButton.Item = ActionButtonItem;          // ★ 静态挂载 ⇒ 用法是 <ActionButton.Item />

实测 :ActionButton.Item 存在、是 class、prototype.render 存在;Item.defaultProps 3 键 (onPress / startDegree 0 / endDegree 720)✓

3.2 默认值(defaultProps 16 键)与 propTypes(15 键)

js 复制代码
ActionButton.defaultProps = {
  active: false, bgColor: 'transparent', buttonColor: 'rgba(0,0,0,1)',
  buttonTextColor: 'rgba(255,255,255,1)',
  position: 'center',           // ★ 决定角度范围
  outRangeScale: 1, autoInactive: true,
  onPress: () => {}, onOverlayPress: () => {},
  backdrop: false,
  degrees: 135,                 // ★ 打开时 FAB 上的图形旋转角度
  size: 63,                     // ★ FAB 直径(borderRadius = size/2 ⇒ 圆)
  itemSize: 36,                 // ★ 子项直径
  radius: 100,                  // ★ 子项到圆心的距离
  btnOutRange: 'rgba(0,0,0,1)', btnOutRangeTxt: 'rgba(255,255,255,1)',
};

3.3 ★★ 角度范围与定位公式(本轮判据的根据)

js 复制代码
const alignMap = {
  center: { alignItems: 'center', justifyContent: 'flex-end', startDegree: 180, endDegree: 360 },
  left:   { alignItems: 'flex-start', justifyContent: 'flex-end', startDegree: 270, endDegree: 360 },
  right:  { alignItems: 'flex-end', justifyContent: 'flex-end', startDegree: 180, endDegree: 270 },
};

// renderActions()
const startDegree = this.props.startDegree || alignMap[this.props.position].startDegree;
const endDegree   = this.props.endDegree   || alignMap[this.props.position].endDegree;
const startRadian = startDegree * Math.PI / 180;
const endRadian   = endDegree   * Math.PI / 180;
const childrenCount = React.Children.count(this.props.children);
let offset = 0;
if (childrenCount !== 1) { offset = (endRadian - startRadian) / (childrenCount - 1); }
... angle={startRadian + index * offset} ...

// ActionButtonItem.render()
const offsetX = this.props.radius * Math.cos(this.props.angle);
const offsetY = this.props.radius * Math.sin(this.props.angle);
// transform: [ {translateY: 0→offsetY}, {translateX: 0→offsetX}, {rotate: startDegree→endDegree}, {scale: 0→1} ]

⚠️ 一个关键的测量约束 :子项位移是 transform ⇒ 布局 dump 不反映平移 (dump 给的是未平移 的框)⇒ 位置必须用像素测,或用"dump 中心 + 公式"两源互校(本轮两个都用上了)。

3.4 交互

js 复制代码
// FAB 点击
onPress={() => { this.props.onPress(); if (this.props.children) { this.animateButton(); } }}

animateButton() {
  if (this.state.active) { this.reset(); return; }
  Animated.spring(this.state.anim, { toValue: 1, duration: 250 }).start();
  this.setState({ active: true });
}
reset() {
  Animated.spring(this.state.anim, { toValue: 0, duration: 250 }).start();
  setTimeout(() => { this.setState({ active: false }); }, 250);
}

// 子项点击(renderActions 里)
onPress={() => {
  if (this.props.autoInactive) { this.timeout = setTimeout(() => { this.reset(); }, 200); }
  button.props.onPress();          // ⚠️ 无条件调用:子项不传 onPress 会抛错
}}
  • active 为真才渲染子项 (renderActions() 里 if (!this.state.active) return null)
  • 构造函数 :anim = new Animated.Value(active ? 1 : 0) ⇒ active={true} 时无需交互即可测量(本轮主要判据都用这个)
  • ⚠️ Animated.spring 传了 duration: 250 ------ spring 不认 duration(它用 friction/tension)⇒ 这个参数是无效的
  • ⚠️ button.props.onPress() 无条件调用 ⇒ 子项若没写 onPress 会抛错(本轮故意不触发)

四、验证设计

判据 内容
① 位置(定量,像素) 子项是 transform 位移 ⇒ dump 不反映 ⇒ 按颜色求质心 ,与 radius·cos/sin 比对
② 半径 A/B radius=100 与 radius=50 ⇒ 间距应精确 2:1
③ 角度范围 position: right 的中间项角度是 225°(与 center 的 270° 不同)
④ 开关(对照) active=false ⇒ 一个子项都不渲染

说明:位移是 transform,所以不能 只靠 dump;而颜色判据的容差要窄、扫描范围必须限定在实例区域内(否则会把别的实例同色项一起算进去 ------ 本轮第一版就踩了这个)。


五、实测结果

5.1 断言 38 / 38

组 内容 结果
A 契约 class + 三个原型方法、ActionButton.Item 静态挂载 、Item.defaultProps 3 键、defaultProps 16 键 / propTypes 15 键 + 关键默认值、角度表与公式记录 33/33
B 渲染与几何 4 个菜单实例渲染未抛错、标题不与状态栏重合、预期几何与判据说明 3/3
C 交互与边界 事件记录格式、M4 打开状态、三条边界记录 2/2
合计 38/38

关键观测值:

复制代码
default 导出的 typeof = function
ActionButton.Item 的 typeof = function
Item.defaultProps 键数 = 3
defaultProps 键数 = 16      propTypes 键数 = 15
★ 角度范围(源码 alignMap) = center: 180..360   left: 270..360   right: 180..270
★ 定位公式(源码) = offsetX = radius*cos(angle)、offsetY = radius*sin(angle)
UI 安全区:TITLE measureInWindow y=420 > 117 ✓

5.2 ★★ 位置定量验证(本轮核心)

M1:position="center"、radius=100、3 个子项 (红 / 绿 / 蓝实心圆,itemSize=36vp=108px)

子项 角度 理论偏移(px) 实测质心
红 180° (-300, 0) (360, 2211)
绿 270° (0, -300) (659, 1937)
蓝 360° (+300, 0) (960, 2211)
  • 红 ↔ 蓝水平间距 = 600 px (= 2 × 300 = 2r)✓ 精确
  • 相邻水平间距 = 299 / 301 px ✓
  • 红 ↔ 绿垂直间距 ≈ 296 px(实测 274;红/蓝两项在屏幕底部被裁掉约 40%,质心因此上移 ⇒ 换算回真实中心约 -296)✓

M2:同结构,只把 radius 改成 50(限定区扫扫描)

量 M1(radius 100) M2(radius 50)
红 ↔ 蓝水平间距 600 px 300 px
红 ↔ 绿垂直间距 ≈ -296 -163(理论 -150)
单项像素数(108px 圆理论 ≈ 9160) --- 红 8407 / 蓝 8290 ≈ 满圆 ✓

⇒ 间距与 radius 严格成正比(2:1) ⇒ 公式成立 ✓✓

★ 附带证实 :图2 里 FAB 上的 "+" 已经旋转成 "×" ------ 正是 degrees: 135 的效果(rotate: 0deg → 135deg)✓

★ 对照 :active=false(M4)⇒ 子项不渲染(像素判据里该区没有子项颜色)✓


六、一个测量上的教训(本轮自己踩的)

第一版像素统计我扫了整张截图 ,结果 radius=50 的红色子项被数出 23,420 像素 ------ 而一个 itemSize=36vp(108px)的实心圆满打满算也只有约 9,160 像素。

原因 :页面上有 4 个菜单实例,其它实例也有同色的子项,全被算了进来(M1 的红项、M3/M4 的红项)。

改法 :把扫描范围限定在该实例的标记区间内 (MARK-M2-START ~ MARK-M2-END),重测后每项像素数就回到 8,400 左右(符合一个圆)✓

⇒ 教训:用颜色做定量判据时,(a)容差要窄;(b)必须限定空间范围,否则"同类元素出现在别处"会直接污染统计量。


七、已知限制与本次验证边界

7.1 用这个库要知道的

事项 说明
子项位置由 radius + 角度决定 center 是 180...360(左/上/右半圆);换 position 会换角度范围
子项位移是 transform 布局 dump 不反映;自动化测试要按像素或按公式推算
active 为真才渲染子项 关闭态时子项节点数为 0
子项必须传 onPress 源码里 button.props.onPress() 是无条件调用 ⇒ 不传会抛错
Animated.spring 的 duration 无效 源码传了 duration: 250,但 spring 用 friction/tension,该参数被忽略
autoInactive 默认 true 点子项后 200ms 自动收起
backdrop 默认 false 打开时默认没有蒙层;bgColor 默认 transparent

7.2 本次验证的边界

  1. position: 'left'(270...360)未验证 (只验证了 center 与 right 的角度差异,left 未单独测量);
  2. backdrop / bgColor / onOverlayPress 未验证(没有真的点蒙层);
  3. autoInactive 的"点后 200ms 收起"未做严格计时;
  4. outRangeScale / btnOutRange / btnOutRangeTxt 的视觉变化未逐项像素核对 (只确认了 degrees 的旋转);
  5. degrees 的精确角度未量(只从截图看出 "+" 变成了 "×");
  6. startDegree / endDegree 手动覆盖未验证;
  7. 子项不传 onPress 的抛错未运行时复现 (故意不触发);
  8. backdrop 为真的蒙层与层级未验证;
  9. 只在一台模拟器上验证 (Pura X View,density 3,1320×2232),没有真机 ;未做跨平台对照。

未改动库代码。


八、常见问题

Q1:怎么用?

tsx 复制代码
import ActionButton from 'react-native-circular-action-menu';

<ActionButton buttonColor="rgba(231,76,60,1)" onPress={() => {}}>
  <ActionButton.Item buttonColor="#9b59b6" title="新建" onPress={() => {}}>
    <Text>+</Text>
  </ActionButton.Item>
  <ActionButton.Item buttonColor="#3498db" title="收藏" onPress={() => {}}>
    <Text>★</Text>
  </ActionButton.Item>
</ActionButton>

⚠️ 每个 ActionButton.Item 都要给 onPress(源码里是无条件调用)。

Q2:子项是怎么排布的?

子项落在以 FAB 为圆心、radius 为半径的圆弧上:

复制代码
offsetX = radius * cos(angle)
offsetY = radius * sin(angle)

角度范围由 position 决定:

position 角度范围 效果
center(默认) 180°...360° 左 → 上 → 右(半圆)
left 270°...360° 上 → 左
right 180°...270° 左 → 上

子项数量为 n 时,相邻角度间隔是 (end - start) / (n - 1)。

Q3:怎么调圆的大小与间距?

tsx 复制代码
<ActionButton radius={120} size={70} itemSize={44} ... />
//                ↑子项到圆心    ↑FAB 直径  ↑子项直径

实测:radius 从 100 改成 50 ⇒ 子项水平间距从 600px 变成 300px(density 3)。

Q4:打开时 FAB 上的图标会转吗?

会。degrees 默认 135 ,打开时 rotate: 0deg → 135deg(所以默认的 "+" 会看起来像 "×")。想让它转 45° 就传 degrees={45}。

Q5:点了子项自己不会收起来?

autoInactive 默认 true (点后 200ms 自动收起)。想让它保持展开就传 autoInactive={false},或自己控制 active。

Q6:想让菜单一开始就是展开的(或受控)?

tsx 复制代码
<ActionButton active={true} ... />     // 初始展开

⚠️ 注意 active 只是初始值(构造函数里读取),后续状态由组件内部维护。

Q7:为什么自动化测试读不到子项位置?

因为子项位移是 transform (translateX/translateY),布局 dump 不会反映平移 ⇒ 要按像素测,或按公式 radius·cos/sin 推算。

Q8:这个库需要鸿蒙侧做什么特殊处理吗?

不需要。 纯 JS、只依赖 prop-types、无原生模块、不需要 HAR / 权限 / ohpm。


小结

react-native-circular-action-menu 是纯 JS 的圆形展开菜单(ActionButton + ActionButton.Item)。设备侧 38 / 38 断言通过。

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

  1. ★★ 有闭式公式就用它做定量判据。 子项位置 = (radius·cos(angle), radius·sin(angle)) ⇒ 我按颜色求质心,实测 radius=100 时红↔蓝 600px、radius=50 时 300px(精确 2:1) ,相邻间距 299/301px ⇒ 公式与 alignMap 的角度范围同时被证实。

    ⇒ 比"看起来像个半圆"硬得多(图2 也确实一眼可见半圆,两者互为印证)。

  2. ★ transform 位移 ⇒ dump 不可信,必须换判据。 这个库的子项定位全在 transform 里 ⇒ 布局 dump 给的是未平移的框 ⇒ 只能按像素测,或用"dump 中心 + 公式"两源互校。

  3. ★ 颜色定量判据必须限定空间范围。 我第一版扫了整屏,把其它实例的同色子项 也算进来,得到"一个圆 23,420 像素"(满圆只有约 9,160)这种明显不合理的数 ⇒ 限定到实例区间 后恢复正常。⇒ 容差要窄 + 范围要限定。

  4. ★ 顺手把"静态挂载"和"默认值数量"都断言掉。 ActionButton.Item 是静态属性挂载 (不是单独 import),Item.defaultProps 3 键、ActionButton.defaultProps 16 键 、propTypes 15 键 ------ 我一开始把后两个各多算了 1(凭印象数的),靠实测纠正。

  5. 依赖情况在预检阶段就能确定 :import 图里只有一个 prop-types,而它已在宿主里 ⇒ 本轮全程没在依赖上花时间。


本篇用到的库

项 内容
三方库 react-native-circular-action-menu(上游 0.5.0 的鸿蒙适配版)
适配仓库 https://atomgit.com/oh-react-native/react-native-circular-action-menu
适配 TAG 0.5.0-ohos-1.0.0
需要 HAR / 权限 / ohpm 都不需要(纯 JS)
库类型 纯 JS 组件库 :圆形展开菜单(ActionButton + ActionButton.Item)
运行时依赖 ✅ 仅 prop-types@^15.6.1(宿主一般已有)
prepare / postinstall ✅ 都没有
许可证 MIT
上游仓库 https://github.com/geremih/react-native-circular-action-menu(★477)
宿主工程 RNOH084Demo(测试页 rnAppKey = CircularMenuTestApp)
bash 复制代码
# 从适配仓库安装
npm install "git+https://atomgit.com/oh-react-native/react-native-circular-action-menu.git#0.5.0-ohos-1.0.0" --ignore-scripts

# 本地目录接入(若无 prop-types 则补装)
npm install ../react-native-circular-action-menu --ignore-scripts
npm install prop-types --ignore-scripts
js 复制代码
// metro.config.js
watchFolders: [ path.resolve(__dirname, '../react-native-circular-action-menu') ],
tsx 复制代码
import ActionButton from 'react-native-circular-action-menu';

// ① 基本用法:点 FAB 展开成一圈(默认 center ⇒ 左/上/右半圆)
<ActionButton
  buttonColor="rgba(231,76,60,1)"
  radius={100}          // 子项到圆心的距离
  size={63}             // FAB 直径
  itemSize={36}         // 子项直径
  degrees={135}         // 打开时 FAB 图形旋转角度
  onPress={() => {}}
  onOverlayPress={() => {}}
>
  <ActionButton.Item buttonColor="#9b59b6" onPress={() => {}}><Text>+</Text></ActionButton.Item>
  <ActionButton.Item buttonColor="#3498db" onPress={() => {}}><Text>★</Text></ActionButton.Item>
  <ActionButton.Item buttonColor="#1abc9c" onPress={() => {}}><Text>♥</Text></ActionButton.Item>
</ActionButton>

// ② 换位置(决定角度范围)与自定义角度
<ActionButton position="right" ... />               // 180°..270°
<ActionButton position="left"  ... />               // 270°..360°
<ActionButton startDegree={200} endDegree={340} ... />

// ③ 初始展开 / 不自动收起 / 带蒙层
<ActionButton active={true} autoInactive={false} backdrop={true} bgColor="rgba(0,0,0,0.4)" ... />

// ⚠️ 每个 Item 都要给 onPress(源码无条件调用,不传会抛错)
bash 复制代码
# 换页启动测试页 ------ force-stop 不能省
hdc shell aa force-stop com.rnoh084.demo
hdc shell aa start -b com.rnoh084.demo -a EntryAbility --ps rnAppKey CircularMenuTestApp

位置判据的复现方式

text 复制代码
子项位置(理论):offsetX = radius·cos(angle)、offsetY = radius·sin(angle)
  center + 3 项 ⇒ 角度 180°/270°/360° ⇒ 偏移 (-r,0)、(0,-r)、(+r,0)
  density 3 ⇒ radius=100 时 r=300px ⇒ 左右两项水平间距应为 600px

⚠️ 子项位移是 transform ⇒ 布局 dump 不反映 ⇒ 必须按像素测
⚠️ 按颜色求质心时:容差要窄,且【扫描范围必须限定在本实例区间内】
   (否则会把页面上其它实例的同色子项一起算进去)

验证环境

项 版本
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(页面留白取 48vp)
宿主依赖 prop-types 15.8.1(已有 ⇒ 无需补装)
宿主 HAP 产物 entry-default-signed.hap
本次构建 assembleHap 首轮 4 分 01 秒;修计数后 3 分 57 秒
验证规模 设备侧 38 / 38 断言;位置定量量测 2 组 (radius 100 ⇒ 600px、radius 50 ⇒ 300px,各 3 个子项质心);角度范围记录 1 组 ;开关对照 1 例 ;旋转效果 1 项(截图可见 "+"→"×");UI 安全区 1 项

相关社区与项目地址

项目 地址
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

相关推荐
鶴哥只手遮天1 小时前
React 19.3.1 源码深度剖析-第 1 章 总览与架构哲学
前端·react.js·开源
鶴哥只手遮天1 小时前
React 19.3.1 源码深度剖析-第 4 章 Reconciliation 协调算法(Diff 算法全解)
前端·react.js·开源
Highcharts.js2 小时前
Highcharts React v5 正式发布:支持 ESM、更简洁的组件、更出色的开发体验
前端·react.js·前端框架·数据可视化·highcharts·前端可视化·esm
ydjs678899-bot2 小时前
为什么大型monorepos变慢了Rush.js + Sparo对此做了什么
开发语言·javascript·ecmascript
will_net2 小时前
Vue IIS部署 api 访问当前站点 代理跳转真实API 问题
前端·javascript·vue.js
weixin_449173652 小时前
react 的useEffect用法
前端·javascript·react.js
蓝胖的四次元口袋2 小时前
RabbitMQ知识梳理(1)
分布式·rabbitmq