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

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

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

// 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✅ 都没有
mainActionButton.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 家族✅ 没有
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
// metro.config.js
watchFolders: [ path.resolve(__dirname, '../react-native-circular-action-menu') ],
import CircularMenuTestApp from './CircularMenuTestApp';
AppRegistry.registerComponent('CircularMenuTestApp', () => CircularMenuTestApp);
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 结构与约定

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 键)

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 ★★ 角度范围与定位公式(本轮判据的根据)

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 交互

// 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/Bradius=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 px300 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:怎么用?

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°左 → 上 → 右(半圆)
left270°…360°上 → 左
right180°…270°左 → 上

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

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

<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:想让菜单一开始就是展开的(或受控)?

<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
适配 TAG0.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)
# 从适配仓库安装
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
// metro.config.js
watchFolders: [ path.resolve(__dirname, '../react-native-circular-action-menu') ],
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(源码无条件调用,不传会抛错)
# 换页启动测试页 —— force-stop 不能省
hdc shell aa force-stop com.rnoh084.demo
hdc shell aa start -b com.rnoh084.demo -a EntryAbility --ps rnAppKey CircularMenuTestApp

位置判据的复现方式

子项位置(理论):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 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(页面留白取 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

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