在这里插入图片描述

今天我们用 React Native 实现一个打字练习工具,支持实时反馈、速度统计、进度显示。

句子库和状态设计

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

const sentences = [
  'The quick brown fox jumps over the lazy dog.',
  'Pack my box with five dozen liquor jugs.',
  'How vexingly quick daft zebras jump!',
  'The five boxing wizards jump quickly.',
  'Sphinx of black quartz, judge my vow.',
];

export const TypingPractice: React.FC = () => {
  const [target, setTarget] = useState(sentences[0]);
  const [input, setInput] = useState('');
  const [startTime, setStartTime] = useState<number | null>(null);
  const [endTime, setEndTime] = useState<number | null>(null);
  const [isComplete, setIsComplete] = useState(false);
  
  const pulseAnim = useRef(new Animated.Value(1)).current;
  const statsAnim = useRef(new Animated.Value(0)).current;
  const buttonAnim = useRef(new Animated.Value(1)).current;
  const progressAnim = useRef(new Animated.Value(0)).current;

句子库包含 5 个英文句子,都是包含所有字母的全字母句。

为什么用全字母句?因为全字母句包含 26 个英文字母,能全面练习所有按键。比如第一句"The quick brown fox jumps over the lazy dog"包含所有字母,是最经典的打字练习句子。

状态设计

  • target:目标句子,从句子库随机选择
  • input:用户输入的内容
  • startTime:开始输入的时间戳,第一次输入时记录
  • endTime:完成输入的时间戳,输入完成时记录
  • isComplete:是否完成输入

四个动画值

  • pulseAnim:目标文本的脉冲动画,循环缩放
  • statsAnim:统计信息的缩放和淡入动画
  • buttonAnim:按钮的缩放动画
  • progressAnim:进度条的宽度动画

为什么用时间戳而不是计时器?因为时间戳更准确。计时器(setInterval)受 JavaScript 事件循环影响,可能不准确。时间戳直接记录开始和结束的时刻,用结束减开始得到精确的时长。

脉冲动画

  useEffect(() => {
    Animated.loop(
      Animated.sequence([
        Animated.timing(pulseAnim, { toValue: 1.02, duration: 1000, useNativeDriver: true }),
        Animated.timing(pulseAnim, { toValue: 1, duration: 1000, useNativeDriver: true }),
      ])
    ).start();
  }, []);

组件加载时启动脉冲动画,目标文本循环缩放。

循环动画Animated.loop 让动画无限重复。内部是序列动画,从 1 到 1.02(1 秒),再从 1.02 到 1(1 秒),总共 2 秒一个周期。

为什么缩放到 1.02 而不是 1.1?因为 1.02 是 2% 的缩放,非常微妙,像"呼吸"。1.1 是 10% 的缩放,太明显,会干扰用户阅读。微妙的脉冲动画吸引注意力,但不会分散注意力。

开始练习

  const start = () => {
    Animated.sequence([
      Animated.timing(buttonAnim, { toValue: 0.9, duration: 100, useNativeDriver: true }),
      Animated.spring(buttonAnim, { toValue: 1, friction: 3, useNativeDriver: true }),
    ]).start();
    
    setTarget(sentences[Math.floor(Math.random() * sentences.length)]);
    setInput('');
    setStartTime(null);
    setEndTime(null);
    setIsComplete(false);
    statsAnim.setValue(0);
    progressAnim.setValue(0);
  };

  useEffect(() => { start(); }, []);

开始函数随机选择句子,重置所有状态。

按钮动画:序列动画,按钮缩小到 90%(100ms),再弹回到 100%。给用户"点击"的触感反馈。

随机选择句子Math.floor(Math.random() * sentences.length) 生成 0 到 4 的随机整数,作为数组索引。

重置状态

  • 清空输入
  • 清除开始和结束时间
  • 设置为未完成
  • 重置统计动画值为 0
  • 重置进度动画值为 0

组件加载时启动useEffect(() => { start(); }, []) 在组件首次渲染后调用 start,选择第一个句子。

输入处理

  const handleChange = (text: string) => {
    if (!startTime) setStartTime(Date.now());
    setInput(text);
    
    // 进度动画
    Animated.timing(progressAnim, {
      toValue: text.length / target.length,
      duration: 100,
      useNativeDriver: false,
    }).start();
    
    if (text === target) {
      setEndTime(Date.now());
      setIsComplete(true);
      Animated.spring(statsAnim, { toValue: 1, friction: 4, useNativeDriver: true }).start();
    }
  };

输入处理函数记录开始时间、更新进度、检查完成。

记录开始时间:如果 startTimenull(还没开始),记录当前时间戳。Date.now() 返回当前时间的毫秒数。

更新输入setInput(text) 更新输入状态,触发重新渲染。

进度动画

  • text.length / target.length:输入长度除以目标长度,得到 0-1 的进度值
  • duration: 100:动画时长 100ms,快速响应
  • useNativeDriver: false:因为动画控制的是 width(宽度),不是 transformopacity,不能用原生驱动

检查完成:如果输入和目标完全相同(text === target),记录结束时间,设置为完成,启动统计动画。

为什么用 === 而不是检查长度?因为 === 检查内容是否完全相同,包括每个字符。如果只检查长度,用户可能输入错误的字符但长度相同,也会判定为完成。

统计计算

  const getStats = () => {
    if (!startTime || !endTime) return null;
    const timeInSeconds = (endTime - startTime) / 1000;
    const words = target.split(' ').length;
    const wpm = Math.round((words / timeInSeconds) * 60);
    const cpm = Math.round((target.length / timeInSeconds) * 60);
    return { time: timeInSeconds.toFixed(1), wpm, cpm };
  };

  const stats = getStats();

统计函数计算用时、WPM(每分钟单词数)、CPM(每分钟字符数)。

检查时间:如果开始或结束时间不存在,返回 null,表示还没完成。

计算用时(endTime - startTime) / 1000 把毫秒转成秒。toFixed(1) 保留 1 位小数。

计算单词数target.split(' ').length 按空格分割,得到单词数组,length 是单词数。

计算 WPM(words / timeInSeconds) * 60

  • words / timeInSeconds:每秒单词数
  • * 60:转换成每分钟单词数
  • Math.round(...):四舍五入取整

计算 CPM(target.length / timeInSeconds) * 60

  • target.length:字符数(包括空格和标点)
  • / timeInSeconds:每秒字符数
  • * 60:转换成每分钟字符数

为什么同时显示 WPM 和 CPM?因为 WPM 是打字速度的标准指标,CPM 是更精确的指标。WPM 受单词长度影响(短单词多,WPM 高),CPM 不受影响。两个指标结合,更全面地评估打字速度。

目标文本渲染

  return (
    <View style={styles.container}>
      <View style={styles.header}>
        <Text style={styles.headerIcon}>⌨️</Text>
        <Text style={styles.headerTitle}>打字练习</Text>
      </View>

      <Animated.View style={[styles.targetBox, { transform: [{ scale: pulseAnim }] }]}>
        <Text style={styles.targetText}>
          {target.split('').map((char, i) => (
            <Text key={i} style={[
              styles.char,
              i < input.length && (input[i] === char ? styles.charCorrect : styles.charWrong)
            ]}>{char}</Text>
          ))}
        </Text>
        <View style={styles.progressBar}>
          <Animated.View style={[styles.progressFill, {
            width: progressAnim.interpolate({ inputRange: [0, 1], outputRange: ['0%', '100%'] })
          }]} />
        </View>
      </Animated.View>

目标文本逐字符渲染,根据输入状态显示不同颜色。

脉冲动画:目标框应用 pulseAnim 缩放动画,循环缩放。

逐字符渲染target.split('') 把字符串拆成字符数组,map 遍历每个字符,渲染一个 Text 组件。

字符样式

  • 默认灰色(styles.char
  • 如果已输入(i < input.length):
    • 输入正确(input[i] === char):绿色(styles.charCorrect
    • 输入错误(input[i] !== char):红色背景(styles.charWrong

为什么用 i < input.length 判断?因为只有已输入的字符才需要显示颜色。未输入的字符保持灰色。i 是字符在目标中的索引,如果小于输入长度,说明这个字符已经输入了。

进度条

  • 外层是灰色背景条
  • 内层是蓝色填充条,宽度从 0% 到 100%
  • 用插值把进度值 0-1 映射到宽度 ‘0%’-‘100%’

为什么进度条用百分比而不是像素?因为百分比是相对值,适应不同屏幕宽度。如果用像素,需要计算容器宽度,更复杂。

输入框和统计信息

      <View style={styles.inputWrapper}>
        <TextInput
          style={styles.input}
          value={input}
          onChangeText={handleChange}
          placeholder="开始输入..."
          placeholderTextColor="#666"
          editable={!isComplete}
          autoCapitalize="none"
          autoCorrect={false}
        />
      </View>

      {isComplete && stats && (
        <Animated.View style={[styles.stats, {
          transform: [{ scale: statsAnim }, { perspective: 1000 }],
          opacity: statsAnim,
        }]}>
          <Text style={styles.statsTitle}>🎉 完成!</Text>
          <View style={styles.statsRow}>
            {[
              { value: stats.time + 's', label: '用时', icon: '⏱️' },
              { value: stats.wpm, label: 'WPM', icon: '📝' },
              { value: stats.cpm, label: 'CPM', icon: '⚡' },
            ].map((stat, i) => (
              <View key={i} style={styles.statItem}>
                <Text style={styles.statIcon}>{stat.icon}</Text>
                <Text style={styles.statValue}>{stat.value}</Text>
                <Text style={styles.statLabel}>{stat.label}</Text>
              </View>
            ))}
          </View>
        </Animated.View>
      )}

输入框

  • editable={!isComplete}:完成后禁用输入框
  • autoCapitalize="none":不自动大写,因为目标句子有特定的大小写
  • autoCorrect={false}:不自动纠错,因为需要精确匹配

统计信息

  • 只在完成时显示(isComplete && stats
  • 应用缩放和淡入动画
  • 显示三个统计项:用时、WPM、CPM

统计项数组:用数组存储三个统计项的数据,map 遍历渲染。每个统计项包含值、标签、图标。

为什么用 perspective: 1000?因为 perspective 给缩放动画增加 3D 效果,让统计信息看起来像"从远处飞过来"。没有 perspective,缩放是平面的;有 perspective,缩放有透视感。

按钮

      <Animated.View style={{ transform: [{ scale: buttonAnim }] }}>
        <TouchableOpacity style={styles.btn} onPress={start} activeOpacity={0.8}>
          <Text style={styles.btnText}>{isComplete ? '🔄 再来一次' : '🔀 换一句'}</Text>
        </TouchableOpacity>
      </Animated.View>
    </View>
  );
};

按钮用 Animated.View 包裹,应用缩放动画。

按钮文字

  • 完成时显示"🔄 再来一次"
  • 未完成时显示"🔀 换一句"

为什么用不同的文字?因为不同状态下,按钮的功能不同。完成时,用户想"再练一次";未完成时,用户可能觉得句子太难,想"换一句"。不同的文字让用户清楚按钮的作用。

鸿蒙 ArkTS 对比:打字逻辑

@State target: string = sentences[0]
@State input: string = ''
@State startTime: number | null = null
@State endTime: number | null = null
@State isComplete: boolean = false

handleChange(text: string) {
  if (!this.startTime) this.startTime = Date.now()
  this.input = text
  
  if (text === this.target) {
    this.endTime = Date.now()
    this.isComplete = true
  }
}

getStats() {
  if (!this.startTime || !this.endTime) return null
  const timeInSeconds = (this.endTime - this.startTime) / 1000
  const words = this.target.split(' ').length
  const wpm = Math.round((words / timeInSeconds) * 60)
  const cpm = Math.round((this.target.length / timeInSeconds) * 60)
  return { time: timeInSeconds.toFixed(1), wpm, cpm }
}

ArkTS 中的打字逻辑完全一样,核心是字符串比较、时间计算、统计公式。Date.now()splitMath.round 都是标准 JavaScript API,跨平台通用。

样式定义:容器和目标框

const styles = StyleSheet.create({
  container: { flex: 1, backgroundColor: '#0f0f23', padding: 20 },
  header: { alignItems: 'center', marginBottom: 24 },
  headerIcon: { fontSize: 50, marginBottom: 8 },
  headerTitle: { fontSize: 28, fontWeight: '700', color: '#fff' },
  targetBox: {
    backgroundColor: '#1a1a3e',
    padding: 20,
    borderRadius: 16,
    marginBottom: 20,
    borderWidth: 1,
    borderColor: '#3a3a6a',
  },
  targetText: { fontSize: 20, lineHeight: 32 },
  char: { color: '#666' },
  charCorrect: { color: '#2ecc71' },
  charWrong: { color: '#e74c3c', backgroundColor: 'rgba(231, 76, 60, 0.2)' },

容器用深蓝黑色背景(#0f0f23),营造深色主题。

目标框:深蓝色背景(#1a1a3e),圆角 16,边框。

目标文字:字号 20,行高 32。行高比字号大 12,让文字有足够的垂直间距,不会太挤。

字符样式

  • 默认灰色(#666)
  • 正确:绿色(#2ecc71)
  • 错误:红色文字(#e74c3c)+ 半透明红色背景

为什么错误字符用背景色?因为背景色能突出显示错误,让用户立即注意到。只用红色文字,不够醒目;加上背景色,错误字符像"高亮"一样显眼。

样式定义:进度条和输入框

  progressBar: {
    height: 4,
    backgroundColor: '#252550',
    borderRadius: 2,
    marginTop: 16,
    overflow: 'hidden',
  },
  progressFill: { height: '100%', backgroundColor: '#4A90D9', borderRadius: 2 },
  inputWrapper: {
    backgroundColor: '#1a1a3e',
    borderRadius: 12,
    marginBottom: 20,
    borderWidth: 1,
    borderColor: '#3a3a6a',
  },
  input: { padding: 16, fontSize: 18, color: '#fff' },

进度条

  • 高度 4,很细
  • 深蓝色背景(#252550)
  • overflow: 'hidden':裁剪超出边界的内容,确保圆角生效

进度填充

  • 高度 100%,填满进度条
  • 蓝色(#4A90D9)
  • 宽度由动画控制,从 0% 到 100%

输入框:深蓝色背景,白色文字,字号 18。

样式定义:统计信息和按钮

  stats: {
    backgroundColor: '#1a1a3e',
    padding: 24,
    borderRadius: 16,
    marginBottom: 20,
    borderWidth: 1,
    borderColor: '#ffd700',
  },
  statsTitle: { color: '#ffd700', fontSize: 28, textAlign: 'center', marginBottom: 20, fontWeight: '700' },
  statsRow: { flexDirection: 'row', justifyContent: 'space-around' },
  statItem: { alignItems: 'center' },
  statIcon: { fontSize: 24, marginBottom: 8 },
  statValue: { color: '#4A90D9', fontSize: 32, fontWeight: '700' },
  statLabel: { color: '#888', marginTop: 4 },
  btn: {
    backgroundColor: '#4A90D9',
    padding: 18,
    borderRadius: 12,
    alignItems: 'center',
    shadowColor: '#4A90D9',
    shadowOffset: { width: 0, height: 8 },
    shadowOpacity: 0.4,
    shadowRadius: 15,
  },
  btnText: { color: '#fff', fontSize: 18, fontWeight: '700' },
});

统计信息

  • 深蓝色背景,金色边框(#ffd700),营造"成就"的感觉
  • 标题用金色,字号 28,粗体

统计项

  • 水平排列,平均分布(justifyContent: 'space-around'
  • 图标字号 24
  • 数值用蓝色,字号 32,粗体,醒目
  • 标签用灰色,字号小,次要

按钮:蓝色背景,蓝色阴影,向下偏移 8 像素,模拟"悬浮"效果。

为什么统计信息用金色边框?因为金色代表"成就"、“胜利”。完成打字练习是一个成就,用金色边框庆祝。如果用普通的灰色边框,没有成就感。

小结

这个打字练习工具展示了实时反馈和统计计算的实现。逐字符比较显示正确和错误,进度条实时更新,完成后计算 WPM 和 CPM。脉冲动画吸引注意力,统计动画庆祝完成。


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

Logo

更多推荐