在这里插入图片描述

这一篇我们实现一个「手电筒」小工具界面:点击屏幕开/关灯光,打开时提供多个“亮度档位”按钮,并通过呼吸脉冲 + 光晕过渡让界面更有真实的“照明氛围”。

需要强调:当前实现是模拟手电筒界面(通过背景色亮度模拟屏幕发光),并不直接控制设备闪光灯硬件。

本文所有代码片段均来自仓库真实文件:

  • src/pages/Flashlight.tsx
  • src/tools/index.ts
  • src/screens/ToolScreen.tsx

你要求“每段代码后解释多一些”并且“约 380 行左右”,本文会采用固定写法:

  • 每一个代码块之后都紧跟一段较长的解释
  • 解释会包含:为什么这么写、涉及的交互边界、动画的职责划分、容易踩坑点

01. 功能与交互目标(先从产品角度定义)

在写代码之前,先把功能目标写清楚,会让状态设计更稳:

  • 一键开关:点击屏幕任意位置切换 开/关
  • 亮度档位:开灯时显示 4 个亮度按钮(30/50/70/100%)
  • 动效反馈
    • 开灯:光晕淡入
    • 关灯:光晕淡出,同时停止呼吸脉冲
    • 开灯状态:图标轻微呼吸(scale 1 -> 1.1 -> 1)
  • 交互边界
    • 点击亮度按钮不能触发“开关灯”(否则会出现点亮度时把灯关了的体验灾难)

这一篇的代码不长,但要把“交互边界”处理好,才像真正可用的小工具。


02. 工具箱接入(id=51 映射到 Flashlight)

2.1 工具列表注册

文件:src/tools/index.ts

{ id: 51, name: '手电筒', description: '简单手电筒界面', icon: '🔦', component: 'Flashlight' },

解释:

  • id=51 对应这篇文章的编号,也对应工具列表里的唯一标识
  • component: 'Flashlight' 是 ToolScreen 做页面映射时的 key
  • icon 选择 🔦,用户在工具网格里一眼就能理解这是“手电筒”

如果你未来要把它升级为“真手电筒”(控制硬件闪光灯),建议仍然保持这套接入方式不变,只改页面内部实现即可。

2.2 ToolScreen 映射到真实页面组件

文件:src/screens/ToolScreen.tsx

Flashlight: Pages.Flashlight,

解释:

  • 工具箱页面拿到配置后,会根据 tool.componentcomponentMap 查组件
  • 这类映射可以理解为“路由表”,也可以理解为“安全白名单”
  • 好处是:工具配置再多,也不会出现随便传个字符串就能渲染任意组件的风险

03. 页面核心:状态与动画值(把职责拆清楚)

文件:src/pages/Flashlight.tsx

const [isOn, setIsOn] = useState(false);
const [brightness, setBrightness] = useState(1);

const pulseAnim = useRef(new Animated.Value(1)).current;
const glowAnim = useRef(new Animated.Value(0)).current;

解释(非常关键):

3.1 为什么需要 isOn

isOn 是“模式状态”:

  • false:深色背景,表示关灯
  • true:浅色背景(并带 alpha),表示开灯

它不仅决定背景颜色,还决定:

  • 是否显示亮度按钮区
  • 光晕是否显示
  • 呼吸动画是否运行

因此它是整页的“主开关”。

3.2 为什么 brightness 要独立出来?

亮度是一个“参数状态”,它依附于 isOn=true 时才有意义,但仍然应该独立存储:

  • 亮度的变化应该只影响“光强”相关的 UI(背景、光晕强度)
  • 不应该影响 “on/off” 的逻辑

这样拆开后,你会得到更清晰的因果关系:

  • 开/关:由 isOn 决定
  • 亮度:由 brightness 决定

3.3 动画值拆成两个的原因

  • pulseAnim:负责“呼吸脉冲”(scale 来回)
  • glowAnim:负责“光晕显隐”(opacity 0 -> 1)

这两个动画是不同性质:

  • 一个是循环动画(loop)
  • 一个是状态过渡动画(toggling)

如果把它们写在一个动画里,你很容易在切换开关时出现“循环动画还在跑”的问题。


04. 开关灯逻辑:useEffect 驱动动画的启动与停止

useEffect(() => {
  if (isOn) {
    Animated.loop(
      Animated.sequence([
        Animated.timing(pulseAnim, { toValue: 1.1, duration: 1000, useNativeDriver: true }),
        Animated.timing(pulseAnim, { toValue: 1, duration: 1000, useNativeDriver: true }),
      ])
    ).start();
    Animated.timing(glowAnim, { toValue: 1, duration: 300, useNativeDriver: true }).start();
  } else {
    pulseAnim.setValue(1);
    Animated.timing(glowAnim, { toValue: 0, duration: 300, useNativeDriver: true }).start();
  }
}, [isOn]);

解释(这段决定了“灯光氛围”是否自然):

4.1 为什么把动画写在 useEffect([isOn]) 里?

