React Native Reanimated呼吸应用开发教程:动画呼吸练习
概述
呼吸练习是最简单有效的健康干预手段。通过调节呼吸,我们可以:
- 降低压力和焦虑:激活副交感神经
- 改善睡眠质量:放松身心
- 提高专注力:增强正念状态
本教程将教你使用React Native + Reanimated构建一个流畅的呼吸练习应用。
技术栈
code
React Native - 跨平台移动应用框架
├── Reanimated 3 - 高性能动画库
├── React Hooks - 状态管理
└── Audio API - 音频引导
Code collapsed
核心特性
| 功能 | 技术实现 |
|---|---|
| 流畅动画 | Reanimated 3 共享值转换 |
| 呼吸引导 | 自定义呼吸模式算法 |
| 音频提示 | React Native Sound |
| 数据追踪 | AsyncStorage 持久化 |
| 振动反馈 | Haptics API |
项目设置
安装依赖
code
# 创建项目
npx react-native init BreathingApp
cd BreathingApp
# 安装Reanimated 3
npm install react-native-reanimated
# 安装其他依赖
npm install react-native-sound
npm install @react-native-async-storage/async-storage
Code collapsed
配置Reanimated
code
// babel.config.js
module.exports = {
presets: ['module:metro-react-native-babel-preset'],
plugins: [
'react-native-reanimated/plugin', // 必须放在最后
],
};
Code collapsed
呼吸动画核心
1. 圆形呼吸指示器
code
// components/BreathingCircle.tsx
import React, { useEffect } from 'react';
import { StyleSheet, View, Text } from 'react-native';
import Animated, {
useSharedValue,
useAnimatedStyle,
withRepeat,
withTiming,
Easing,
interpolate,
} from 'react-native-reanimated';
interface BreathingCircleProps {
isInhaling: boolean;
duration: number; // 呼吸周期时长(毫秒)
}
export function BreathingCircle({ isInhaling, duration }: BreathingCircleProps) {
// 动画进度 (0 = 最小, 1 = 最大)
const progress = useSharedValue(0);
useEffect(() => {
// 根据呼吸状态调整动画
progress.value = withRepeat(
withTiming(isInhaling ? 1 : 0, {
duration: duration / 2, // 吸气或呼气各占一半
easing: Easing.inOut(Easing.ease),
}),
1, // 重复1次(完整周期)
false
);
}, [isInhaling]);
const animatedStyle = useAnimatedStyle(() => {
// 计算圆的大小变化
const scale = interpolate(progress.value, [0, 1], [1, 1.5]);
// 计算透明度变化
const opacity = interpolate(progress.value, [0, 1], [0.3, 0.8]);
// 计算颜色渐变(蓝色到绿色)
const hue = interpolate(progress.value, [0, 1], [200, 150]);
return {
width: 200 * scale,
height: 200 * scale,
borderRadius: 100 * scale,
backgroundColor: `hsla(${hue}, 70%, 50%, ${opacity})`,
transform: [{ scale }],
};
});
return (
<View style={styles.container}>
<Animated.View style={animatedStyle} />
<Text style={styles.instruction}>
{isInhaling ? '吸气' : '呼气'}
</Text>
</View>
);
}
const styles = StyleSheet.create({
container: {
justifyContent: 'center',
alignItems: 'center',
},
instruction: {
position: 'absolute',
fontSize: 24,
fontWeight: 'bold',
color: '#333',
},
});
Code collapsed
2. 多重圆环动画
code
// components/MultiRingBreathing.tsx
import React, { useEffect } from 'react';
import { StyleSheet, View } from 'react-native';
import Animated, {
useSharedValue,
useAnimatedStyle,
withDelay,
withRepeat,
withTiming,
Easing,
} from 'react-native-reanimated';
interface RingProps {
delay: number;
duration: number;
isInhaling: boolean;
}
function BreathingRing({ delay, duration, isInhaling }: RingProps) {
const scale = useSharedValue(1);
const opacity = useSharedValue(0.3);
useEffect(() => {
// 顺序动画:先缩放,再透明度
scale.value = withDelay(
delay,
withRepeat(
withTiming(isInhaling ? 1.3 : 1, {
duration: duration / 2,
easing: Easing.inOut(Easing.ease),
}),
1,
false
)
);
opacity.value = withDelay(
delay,
withRepeat(
withTiming(isInhaling ? 0.6 : 0.3, {
duration: duration / 2,
easing: Easing.inOut(Easing.ease),
}),
1,
false
)
);
}, [isInhaling]);
const animatedStyle = useAnimatedStyle(() => ({
width: 150,
height: 150,
borderRadius: 75,
borderWidth: 3,
borderColor: `rgba(100, 200, 255, ${opacity.value})`,
transform: [{ scale: scale.value }],
}));
return <Animated.View style={[styles.ring, animatedStyle]} />;
}
export function MultiRingBreathing({ isInhaling, duration }: { isInhaling: boolean; duration: number }) {
return (
<View style={styles.container}>
<BreathingRing delay={0} duration={duration} isInhaling={isInhaling} />
<BreathingRing delay={100} duration={duration} isInhaling={isInhaling} />
<BreathingRing delay={200} duration={duration} isInhaling={isInhaling} />
</View>
);
}
const styles = StyleSheet.create({
container: {
justifyContent: 'center',
alignItems: 'center',
},
ring: {
position: 'absolute',
},
});
Code collapsed
呼吸模式
1. 标准呼吸模式
code
// types/breathing.ts
export interface BreathingPattern {
name: string;
description: string;
inhaleDuration: number; // 吸气时长(秒)
holdDuration?: number; // 屏息时长(秒)
exhaleDuration: number; // 呼气时长(秒)
pauseDuration?: number; // 呼后停顿(秒)
}
export const BREATHING_PATTERNS: BreathingPattern[] = [
{
name: '4-7-8 呼吸法',
description: '帮助放松和入睡',
inhaleDuration: 4,
holdDuration: 7,
exhaleDuration: 8,
},
{
name: '箱式呼吸',
description: '减轻压力,提高专注',
inhaleDuration: 4,
holdDuration: 4,
exhaleDuration: 4,
pauseDuration: 4,
},
{
name: '自然呼吸',
description: '日常练习',
inhaleDuration: 4,
exhaleDuration: 6,
},
{
name: '强化呼吸',
description: '晨间提神',
inhaleDuration: 6,
holdDuration: 2,
exhaleDuration: 2,
},
];
Code collapsed
2. 呼吸会话管理器
code
// hooks/useBreathingSession.ts
import { useState, useEffect, useRef, useCallback } from 'react';
import { BreathingPattern } from '../types/breathing';
export interface BreathingState {
phase: 'inhale' | 'hold' | 'exhale' | 'pause';
remaining: number; // 当前阶段剩余秒数
cycle: number; // 当前周期数
totalCycles: number; // 总周期数
isActive: boolean; // 是否正在进行
}
export function useBreathingSession(pattern: BreathingPattern, totalCycles: number = 5) {
const [state, setState] = useState<BreathingState>({
phase: 'inhale',
remaining: pattern.inhaleDuration,
cycle: 1,
totalCycles,
isActive: false,
});
const timerRef = useRef<NodeJS.Timeout>();
// 计算总周期时长
const getCycleDuration = useCallback(() => {
return (
pattern.inhaleDuration +
(pattern.holdDuration || 0) +
pattern.exhaleDuration +
(pattern.pauseDuration || 0)
);
}, [pattern]);
// 开始呼吸练习
const start = useCallback(() => {
setState(prev => ({ ...prev, isActive: true }));
}, []);
// 暂停呼吸练习
const pause = useCallback(() => {
setState(prev => ({ ...prev, isActive: false }));
if (timerRef.current) {
clearInterval(timerRef.current);
}
}, []);
// 重置呼吸练习
const reset = useCallback(() => {
if (timerRef.current) {
clearInterval(timerRef.current);
}
setState({
phase: 'inhale',
remaining: pattern.inhaleDuration,
cycle: 1,
totalCycles,
isActive: false,
});
}, [pattern, totalCycles]);
// 计时器逻辑
useEffect(() => {
if (!state.isActive) return;
timerRef.current = setInterval(() => {
setState(prev => {
// 计算下一个阶段
let nextPhase: BreathingState['phase'] = prev.phase;
let nextRemaining = prev.remaining - 1;
let nextCycle = prev.cycle;
if (nextRemaining <= 0) {
// 当前阶段结束,切换到下一阶段
switch (prev.phase) {
case 'inhale':
if (pattern.holdDuration) {
nextPhase = 'hold';
nextRemaining = pattern.holdDuration;
} else {
nextPhase = 'exhale';
nextRemaining = pattern.exhaleDuration;
}
break;
case 'hold':
nextPhase = 'exhale';
nextRemaining = pattern.exhaleDuration;
break;
case 'exhale':
if (pattern.pauseDuration) {
nextPhase = 'pause';
nextRemaining = pattern.pauseDuration;
} else {
// 开始新周期
nextCycle = prev.cycle + 1;
if (nextCycle > prev.totalCycles) {
// 全部完成
return { ...prev, isActive: false };
}
nextPhase = 'inhale';
nextRemaining = pattern.inhaleDuration;
}
break;
case 'pause':
// 开始新周期
nextCycle = prev.cycle + 1;
if (nextCycle > prev.totalCycles) {
return { ...prev, isActive: false };
}
nextPhase = 'inhale';
nextRemaining = pattern.inhaleDuration;
break;
}
}
return {
...prev,
phase: nextPhase,
remaining: nextRemaining,
cycle: nextCycle,
};
});
}, 1000);
return () => {
if (timerRef.current) {
clearInterval(timerRef.current);
}
};
}, [state.isActive, pattern]);
return {
state,
start,
pause,
reset,
isInhaling: state.phase === 'inhale',
};
}
Code collapsed
完整界面
呼吸练习主界面
code
// screens/BreathingScreen.tsx
import React, { useState } from 'react';
import { View, Text, StyleSheet, TouchableOpacity, ScrollView } from 'react-native';
import { BreathingCircle } from '../components/BreathingCircle';
import { MultiRingBreathing } from '../components/MultiRingBreathing';
import { useBreathingSession } from '../hooks/useBreathingSession';
import { BREATHING_PATTERNS, BreathingPattern } from '../types/breathing';
export function BreathingScreen() {
const [selectedPattern, setSelectedPattern] = useState(BREATHING_PATTERNS[0]);
const [cycleCount, setCycleCount] = useState(5);
const { state, start, pause, reset, isInhaling } = useBreathingSession(
selectedPattern,
cycleCount
);
const currentDuration = isInhaling
? selectedPattern.inhaleDuration * 1000
: selectedPattern.exhaleDuration * 1000;
return (
<View style={styles.container}>
<Text style={styles.title}>呼吸练习</Text>
{/* 模式选择 */}
<ScrollView
horizontal
style={styles.patternSelector}
showsHorizontalScrollIndicator={false}
>
{BREATHING_PATTERNS.map((pattern) => (
<TouchableOpacity
key={pattern.name}
style={[
styles.patternButton,
selectedPattern.name === pattern.name && styles.patternButtonActive,
]}
onPress={() => setSelectedPattern(pattern)}
>
<Text
style={[
styles.patternButtonText,
selectedPattern.name === pattern.name && styles.patternButtonTextActive,
]}
>
{pattern.name}
</Text>
</TouchableOpacity>
))}
</ScrollView>
{/* 模式描述 */}
<Text style={styles.description}>{selectedPattern.description}</Text>
{/* 呼吸动画 */}
<View style={styles.animationContainer}>
<MultiRingBreathing isInhaling={isInhaling} duration={currentDuration} />
</View>
{/* 状态显示 */}
<View style={styles.statusContainer}>
<Text style={styles.phaseText}>
{getPhaseText(state.phase)}
</Text>
<Text style={styles.remainingText}>
{state.remaining} 秒
</Text>
<Text style={styles.cycleText}>
周期 {state.cycle} / {state.totalCycles}
</Text>
</View>
{/* 控制按钮 */}
<View style={styles.controls}>
{!state.isActive ? (
<TouchableOpacity style={styles.startButton} onPress={start}>
<Text style={styles.startButtonText}>开始</Text>
</TouchableOpacity>
) : (
<TouchableOpacity style={styles.pauseButton} onPress={pause}>
<Text style={styles.pauseButtonText}>暂停</Text>
</TouchableOpacity>
)}
<TouchableOpacity style={styles.resetButton} onPress={reset}>
<Text style={styles.resetButtonText}>重置</Text>
</TouchableOpacity>
</View>
{/* 周期数选择 */}
<View style={styles.cycleSelector}>
<Text style={styles.cycleLabel}>练习周期:</Text>
{[3, 5, 10].map((count) => (
<TouchableOpacity
key={count}
style={[
styles.cycleButton,
cycleCount === count && styles.cycleButtonActive,
]}
onPress={() => setCycleCount(count)}
>
<Text
style={[
styles.cycleButtonText,
cycleCount === count && styles.cycleButtonTextActive,
]}
>
{count}
</Text>
</TouchableOpacity>
))}
</View>
</View>
);
}
function getPhaseText(phase: BreathingState['phase']): string {
const phaseTexts = {
inhale: '吸气',
hold: '屏息',
exhale: '呼气',
pause: '停顿',
};
return phaseTexts[phase];
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
padding: 20,
},
title: {
fontSize: 28,
fontWeight: 'bold',
textAlign: 'center',
marginTop: 40,
marginBottom: 20,
},
patternSelector: {
marginBottom: 10,
},
patternButton: {
paddingHorizontal: 16,
paddingVertical: 8,
marginRight: 10,
borderRadius: 20,
backgroundColor: '#fff',
borderWidth: 1,
borderColor: '#ddd',
},
patternButtonActive: {
backgroundColor: '#4A90E2',
borderColor: '#4A90E2',
},
patternButtonText: {
fontSize: 14,
color: '#333',
},
patternButtonTextActive: {
color: '#fff',
},
description: {
fontSize: 14,
textAlign: 'center',
color: '#666',
marginBottom: 30,
},
animationContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
statusContainer: {
alignItems: 'center',
marginBottom: 30,
},
phaseText: {
fontSize: 32,
fontWeight: 'bold',
marginBottom: 10,
},
remainingText: {
fontSize: 48,
fontWeight: 'bold',
color: '#4A90E2',
},
cycleText: {
fontSize: 16,
color: '#666',
marginTop: 10,
},
controls: {
flexDirection: 'row',
justifyContent: 'center',
marginBottom: 30,
},
startButton: {
backgroundColor: '#4CAF50',
paddingHorizontal: 40,
paddingVertical: 15,
borderRadius: 25,
marginRight: 10,
},
startButtonText: {
color: '#fff',
fontSize: 18,
fontWeight: 'bold',
},
pauseButton: {
backgroundColor: '#FF9800',
paddingHorizontal: 40,
paddingVertical: 15,
borderRadius: 25,
marginRight: 10,
},
pauseButtonText: {
color: '#fff',
fontSize: 18,
fontWeight: 'bold',
},
resetButton: {
backgroundColor: '#f44336',
paddingHorizontal: 40,
paddingVertical: 15,
borderRadius: 25,
},
resetButtonText: {
color: '#fff',
fontSize: 18,
fontWeight: 'bold',
},
cycleSelector: {
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
},
cycleLabel: {
fontSize: 16,
marginRight: 10,
},
cycleButton: {
width: 40,
height: 40,
justifyContent: 'center',
alignItems: 'center',
borderRadius: 20,
backgroundColor: '#fff',
borderWidth: 1,
borderColor: '#ddd',
marginLeft: 10,
},
cycleButtonActive: {
backgroundColor: '#4A90E2',
borderColor: '#4A90E2',
},
cycleButtonText: {
fontSize: 16,
color: '#333',
},
cycleButtonTextActive: {
color: '#fff',
},
});
Code collapsed
音频引导
添加语音提示
code
// hooks/useBreathingAudio.ts
import { useEffect } from 'react';
import { Sound } from 'react-native-sound';
import { BreathingState } from './useBreathingSession';
// 启用音频(需要在iOS配置Info.plist)
Sound.setCategory('Playback');
export function useBreathingAudio(state: BreathingState) {
useEffect(() => {
if (!state.isActive) return;
// 播放阶段提示音
const playPhaseAudio = async () => {
const audioFiles = {
inhale: 'inhale.mp3',
hold: 'hold.mp3',
exhale: 'exhale.mp3',
pause: 'pause.mp3',
};
const sound = new Sound(
audioFiles[state.phase],
Sound.MAIN_BUNDLE,
(error) => {
if (error) {
console.log('音频加载失败', error);
return;
}
sound.play();
}
);
};
// 只在阶段刚开始时播放
if (state.remaining ===
getPhaseDuration(state.phase, selectedPattern)) {
playPhaseAudio();
}
}, [state.phase, state.remaining]);
}
function getPhaseDuration(phase: BreathingState['phase'], pattern: BreathingPattern): number {
switch (phase) {
case 'inhale': return pattern.inhaleDuration;
case 'hold': return pattern.holdDuration || 0;
case 'exhale': return pattern.exhaleDuration;
case 'pause': return pattern.pauseDuration || 0;
}
}
Code collapsed
数据追踪
记录练习历史
code
// hooks/useBreathingHistory.ts
import AsyncStorage from '@react-native-async-storage/async-storage';
import { BreathingPattern } from '../types/breathing';
interface BreathingSession {
date: string;
patternName: string;
duration: number; // 分钟
cycles: number;
}
const STORAGE_KEY = 'breathing_history';
export function useBreathingHistory() {
const saveSession = async (session: BreathingSession) => {
try {
const existingData = await AsyncStorage.getItem(STORAGE_KEY);
const history: BreathingSession[] = existingData
? JSON.parse(existingData)
: [];
history.push(session);
// 只保留最近30天
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const filteredHistory = history.filter(session => {
return new Date(session.date) >= thirtyDaysAgo;
});
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(filteredHistory));
} catch (error) {
console.error('保存练习记录失败', error);
}
};
const getHistory = async (): Promise<BreathingSession[]> => {
try {
const data = await AsyncStorage.getItem(STORAGE_KEY);
return data ? JSON.parse(data) : [];
} catch (error) {
console.error('获取练习记录失败', error);
return [];
}
};
const getStats = async () => {
const history = await getHistory();
const totalSessions = history.length;
const totalMinutes = history.reduce((sum, s) => sum + s.duration, 0);
const mostUsedPattern = getMostFrequent(
history.map(s => s.patternName)
);
return {
totalSessions,
totalMinutes,
mostUsedPattern,
streak: calculateStreak(history),
};
};
return { saveSession, getHistory, getStats };
}
function getMostFrequent(arr: string[]): string {
const frequency: Record<string, number> = {};
let maxCount = 0;
let mostFrequent = '';
arr.forEach(item => {
frequency[item] = (frequency[item] || 0) + 1;
if (frequency[item] > maxCount) {
maxCount = frequency[item];
mostFrequent = item;
}
});
return mostFrequent;
}
function calculateStreak(history: BreathingSession[]): number {
if (history.length === 0) return 0;
const sortedHistory = [...history].sort((a, b) =>
new Date(b.date).getTime() - new Date(a.date).getTime()
);
let streak = 0;
let currentDate = new Date();
for (const session of sortedHistory) {
const sessionDate = new Date(session.date);
const diffDays = Math.floor(
(currentDate.getTime() - sessionDate.getTime()) / (1000 * 60 * 60 * 24)
);
if (diffDays <= streak + 1) {
streak++;
currentDate = sessionDate;
} else {
break;
}
}
return streak;
}
Code collapsed
关键要点
- Reanimated 3提供流畅动画:使用共享值和工作线程
- 多种呼吸模式:4-7-8、箱式呼吸等
- 音频引导增强体验:语音提示每个阶段
- 数据追踪激励坚持:记录练习历史
- 自定义参数:周期数、模式可调
常见问题
Reanimated动画卡顿怎么办?
- 确保正确配置Babel插件
- 使用
useAnimatedStyle而非内联样式 - 避免在动画回调中执行重计算
iOS音频不播放?
需要在ios/YourApp/Info.plist添加:
code
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
Code collapsed
如何添加振动反馈?
code
import { Platform } from 'react-native';
import Vibration from 'react-native-vibration';
const vibrate = () => {
if (Platform.OS === 'ios') {
// iOS单次振动
Vibration.vibrate(500);
} else {
// Android模式振动
Vibration.vibrate([0, 200, 100, 200]);
}
};
Code collapsed
参考资料
- React Native Reanimated文档
- 呼吸训练科学依据
- 4-7-8呼吸法研究
- React Native Sound文档
发布日期:2026年3月8日 最后更新:2026年3月8日