在这里插入图片描述

秒表和倒计时虽然都是计时工具,但使用场景完全不同。秒表用于测量时间间隔,比如跑步计时、烹饪计时等。今天我们用 React Native 实现一个带有计次功能和动态效果的秒表。

功能需求

这个秒表需要实现:

  • 精确到毫秒的计时显示
  • 开始、停止、重置功能
  • 计次功能,记录多个时间点
  • 运行时有动态视觉反馈

和倒计时不同,秒表是从 0 开始往上计数,而且需要更高的精度。

状态和动画值定义

import React, { useState, useRef, useEffect } from 'react';
import { View, Text, TouchableOpacity, StyleSheet, ScrollView, Animated } from 'react-native';

export const Stopwatch: React.FC = () => {
  const [time, setTime] = useState(0);
  const [isRunning, setIsRunning] = useState(false);
  const [laps, setLaps] = useState<number[]>([]);
  const intervalRef = useRef<any>(null);
  const pulseAnim = useRef(new Animated.Value(1)).current;
  const glowAnim = useRef(new Animated.Value(0)).current;
  const ringRotation = useRef(new Animated.Value(0)).current;

状态变量:

  • time 存储当前计时的毫秒数
  • isRunning 标记是否正在计时
  • laps 存储计次记录的数组

动画值:

  • pulseAnim 控制计时器的脉冲效果
  • glowAnim 控制发光效果
  • ringRotation 控制外圈的旋转

鸿蒙 ArkTS 对比:状态定义

@Entry
@Component
struct Stopwatch {
  @State time: number = 0
  @State isRunning: boolean = false
  @State laps: number[] = []
  @State pulseScale: number = 1
  @State glowOpacity: number = 0
  @State ringAngle: number = 0
  
  private intervalId: number = -1

ArkTS 的状态定义更简洁,动画值直接用数字类型。在 React Native 中需要用 Animated.Value 包装,这是两个框架在动画实现上的主要区别。

动画效果控制

运行状态变化时启动或停止动画:

  useEffect(() => {
    if (isRunning) {
      Animated.loop(
        Animated.sequence([
          Animated.timing(pulseAnim, { toValue: 1.05, duration: 500, useNativeDriver: true }),
          Animated.timing(pulseAnim, { toValue: 1, duration: 500, useNativeDriver: true }),
        ])
      ).start();
      Animated.timing(glowAnim, { toValue: 1, duration: 300, useNativeDriver: false }).start();
      Animated.loop(
        Animated.timing(ringRotation, { toValue: 1, duration: 3000, useNativeDriver: true })
      ).start();
    } else {
      pulseAnim.setValue(1);
      Animated.timing(glowAnim, { toValue: 0, duration: 300, useNativeDriver: false }).start();
      ringRotation.setValue(0);
    }
  }, [isRunning]);

运行时启动三个动画:

  1. 脉冲动画:计时器圆圈轻微放大缩小,1 秒一个周期
  2. 发光动画:绿色光晕渐显
  3. 旋转动画:外圈虚线环 3 秒转一圈

停止时重置所有动画值,发光效果渐隐。

鸿蒙 ArkTS 对比:动画控制

startAnimations() {
  // 脉冲动画
  animateTo({
    duration: 500,
    iterations: -1,
    playMode: PlayMode.Alternate,
    curve: Curve.EaseInOut
  }, () => {
    this.pulseScale = 1.05
  })
  
  // 旋转动画
  animateTo({
    duration: 3000,
    iterations: -1,
    curve: Curve.Linear
  }, () => {
    this.ringAngle = 360
  })
}

stopAnimations() {
  this.pulseScale = 1
  this.ringAngle = 0
}

ArkTS 用 playMode: PlayMode.Alternate 实现往复动画,React Native 用 Animated.sequence 串联两个方向的动画。

核心计时逻辑

  const start = () => {
    setIsRunning(true);
    intervalRef.current = setInterval(() => {
      setTime(prev => prev + 10);
    }, 10);
  };

  const stop = () => {
    setIsRunning(false);
    clearInterval(intervalRef.current);
  };

  const reset = () => {
    stop();
    setTime(0);
    setLaps([]);
  };

  const lap = () => setLaps([time, ...laps]);

秒表的定时器间隔是 10 毫秒,每次增加 10。这样可以显示到百分之一秒的精度。

lap 函数把当前时间添加到数组开头,这样最新的记录会显示在最上面。用展开运算符 [time, ...laps] 创建新数组,保证 React 能检测到状态变化。

时间格式化

  const formatTime = (ms: number) => {
    const minutes = Math.floor(ms / 60000);
    const seconds = Math.floor((ms % 60000) / 1000);
    const centiseconds = Math.floor((ms % 1000) / 10);
    return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}.${centiseconds.toString().padStart(2, '0')}`;
  };

把毫秒数转换成 MM:SS.CC 格式:

  • 分钟:总毫秒数除以 60000
  • 秒:余数除以 1000
  • 百分秒:余数除以 10

padStart(2, '0') 确保每个部分都是两位数字。

鸿蒙 ArkTS 对比:时间格式化

formatTime(ms: number): string {
  let minutes = Math.floor(ms / 60000)
  let seconds = Math.floor((ms % 60000) / 1000)
  let centiseconds = Math.floor((ms % 1000) / 10)
  
  return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}.${centiseconds.toString().padStart(2, '0')}`
}

格式化逻辑完全一样,这是纯 JavaScript 代码,在任何平台都能运行。

动画插值

  const spin = ringRotation.interpolate({
    inputRange: [0, 1],
    outputRange: ['0deg', '360deg'],
  });

  const glowColor = glowAnim.interpolate({
    inputRange: [0, 1],
    outputRange: ['rgba(0, 255, 136, 0)', 'rgba(0, 255, 136, 0.5)'],
  });

interpolate 把动画值映射成实际的样式值。旋转角度从 0 到 360 度,发光颜色从透明到半透明绿色。

界面渲染:计时器区域

  return (
    <View style={styles.container}>
      <View style={styles.timerContainer}>
        <Animated.View style={[styles.outerRing, { transform: [{ rotate: spin }] }]}>
          <View style={styles.ringDot} />
        </Animated.View>
        <Animated.View style={[styles.glowRing, { shadowColor: glowAnim.interpolate({
          inputRange: [0, 1], outputRange: ['transparent', '#00ff88']
        }), shadowOpacity: glowAnim }]}>
          <Animated.View style={[styles.timerCircle, { transform: [{ scale: pulseAnim }] }]}>
            <Text style={styles.timer}>{formatTime(time)}</Text>
            <Text style={styles.msLabel}>毫秒精度</Text>
          </Animated.View>
        </Animated.View>
      </View>

计时器区域有三层:

  1. 外圈虚线环,带一个小圆点,整体旋转
  2. 发光层,控制阴影颜色和透明度
  3. 内圈计时器,显示时间,带脉冲缩放

这种分层设计让每个动画效果独立控制,互不干扰。

控制按钮

      <View style={styles.btnRow}>
        {!isRunning ? (
          <TouchableOpacity style={[styles.btn, styles.btnStart]} onPress={start}>
            <View style={styles.btn3D}>
              <Text style={styles.btnText}>▶ 开始</Text>
            </View>
          </TouchableOpacity>
        ) : (
          <TouchableOpacity style={[styles.btn, styles.btnStop]} onPress={stop}>
            <View style={styles.btn3D}>
              <Text style={styles.btnText}>⏸ 停止</Text>
            </View>
          </TouchableOpacity>
        )}
        <TouchableOpacity style={[styles.btn, styles.btnLap]} onPress={isRunning ? lap : reset}>
          <View style={styles.btn3D}>
            <Text style={styles.btnText}>{isRunning ? '🏁 计次' : '↺ 重置'}</Text>
          </View>
        </TouchableOpacity>
      </View>

左边按钮根据运行状态切换开始/停止,右边按钮在运行时是计次,停止时是重置。这种设计减少了按钮数量,界面更简洁。

鸿蒙 ArkTS 对比:条件渲染

Row() {
  if (!this.isRunning) {
    Button('▶ 开始')
      .onClick(() => this.start())
  } else {
    Button('⏸ 停止')
      .onClick(() => this.stop())
  }
  
  Button(this.isRunning ? '🏁 计次' : '↺ 重置')
    .onClick(() => {
      if (this.isRunning) {
        this.lap()
      } else {
        this.reset()
      }
    })
}

ArkTS 在 build 方法中直接使用 if 语句进行条件渲染,React Native 用三元表达式。两种方式都能实现相同的效果。

计次记录列表

      <ScrollView style={styles.laps} showsVerticalScrollIndicator={false}>
        {laps.map((lapTime, i) => (
          <Animated.View key={i} style={styles.lapItem}>
            <View style={styles.lapBadge}>
              <Text style={styles.lapNumber}>{laps.length - i}</Text>
            </View>
            <Text style={styles.lapTime}>{formatTime(lapTime)}</Text>
          </Animated.View>
        ))}
      </ScrollView>
    </View>
  );
};