因为 isOn 是“开关状态”,而动画的启动/停止就是由开关状态驱动的。

把动画放在 effect 里可以保证:

  • isOn=true 时必然启动循环脉冲 + 光晕淡入
  • isOn=false 时必然停止视觉反馈(至少在视觉层面停下来)

这比你在 toggleLight() 里手动写一堆动画更稳,因为:

  • 状态更新是异步的
  • effect 是“状态变化后的统一响应点”

4.2 pulseAnim 为什么要 loop + sequence?

我们想要的是一个很轻的“呼吸感”,而不是突兀的缩放:

  • 1 -> 1.1(慢慢放大)
  • 1.1 -> 1(慢慢回到原状)

通过 sequence 组合两个 timing,再 loop 起来,就形成稳定的呼吸节律。

4.3 为什么关灯要 pulseAnim.setValue(1)

关灯后,我们希望 UI 回到静止状态。

如果不手动把 pulseAnim 重置为 1,可能出现:

  • 关灯时刚好停在 1.06
  • 图标在关灯状态下仍然保持“放大”

这会让用户误以为它还在亮。

4.4 glowAnim 为什么用 300ms 的 timing?

光晕的变化应该“快但不突兀”:

  • 太快(比如 50ms)会闪
  • 太慢(比如 1500ms)会拖泥带水

300ms 是一个很常见的 UI 过渡时间,既能让用户感知变化,又不会影响操作节奏。


05. 根容器交互:点击全屏切换开关

const toggleLight = () => setIsOn(!isOn);

return (
  <TouchableOpacity
    style={[
      styles.container,
      { backgroundColor: isOn ? `rgba(255,255,255,${brightness})` : '#0f0f23' },
    ]}
    onPress={toggleLight}
    activeOpacity={1}
  >
    {/* ... */}
  </TouchableOpacity>
);

解释:

5.1 为什么用 TouchableOpacity 做整页容器?

因为这个工具的交互极简单:点击任何地方开/关。

用整页可点击的容器可以让用户:

  • 单手操作
  • 不用找按钮
  • 在暗光场景下也能快速触发

5.2 activeOpacity={1} 的意义

TouchableOpacity 默认会在按下时降低透明度,这会导致:

  • 你按下屏幕的一瞬间,整个页面会变暗(或闪一下)

对于“手电筒”这种对光感非常敏感的界面,这种默认反馈是负效果。

因此设置 activeOpacity={1},禁用按下的透明度变化,让灯光表现更稳定。

5.3 背景色为什么用 rgba(255,255,255,brightness)

这里的设计是“屏幕发光模拟”:

  • rgb(255,255,255) 表示白光
  • alpha 值代表亮度强弱

例如:

  • 30%:rgba(255,255,255,0.3)(偏暗)
  • 100%:rgba(255,255,255,1)(最亮)

这种做法不依赖任何硬件能力,纯 UI 即可实现,适合 demo 和跨端场景。


06. 中心视觉:光晕 + 图标(叠层的顺序很重要)

<View style={styles.iconContainer}>
  <Animated.View
    style={[
      styles.glowCircle,
      {
        opacity: glowAnim,
        transform: [{ scale: pulseAnim }],
        backgroundColor: `rgba(255,255,0,${brightness * 0.3})`,
      },
    ]}
  />
  <Animated.Text
    style={[
      styles.icon,
      { opacity: isOn ? 0.3 : 1, transform: [{ scale: pulseAnim }] },
    ]}
  >
    🔦
  </Animated.Text>
</View>

解释:

6.1 为什么光晕是一个 Animated.View 圆形?

光晕本质上就是一个“模糊的光圈”。在纯 RN 样式中不做复杂 blur 的情况下,最简单的光晕就是:

  • 一个大圆
  • 带透明度
  • 颜色偏黄(更像暖光)

因此这里的 glowCircle 用了 200x200 的圆:

glowCircle: { position: 'absolute', width: 200, height: 200, borderRadius: 100 },

再用 opacitybackgroundColor 的 alpha 来控制强弱。

6.2 为什么光晕颜色用黄色,并乘以 brightness * 0.3

  • 黄色(255,255,0)更符合“灯光”的直觉
  • brightness * 0.3 是为了:
    • 即使 100% 亮度,光晕也不要太刺眼
    • 光晕应该是“辅助氛围”,而不是抢主视觉

因此我们把光晕的强度限制在一个较柔和的范围。

6.3 为什么图标在开灯时变淡(opacity 0.3)?

当背景变亮后,图标如果仍然很实,会显得“突兀”。

把图标在开灯时降低透明度,会产生一种感觉:

  • 灯已经打开
  • 图标退到背景

这其实是一种“视觉叙事”:让用户关注的是“光”,而不是“图标”。

6.4 为什么图标也跟随 pulseAnim

光晕和图标同频率呼吸,会更像一个整体发光体。

如果只有光晕在动、图标不动,用户会感觉“光圈在抖”,整体不够统一。


