在这里插入图片描述

这一篇实现一个「水平仪」小工具界面:中心十字准星 + 气泡小球(随倾斜移动)+ X/Y 两轴角度读数 + 是否水平的状态提示,并提供“模拟倾斜/重置水平”按钮。

说明:当前版本同样是模拟水平仪(随机生成角度),用于展示 UI、动画和数据流结构。真实水平仪需要接入设备陀螺仪/重力传感器。

本文引用的代码全部来自仓库真实文件:

  • src/pages/LevelTool.tsx
  • src/tools/index.ts
  • src/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.xangle.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 为什么 toValueangle.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();
}, []);

解释:

  • [] 依赖确保动画只启动一次
  • sequence1 -> 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

Logo

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

更多推荐