康心伴Logo
康心伴WellAlly
Health

React Native Reanimated呼吸应用开发教程:动画呼吸练习 | WellAlly康心伴

5 分钟阅读

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

关键要点

  1. Reanimated 3提供流畅动画:使用共享值和工作线程
  2. 多种呼吸模式:4-7-8、箱式呼吸等
  3. 音频引导增强体验:语音提示每个阶段
  4. 数据追踪激励坚持:记录练习历史
  5. 自定义参数:周期数、模式可调

常见问题

Reanimated动画卡顿怎么办?

  1. 确保正确配置Babel插件
  2. 使用useAnimatedStyle而非内联样式
  3. 避免在动画回调中执行重计算

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日

免责声明: 本内容仅供教育参考,不能替代专业医疗建议。请咨询医生获取个性化诊断和治疗方案。

#

文章标签

React Native
Reanimated
呼吸练习
健康应用
动画开发

觉得这篇文章有帮助?

立即体验康心伴,开始您的健康管理之旅