计次记录用 ScrollView 包裹,支持滚动查看。每条记录显示序号和时间,序号用 laps.length - i 计算,让最新的记录显示为最大的数字。

showsVerticalScrollIndicator={false} 隐藏滚动条,界面更干净。

样式定义:容器和计时器

const styles = StyleSheet.create({
  container: { flex: 1, backgroundColor: '#0d1117', padding: 20 },
  timerContainer: { alignItems: 'center', justifyContent: 'center', height: 280, marginVertical: 20 },
  outerRing: {
    position: 'absolute',
    width: 260,
    height: 260,
    borderRadius: 130,
    borderWidth: 2,
    borderColor: '#00ff88',
    borderStyle: 'dashed',
  },
  ringDot: {
    position: 'absolute',
    top: -6,
    left: '50%',
    marginLeft: -6,
    width: 12,
    height: 12,
    borderRadius: 6,
    backgroundColor: '#00ff88',
  },
  glowRing: { shadowOffset: { width: 0, height: 0 }, shadowRadius: 30, elevation: 20 },
  timerCircle: {
    width: 220,
    height: 220,
    borderRadius: 110,
    backgroundColor: '#161b22',
    justifyContent: 'center',
    alignItems: 'center',
    borderWidth: 4,
    borderColor: '#30363d',
  },
  timer: { fontSize: 48, color: '#fff', fontWeight: '200', fontFamily: 'monospace' },
  msLabel: { color: '#8b949e', fontSize: 12, marginTop: 8 },

背景色用 GitHub 风格的深色 #0d1117。外圈用虚线边框 borderStyle: 'dashed',配合绿色 #00ff88 形成科技感。

计时器字体用 fontFamily: 'monospace',等宽字体让数字变化时不会跳动。

按钮样式

  btnRow: { flexDirection: 'row', justifyContent: 'center', marginBottom: 20 },
  btn: { marginHorizontal: 15 },
  btn3D: {
    width: 100,
    height: 100,
    borderRadius: 50,
    justifyContent: 'center',
    alignItems: 'center',
    shadowColor: '#000',
    shadowOffset: { width: 0, height: 8 },
    shadowOpacity: 0.5,
    shadowRadius: 10,
    elevation: 10,
  },
  btnStart: {},
  btnStop: {},
  btnLap: {},
  btnText: { color: '#fff', fontSize: 14, fontWeight: '600' },

按钮用圆形设计,100x100 的尺寸方便点击。阴影让按钮有立体感。预留了 btnStartbtnStopbtnLap 样式,可以根据需要添加不同的背景色。

计次记录样式

  laps: { flex: 1, backgroundColor: '#161b22', borderRadius: 16, padding: 16 },
  lapItem: {
    flexDirection: 'row',
    alignItems: 'center',
    paddingVertical: 12,
    borderBottomWidth: 1,
    borderBottomColor: '#30363d',
  },
  lapBadge: {
    width: 32,
    height: 32,
    borderRadius: 16,
    backgroundColor: '#238636',
    justifyContent: 'center',
    alignItems: 'center',
    marginRight: 16,
  },
  lapNumber: { color: '#fff', fontSize: 14, fontWeight: '600' },
  lapTime: { color: '#fff', fontSize: 20, fontFamily: 'monospace' },
});

计次列表用卡片样式,每条记录之间用细线分隔。序号用绿色圆形徽章显示,和整体的绿色主题呼应。时间同样用等宽字体,保持一致性。

性能考虑

秒表的定时器每 10 毫秒触发一次,这个频率比较高。在 React Native 中,每次 setTime 都会触发组件重新渲染。为了保证性能:

  1. 动画使用 useNativeDriver: true,在原生线程执行
  2. 计次列表用 ScrollView 而不是 FlatList,因为数据量通常不大
  3. 时间格式化函数是纯函数,没有副作用

在 OpenHarmony 上,这些优化同样有效。如果发现性能问题,可以考虑用 requestAnimationFrame 替代 setInterval,或者降低更新频率到 100 毫秒。

小结

这个秒表展示了 React Native 中高频定时器和多重动画的配合使用。通过分层设计,每个动画效果独立控制,代码结构清晰。计次功能用数组存储,配合 map 渲染列表,是 React 中常见的数据驱动模式。

在 OpenHarmony 平台上,10 毫秒的定时器精度可以正常工作,动画效果也能流畅运行。如果需要更高的精度,可以考虑使用原生模块来实现计时逻辑。


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

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