RN for OpenHarmony 小工具 App 实战:手电筒实现
本文实现了一个模拟手电筒界面,主要功能包括: 点击屏幕任意位置切换开关状态 开灯时显示4个亮度档位按钮(30/50/70/100%) 通过光晕淡入淡出和呼吸脉冲动画增强真实感 技术要点: 使用isOn和brightness两个状态分别控制开关和亮度 通过useEffect管理动画启动/停止,确保状态同步 采用TouchableOpacity实现全屏点击,禁用默认透明度变化 使用Animated实现
这一篇我们实现一个「手电筒」小工具界面:点击屏幕开/关灯光,打开时提供多个“亮度档位”按钮,并通过呼吸脉冲 + 光晕过渡让界面更有真实的“照明氛围”。
需要强调:当前实现是模拟手电筒界面(通过背景色亮度模拟屏幕发光),并不直接控制设备闪光灯硬件。
本文所有代码片段均来自仓库真实文件:
src/pages/Flashlight.tsxsrc/tools/index.tssrc/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 做页面映射时的 keyicon选择🔦,用户在工具网格里一眼就能理解这是“手电筒”
如果你未来要把它升级为“真手电筒”(控制硬件闪光灯),建议仍然保持这套接入方式不变,只改页面内部实现即可。
2.2 ToolScreen 映射到真实页面组件
文件:src/screens/ToolScreen.tsx
Flashlight: Pages.Flashlight,
解释:
- 工具箱页面拿到配置后,会根据
tool.component去componentMap查组件 - 这类映射可以理解为“路由表”,也可以理解为“安全白名单”
- 好处是:工具配置再多,也不会出现随便传个字符串就能渲染任意组件的风险
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 },
再用 opacity 和 backgroundColor 的 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
更多推荐

所有评论(0)