RN for OpenHarmony 小工具 App 实战:水平仪实现
本文实现了一个模拟水平仪工具,包含核心功能:十字准星、可移动气泡、双轴角度显示和水平状态检测。通过Animated实现气泡平滑移动效果,采用2°阈值判断水平状态,并设计了呼吸动画增强体验。代码结构清晰,将业务数据(角度)与表现层(动画)分离,便于后续接入真实传感器数据。该工具满足基础水平测量需求,提供直观的视觉反馈和数值读数。

这一篇实现一个「水平仪」小工具界面:中心十字准星 + 气泡小球(随倾斜移动)+ X/Y 两轴角度读数 + 是否水平的状态提示,并提供“模拟倾斜/重置水平”按钮。
说明:当前版本同样是模拟水平仪(随机生成角度),用于展示 UI、动画和数据流结构。真实水平仪需要接入设备陀螺仪/重力传感器。
本文引用的代码全部来自仓库真实文件:
src/pages/LevelTool.tsxsrc/tools/index.tssrc/screens/ToolScreen.tsx
你要求“每段代码后面解释多一些”,并且“需要生成 380 行左右的内容”。
- 本文会保持:每个代码块后都跟一段较长解释
- 同时会控制整体行数在 360~420 区间,尽量贴近 380 行
01. 先从产品交互定义:一个水平仪应该给用户什么信息?
水平仪的典型使用场景是:
- 装相框/电视/置物架时,判断是否水平
- 简单测量桌面、地面是否倾斜
因此,一个“可用”的水平仪至少要做到:
- 视觉反馈:气泡朝哪里偏、偏多少
- 数值反馈:偏多少度(哪怕是近似)
- 状态反馈:是否在可接受误差范围内(例如 ±2°)
- 可操作性:能重置回 0(模拟校准)
本篇的实现会围绕这四点拆解,而不是只讲 UI 代码。
02. 工具箱接入:id=53 映射到 LevelTool
2.1 工具列表注册
文件:src/tools/index.ts
{ id: 53, name: '水平仪', description: '简单水平仪界面', icon: '📐', component: 'LevelTool' },
解释:
component: 'LevelTool'是关键字段,ToolScreen 会用它找到页面组件📐图标表达“测量/水平”的语义,比纯文字更容易在网格里被找到
2.2 ToolScreen 映射
文件:src/screens/ToolScreen.tsx
LevelTool: Pages.LevelTool,
解释:
- 工具箱通过
componentMap做字符串到组件的映射 - 这保证了工具列表只是“配置”,页面实现仍在
src/pages内聚
03. 状态与动画:角度是业务核心,小球位置是表现层
文件:src/pages/LevelTool.tsx
const [angle, setAngle] = useState({ x: 0, y: 0 });
const bubbleX = useRef(new Animated.Value(0)).current;
const bubbleY = useRef(new Animated.Value(0)).current;
const pulseAnim = useRef(new Animated.Value(1)).current;
解释:
3.1 为什么用 {x, y} 而不是单个数?
水平仪通常要表达两个方向的倾斜:
- X 轴(左右)
- Y 轴(前后)
因此用对象 { x, y } 更自然:
- 读数 UI 直接对应
angle.x和angle.y - 判断是否水平也更直观(两个绝对值都小于阈值)
3.2 为什么 bubbleX/bubbleY 是 Animated.Value?
angle 是“业务读数”,但小球移动属于“表现层”。
直接用 left/top 随 state 变化会导致:
- 小球瞬移
- 缺少“流体”感
而水平仪的经典体验是“气泡慢慢滑过去”。
因此用 Animated.spring 去逼近目标位置更符合直觉。
3.3 pulseAnim 为什么存在?
它用来给主圆盘一个轻微呼吸缩放:
- 让界面不死板
- 同时在“水平状态”时也更有仪器的“运行感”
04. 角度 -> 小球位移:用 spring 模拟“惯性与回弹”
useEffect(() => {
Animated.parallel([
Animated.spring(bubbleX, { toValue: angle.x * 5, friction: 5, useNativeDriver: true }),
Animated.spring(bubbleY, { toValue: angle.y * 5, friction: 5, useNativeDriver: true }),
]).start();
}, [angle]);
解释(这段是水平仪体验的关键):
4.1 为什么在 useEffect([angle]) 里做动画?
因为 angle 变化代表“读数变化”。
小球应该作为读数变化的“响应”,所以放在 effect 里最合理:
- 无论角度来自模拟按钮还是未来传感器回调
- 只要
setAngle(...),小球就会平滑移动
这让你的数据流非常干净:
angle (state) -> bubbleX/bubbleY (animated)
4.2 为什么 toValue 是 angle.x * 5?
angle.x 的单位是度(°)。
直接拿度数当像素会太小(比如 1° 只有 1px,视觉不明显),所以乘一个系数做“可视化放大”。
这里乘 5 表示:
- 1° 约等于 5px 位移
- 20° 约等于 100px 位移
刚好在 260x260 的圆盘中看起来比较合理。
4.3 为什么用 Animated.parallel?
水平仪的小球在 X/Y 两个方向上需要同时移动。
如果分开写两个动画,可能会出现:
- 先动 X 再动 Y
这样会产生奇怪的“折线移动”。
并行执行能让小球沿着对角线直接滑向目标位置。
4.4 friction: 5 的意义
spring 的 friction 决定“阻尼大小”。
- friction 太小:小球会弹跳很明显,像玩具
- friction 太大:小球移动很硬,像瞬移
取 5 是一个偏中性的值:
- 有一点回弹
- 但不会过度夸张
05. 呼吸动画:给仪器一点“运行感”
useEffect(() => {
Animated.loop(
Animated.sequence([
Animated.timing(pulseAnim, { toValue: 1.1, duration: 1000, useNativeDriver: true }),
Animated.timing(pulseAnim, { toValue: 1, duration: 1000, useNativeDriver: true }),
])
).start();
}, []);
解释:
[]依赖确保动画只启动一次- 用
sequence做1 -> 1.1 -> 1 - 用
loop无限循环
这里不需要把 pulse 动画和水平状态绑定,因为它不是业务表达,只是 UI 氛围。
如果你想更“仪器化”,也可以做一个增强:
- 只有
isLevel=true时变慢或停住
这属于后续可调的体验细节。
06. 水平判定:阈值比“等于 0”更符合现实
const resetLevel = () => setAngle({ x: 0, y: 0 });
const isLevel = Math.abs(angle.x) < 2 && Math.abs(angle.y) < 2;
解释:
6.1 为什么阈值是 2°?
真实世界里:
- 设备传感器会抖
- 用户手也会抖
如果你要求严格等于 0,几乎永远无法显示“水平”。
用 abs(x)<2 && abs(y)<2 是一个很实用的容错策略:
- 用户能稳定达到“✓ 水平”的状态
- UI 反馈也更有意义
当然这个阈值不是固定的:
- 精密仪器可以用 0.5°
- 家用工具用 2° 更友好
6.2 重置为什么直接 setAngle 为 0?
对于模拟版,“重置”相当于把倾斜归零。
未来接入传感器时,“重置”更像“校准”:
- 记录当前读数作为 offset
- 后续读数减去 offset
但 UI 结构仍然不需要变。
07. 主视觉区域:十字准星 + 中心圈 + 气泡
<Animated.View style={[styles.levelBox, isLevel && styles.levelBoxOk, { transform: [{ scale: pulseAnim }] }]}>
<View style={styles.crosshair}>
<View style={styles.crosshairH} />
<View style={styles.crosshairV} />
<View style={styles.centerCircle} />
</View>
<Animated.View style={[styles.bubble, { transform: [{ translateX: bubbleX }, { translateY: bubbleY }] }]}>
<View style={styles.bubbleInner} />
</Animated.View>
</Animated.View>
解释:
7.1 为什么 levelBox 是圆形?
水平仪常见有两种形态:
- 条形(用于单轴)
- 圆形(用于双轴)
这里是双轴,因此用圆形更贴合直觉。
7.2 十字准星的作用
准星让用户知道“中心点在哪里”。
如果没有准星,气泡在圆里动会显得没有参照物,用户很难判断偏移方向。
7.3 为什么气泡用 translateX/translateY?
因为 Animated.Value 最适合用在 transform 上,并且 useNativeDriver: true 也能带来更好性能。
如果改成 left/top,很多情况下就需要关闭 native driver,动画更容易卡顿。
7.4 水平状态的视觉强化
你会看到 isLevel && styles.levelBoxOk:
- 不水平:红色边框
- 水平:绿色边框
这是一个非常直接的“状态视觉语义”:红=警示,绿=通过。
08. 读数与状态文案:把“数值”和“结论”分开呈现
<View style={styles.readings}>
<View style={styles.reading}>
<Text style={styles.readingLabel}>X轴</Text>
<Text style={[styles.readingValue, Math.abs(angle.x) < 2 && styles.readingValueOk]}>{angle.x}°</Text>
</View>
<View style={styles.reading}>
<Text style={styles.readingLabel}>Y轴</Text>
<Text style={[styles.readingValue, Math.abs(angle.y) < 2 && styles.readingValueOk]}>{angle.y}°</Text>
</View>
</View>
<Text style={[styles.status, isLevel && styles.statusOk]}>
{isLevel ? '✓ 水平' : '✗ 不水平'}
</Text>
解释:
8.1 为什么读数卡片要分两张?
因为用户需要知道“哪个方向偏了”。
- X 偏说明左右不平
- Y 偏说明前后不平
分成两张卡片比用一行文字更易读。
8.2 数值为什么也有绿色强调?
当 abs(angle.x) < 2 时,X 轴数值变绿。
这让用户获得一种“逐项通过”的反馈:
- 有时 X 已经平了,但 Y 还没平
用户会更知道该往哪个方向调整。
8.3 状态文案为什么单独放大?
用户最终想要的是结论:
我现在是不是水平?
所以状态文案(✓/✗)用更大的字号、更强颜色对比,给出清晰结论。
09. 模拟与重置按钮:演示入口 + 校准入口
<View style={styles.btnRow}>
<TouchableOpacity style={styles.btn} onPress={simulateLevel} activeOpacity={0.8}>
<Text style={styles.btnText}>🎲 模拟倾斜</Text>
</TouchableOpacity>
<TouchableOpacity style={[styles.btn, styles.btnSecondary]} onPress={resetLevel} activeOpacity={0.8}>
<Text style={styles.btnText}>↺ 重置水平</Text>
</TouchableOpacity>
</View>
解释:
simulateLevel用于随机生成角度,验证动画与 UI 数据流resetLevel用于回到 0,模拟“校准”
这里按钮做了颜色区分:
- 蓝色:普通动作
- 绿色:恢复/通过(与“水平”的绿色语义一致)
这种颜色一致性会让用户更容易理解。
10. 模拟倾斜逻辑:控制角度范围,保证 UI 不越界
const simulateLevel = () => {
setAngle({
x: Math.round((Math.random() - 0.5) * 20),
y: Math.round((Math.random() - 0.5) * 20),
});
};
解释:
10.1 为什么范围是 (-10..10) 左右?
(Math.random() - 0.5) * 20 的范围是 [-10, 10)。
这配合前面 angle * 5 的位移系数,得到的位移范围是:
- X/Y 位移约
[-50px, 50px]
在 260 的圆盘里,这样的移动看起来合理且不会跑出边界。
如果你把角度范围加大到 60°,位移会达到 300px,小球会直接飞出圆盘。
因此模拟版的范围要“为 UI 服务”,而不是“模拟真实物理”。
10.2 为什么要 Math.round?
让读数显示为整数度更符合“工具演示”的直觉。
如果显示一堆小数,用户会觉得噪音大。
11. 样式拆解:为什么这个 UI 看起来像“仪器”而不是“按钮页面”
这里不展示全部 styles,只挑关键几段(来自 src/pages/LevelTool.tsx)。
11.1 主圆盘:红/绿边框 + 阴影
levelBox: {
width: 260,
height: 260,
borderRadius: 130,
backgroundColor: '#1a1a3e',
borderWidth: 4,
borderColor: '#e74c3c',
justifyContent: 'center',
alignItems: 'center',
marginBottom: 30,
shadowColor: '#e74c3c',
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.4,
shadowRadius: 16,
elevation: 10,
},
levelBoxOk: { borderColor: '#2ecc71', shadowColor: '#2ecc71' },
解释:
- 深色底 + 高对比边框,很像“仪表盘”
- 阴影颜色与边框一致,会让状态更明显(红色更警示、绿色更通过)
elevation是 Android 的阴影,shadow*是 iOS 阴影,两套都写保证跨平台一致
11.2 气泡:蓝色圆 + 内部高光
bubble: {
width: 50,
height: 50,
borderRadius: 25,
backgroundColor: '#4A90D9',
justifyContent: 'center',
alignItems: 'center',
shadowColor: '#4A90D9',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.5,
shadowRadius: 8,
elevation: 6,
},
bubbleInner: {
width: 20,
height: 20,
borderRadius: 10,
backgroundColor: 'rgba(255,255,255,0.3)',
},
解释:
- 外圆用蓝色,突出“气泡”
- 内圆用半透明白色,模拟高光反射
- 阴影让气泡更像浮起来的实体,而不是贴在底图上
12. 免责声明:明确模拟与真实传感器的差异
LevelTool.tsx 里也写了提示:
<Text style={styles.hint}>
* 这是一个模拟水平仪界面
实际使用需要设备传感器支持
</Text>
解释:
- 避免用户误解“为什么不跟随手机倾斜”
- 工具箱类 demo 在没有硬件能力时,必须明确说明
建议把 hint 放底部并弱化(灰色、小字号),既不抢主视觉,也能被看到。
13. 真实水平仪接入建议(保持结构不变)
如果你未来要接入真实传感器,建议保持当前结构:
angle仍然是业务核心- bubble 动画仍然由
useEffect([angle])驱动
你需要替换的只有数据来源:
- 将
simulateLevel()替换为传感器监听回调 - 并做一个“校准 offset”逻辑,支持用户按下“重置水平”时以当前读数为基准
另外要注意两个现实问题:
- 传感器噪声需要滤波,否则气泡会抖
- 不同设备坐标轴定义可能不同,需要做轴映射与符号校正
这些属于接入硬件能力时的工程问题,但不会影响本文 UI 结构。
14. 小结
这个「水平仪」小工具的实现重点在于:
- 业务核心是
angle.x/angle.y,UI 都由它派生 - 角度到位移用
spring模拟“气泡滑动”的真实感 - “水平判断”用阈值而非严格 0,更符合现实使用
- 视觉语义明确:红=不通过,绿=通过
- 结构天然支持升级:把模拟角度替换为传感器读数即可
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
更多推荐



所有评论(0)