07. 亮度按钮区:事件冒泡处理是关键(stopPropagation)

开灯时显示亮度选择:

{isOn && (
  <View style={styles.brightnessControl}>
    {[0.3, 0.5, 0.7, 1].map(b => (
      <TouchableOpacity
        key={b}
        style={[styles.brightnessBtn, brightness === b && styles.brightnessBtnActive]}
        onPress={(e) => { e.stopPropagation(); setBrightness(b); }}
      >
        <Text style={[styles.brightnessText, { color: isOn ? '#333' : '#fff' }]}>
          {Math.round(b * 100)}%
        </Text>
      </TouchableOpacity>
    ))}
  </View>
)}

解释(这一段是“交互边界”的核心):

7.1 为什么亮度按钮只在 isOn 时显示?

关灯状态下显示亮度按钮意义不大,且会增加 UI 噪音。

更重要的是:

  • 关灯时用户只想“打开”
  • 开灯后才需要调节亮度

因此 isOn && (...) 是一种典型的“渐进式展示”,降低认知负担。

7.2 e.stopPropagation() 在这里解决了什么问题?

因为整页容器是一个 TouchableOpacity,它绑定了 onPress={toggleLight}

当你点击亮度按钮时,如果不阻止事件冒泡:

  • 亮度按钮的 onPress 触发
  • 外层容器的 onPress 也会触发

结果就是:

  • 你想调亮度,却把灯关了(或者开关抖动)

因此必须在按钮点击时:

  • stopPropagation() 阻断事件继续向外层冒泡
  • setBrightness(b) 更新亮度

这类“嵌套可点击区域”在 RN 工具页里非常常见,处理不好会严重影响体验。

7.3 为什么亮度值用 [0.3, 0.5, 0.7, 1]

这是一个权衡:

  • 档位太多会复杂
  • 档位太少又不够可调

4 个档位足够覆盖“省电/正常/偏亮/最亮”的需求。

而且这些值直接对应 alpha,换算非常直观。

7.4 按钮选中态为什么加边框和金色?

brightnessBtnActive: {
  backgroundColor: 'rgba(0,0,0,0.5)',
  borderWidth: 2,
  borderColor: '#ffd700',
},

金色边框在亮背景下仍然明显,同时与“灯光”主题相符。


08. 文案与颜色:根据背景自动切换深浅色

标题、状态文字、提示文字都做了“根据开关状态切颜色”的处理:

<Text style={[styles.headerTitle, { color: isOn ? '#333' : '#fff' }]}>手电筒</Text>

<Text style={[styles.status, { color: isOn ? '#333' : '#fff' }]}
>
  {isOn ? '💡 点击关闭' : '🌙 点击打开'}
</Text>

解释:

8.1 为什么开灯时文字要变深色?

开灯后背景变成白色(或浅色),如果文字仍然是白色,就会看不清。

因此根据 isOn 切换文字颜色,是最基本的可读性保障。

8.2 为什么状态文案要用 emoji?

工具箱 App 很多用户会快速扫一眼。

💡/🌙 能让状态提示更“像产品”,并且在暗色背景上更醒目。

8.3 底部提示为什么要写“这是模拟界面”?

因为很多人会误以为“手电筒=控制闪光灯”。

明确提示:

  • 避免误导
  • 避免用户觉得“为什么没有照亮现实世界”

09. 样式布局:用 absolute 让信息分布更稳定

样式文件里有几个关键布局点:

const styles = StyleSheet.create({
  container: { flex: 1, justifyContent: 'center', alignItems: 'center' },
  header: { position: 'absolute', top: 60, alignItems: 'center' },
  hint: { position: 'absolute', bottom: 40, textAlign: 'center', fontSize: 12 },
});

解释:

9.1 为什么 Header 用 absolute 固定在顶部?

中间区域有呼吸动画和光晕,如果 Header 跟随布局流,可能会被挤压或发生抖动。

固定顶部可以保证:

  • 无论中间动画如何变化,标题位置稳定
  • 用户随时知道自己在“手电筒”工具里

9.2 为什么 Hint 用 absolute 固定在底部?

底部提示属于“免责声明/说明”,不应该参与中间布局竞争。

固定底部可以:

  • 避免随着亮度按钮显示/隐藏而上下跳动
  • 保持阅读位置一致

这类工具页的布局原则是:

  • 中间是主交互
  • 上下是说明与辅助信息

10. 小结

这个「手电筒」小工具的实现重点不在于代码量,而在于把细节处理到位:

  • 全屏点击开关:让工具符合“随手一按就用”的产品直觉
  • 事件冒泡处理:亮度按钮点击必须 stopPropagation(),否则交互会非常糟糕
  • 动画分层:呼吸脉冲(循环)与光晕显隐(过渡)职责明确
  • 可读性自适应:背景变亮时,文字颜色切深色
  • 免责声明清晰:避免用户误解为硬件手电筒

欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net

Logo

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

更多推荐