在这里插入图片描述

前言

“吃葡萄不吐葡萄皮,不吃葡萄倒吐葡萄皮。”

绕口令是中国传统的语言游戏,通过快速朗读相似发音的词句来锻炼口齿清晰度。它不仅是儿童语言启蒙的好工具,也是播音员、主持人的必备训练项目。

本文将实现一个「绕口令练习」小工具,包含以下功能:

功能 描述
📜 内容展示 8 条经典绕口令
⬅️➡️ 切换浏览 上一个/下一个导航
📚 列表查看 展开/收起全部列表
✨ 滑动动画 切换时的滑入滑出效果
💡 练习技巧 贴心的练习建议

一、绕口令数据设计

1.1 数据结构定义

const twisters = [
  { title: '四和十', content: '四是四,十是十,十四是十四,四十是四十。谁能说准四十、十四、四十四,谁来试一试,谁说十四是四十,就打谁十四,谁说四十是十四,就打谁四十。' },
  { title: '吃葡萄', content: '吃葡萄不吐葡萄皮,不吃葡萄倒吐葡萄皮。' },
  { title: '黑化肥', content: '黑化肥发灰,灰化肥发黑。黑化肥发灰会挥发,灰化肥挥发会发黑。' },
  { title: '八百标兵', content: '八百标兵奔北坡,炮兵并排北边跑。炮兵怕把标兵碰,标兵怕碰炮兵炮。' },
  { title: '牛郎恋刘娘', content: '牛郎恋刘娘,刘娘念牛郎,牛郎年年恋刘娘,刘娘年年念牛郎,郎恋娘来娘念郎。' },
  { title: '红凤凰', content: '红凤凰,粉凤凰,红粉凤凰花凤凰。' },
  { title: '扁担长', content: '扁担长,板凳宽,扁担没有板凳宽,板凳没有扁担长。扁担绑在板凳上,板凳不让扁担绑在板凳上。' },
  { title: '司小四和史小世', content: '司小四和史小世,四月十四日十四时四十上集市,司小四买了四十四斤四两西红柿,史小世买了十四斤四两细蚕丝。' },
];

数据字段说明:

字段 类型 用途
title string 绕口令标题,便于识别
content string 绕口令完整内容

1.2 绕口令分类

这 8 条绕口令按发音难点分类:

难点类型 绕口令 训练重点
平翘舌 四和十、司小四和史小世 s/sh、z/zh 区分
唇齿音 吃葡萄、黑化肥 p/b、f/h 发音
鼻音 八百标兵、牛郎恋刘娘 n/l、ng 区分
声调 红凤凰、扁担长 四声变化

这种分类设计让用户可以针对性地练习自己的薄弱环节。


二、状态管理设计

2.1 核心状态

export const TongueTwister: React.FC = () => {
  const [current, setCurrent] = useState(0);
  const [showAll, setShowAll] = useState(false);

状态说明:

状态 类型 初始值 用途
current number 0 当前显示的绕口令索引
showAll boolean false 是否展开全部列表

为什么用索引而不是对象?

// 方案A:存储索引(当前做法)
const [current, setCurrent] = useState(0);
const twister = twisters[current];

// 方案B:存储对象
const [current, setCurrent] = useState(twisters[0]);

使用索引的优势:

  • 便于计算上一个/下一个
  • 便于判断当前选中项(列表高亮)
  • 状态更轻量

2.2 动画值初始化

const fadeAnim = useRef(new Animated.Value(1)).current;
const slideAnim = useRef(new Animated.Value(0)).current;

两个动画值的分工:

动画值 初始值 控制属性 效果
fadeAnim 1 opacity 淡入淡出
slideAnim 0 translateX 水平滑动

组合使用产生"滑出 + 滑入"的切换效果。


三、切换动画实现

3.1 动画函数

const animateChange = (newIndex: number) => {
  Animated.sequence([
    Animated.parallel([
      Animated.timing(fadeAnim, { toValue: 0, duration: 150, useNativeDriver: true }),
      Animated.timing(slideAnim, { toValue: -30, duration: 150, useNativeDriver: true }),
    ]),
    Animated.parallel([
      Animated.timing(fadeAnim, { toValue: 1, duration: 200, useNativeDriver: true }),
      Animated.spring(slideAnim, { toValue: 0, friction: 6, useNativeDriver: true }),
    ]),
  ]).start();
  setTimeout(() => setCurrent(newIndex), 150);
};

动画流程详解:

这个函数实现了一个"滑出-滑入"的切换效果:

阶段1(150ms):当前内容滑出
├── opacity: 1 → 0(淡出)
└── translateX: 0 → -30(向左滑出)

--- 150ms 时更新内容 ---

阶段2(200ms+):新内容滑入
├── opacity: 0 → 1(淡入)
└── translateX: -30 → 0(从左滑入,带弹性)

为什么向左滑出(-30)?

slideAnim, { toValue: -30, duration: 150 }

负值表示向左移动。这符合"翻页"的心理模型:

  • 点击"下一个":当前页向左滑出,新页从右滑入
  • 视觉上像在翻书

setTimeout 的时机选择:

setTimeout(() => setCurrent(newIndex), 150);

延迟 150ms 正好是第一阶段动画结束时。此时:

  • 旧内容已经淡出(opacity = 0)
  • 更新内容不会被用户看到
  • 新内容随即淡入

3.2 上一个/下一个

const next = () => animateChange((current + 1) % twisters.length);
const prev = () => animateChange((current - 1 + twisters.length) % twisters.length);

循环索引的计算:

操作 公式 说明
下一个 (current + 1) % length 到末尾后回到开头
上一个 (current - 1 + length) % length 到开头后回到末尾

为什么上一个要 + length

// 假设 current = 0, length = 8
(0 - 1) % 8 = -1 % 8 = -1  // ❌ 负数索引无效

(0 - 1 + 8) % 8 = 7 % 8 = 7  // ✅ 正确回到最后一个

JavaScript 的取模运算对负数的处理与数学定义不同,需要先加上 length 确保结果为正。


四、UI 布局实现

4.1 头部区域

<View style={styles.header}>
  <Text style={styles.headerEmoji}>👅</Text>
  <Text style={styles.headerTitle}>绕口令</Text>
  <Text style={styles.headerSubtitle}>挑战你的口才</Text>
</View>

使用舌头 emoji 👅 作为图标,形象地表达"口才练习"的主题。

4.2 内容卡片

<Animated.View style={[styles.card, { 
  opacity: fadeAnim, 
  transform: [{ translateX: slideAnim }] 
}]}>
  <Text style={styles.title}>{twisters[current].title}</Text>
  <Text style={styles.content}>{twisters[current].content}</Text>
  <View style={styles.nav}>
    <TouchableOpacity style={styles.navBtn} onPress={prev}>
      <Text style={styles.navText}>← 上一个</Text>
    </TouchableOpacity>
    <Text style={styles.counter}>{current + 1}/{twisters.length}</Text>
    <TouchableOpacity style={styles.navBtn} onPress={next}>
      <Text style={styles.navText}>下一个 →</Text>
    </TouchableOpacity>
  </View>
</Animated.View>

卡片结构:

┌─────────────────────────────┐
│          四和十              │  ← 标题
│                             │
│   四是四,十是十,           │
│   十四是十四,四十是四十...   │  ← 内容
│                             │
├─────────────────────────────┤
│ ← 上一个    1/8    下一个 → │  ← 导航栏
└─────────────────────────────┘

导航栏的三栏布局:

nav: { 
  flexDirection: 'row', 
  justifyContent: 'space-between', 
  alignItems: 'center' 
}
  • flexDirection: 'row':横向排列
  • justifyContent: 'space-between':两端对齐,中间自动留白
  • 三个元素自然分布在左、中、右

4.3 展开/收起按钮

<TouchableOpacity style={styles.toggleBtn} onPress={() => setShowAll(!showAll)}>
  <Text style={styles.toggleText}>{showAll ? '📖 收起列表' : '📚 查看全部'}</Text>
</TouchableOpacity>

状态切换的文案设计:

状态 图标 文案 含义
收起 📚 查看全部 点击展开
展开 📖 收起列表 点击收起

图标的变化(📚 → 📖)暗示了"打开书本"的动作。

4.4 绕口令列表

{showAll && (
  <View style={styles.list}>
    {twisters.map((t, i) => (
      <TouchableOpacity 
        key={i} 
        style={[styles.listItem, current === i && styles.listItemActive]} 
        onPress={() => { animateChange(i); setShowAll(false); }}
      >
        <Text style={[styles.listTitle, current === i && styles.listTitleActive]}>
          {t.title}
        </Text>
      </TouchableOpacity>
    ))}
  </View>
)}

列表项的交互逻辑:

onPress={() => { 
  animateChange(i);  // 切换到选中项
  setShowAll(false); // 收起列表
}}

点击列表项后:

  1. 触发切换动画
  2. 自动收起列表

这种设计让用户快速定位后回到专注阅读模式。

当前项高亮:

style={[styles.listItem, current === i && styles.listItemActive]}

current === i 时,应用 listItemActive 样式,背景色变深,让用户知道当前在哪一条。

4.5 练习技巧提示

<View style={styles.tips}>
  <Text style={styles.tipsTitle}>💡 练习技巧</Text>
  <Text style={styles.tipsText}>• 先慢后快,逐渐加速</Text>
  <Text style={styles.tipsText}>• 注意发音准确,不要含糊</Text>
  <Text style={styles.tipsText}>• 多次重复,熟能生巧</Text>
</View>

为什么要加练习技巧?

绕口令工具不仅是"展示内容",更要"指导练习"。这三条技巧是播音训练的基本方法:

技巧 原理
先慢后快 建立正确的发音肌肉记忆
发音准确 避免形成错误习惯
多次重复 熟练度来自重复练习

五、样式系统详解

5.1 内容卡片样式

card: { 
  backgroundColor: '#1a1a3e', 
  padding: 24, 
  borderRadius: 20, 
  marginBottom: 16, 
  borderWidth: 1, 
  borderColor: '#e74c3c', 
  shadowColor: '#e74c3c', 
  shadowOffset: { width: 0, height: 8 }, 
  shadowOpacity: 0.3, 
  shadowRadius: 16, 
  elevation: 10 
},

红色主题设计:

属性 设计意图
borderColor #e74c3c 红色边框,热情活力
shadowColor #e74c3c 红色阴影,发光效果

红色传达"挑战"、"热情"的感觉,符合绕口令"挑战口才"的主题。

5.2 标题与内容样式

title: { 
  fontSize: 24, 
  fontWeight: '700', 
  color: '#e74c3c', 
  textAlign: 'center', 
  marginBottom: 20 
},
content: { 
  fontSize: 18, 
  lineHeight: 32, 
  color: '#fff', 
  textAlign: 'center' 
},

排版细节:

  • 标题用红色,与边框呼应
  • 内容用白色,保证可读性
  • lineHeight: 32(1.78 倍行高),绕口令需要逐字阅读,行距要大

5.3 列表项样式

listItem: { 
  padding: 16, 
  borderBottomWidth: 1, 
  borderBottomColor: '#3a3a6a' 
},
listItemActive: { 
  backgroundColor: '#252550' 
},
listTitle: { 
  fontSize: 16, 
  color: '#fff' 
},
listTitleActive: { 
  color: '#e74c3c', 
  fontWeight: '600' 
},

激活状态的双重反馈:

元素 默认状态 激活状态
背景 透明 #252550(深色)
文字 白色 红色 + 加粗

双重变化让当前选中项更加醒目。


六、完整代码

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

const twisters = [
  { title: '四和十', content: '四是四,十是十,十四是十四,四十是四十。谁能说准四十、十四、四十四,谁来试一试,谁说十四是四十,就打谁十四,谁说四十是十四,就打谁四十。' },
  { title: '吃葡萄', content: '吃葡萄不吐葡萄皮,不吃葡萄倒吐葡萄皮。' },
  { title: '黑化肥', content: '黑化肥发灰,灰化肥发黑。黑化肥发灰会挥发,灰化肥挥发会发黑。' },
  { title: '八百标兵', content: '八百标兵奔北坡,炮兵并排北边跑。炮兵怕把标兵碰,标兵怕碰炮兵炮。' },
  { title: '牛郎恋刘娘', content: '牛郎恋刘娘,刘娘念牛郎,牛郎年年恋刘娘,刘娘年年念牛郎,郎恋娘来娘念郎。' },
  { title: '红凤凰', content: '红凤凰,粉凤凰,红粉凤凰花凤凰。' },
  { title: '扁担长', content: '扁担长,板凳宽,扁担没有板凳宽,板凳没有扁担长。扁担绑在板凳上,板凳不让扁担绑在板凳上。' },
  { title: '司小四和史小世', content: '司小四和史小世,四月十四日十四时四十上集市,司小四买了四十四斤四两西红柿,史小世买了十四斤四两细蚕丝。' },
];

export const TongueTwister: React.FC = () => {
  const [current, setCurrent] = useState(0);
  const [showAll, setShowAll] = useState(false);
  const fadeAnim = useRef(new Animated.Value(1)).current;
  const slideAnim = useRef(new Animated.Value(0)).current;

  const animateChange = (newIndex: number) => {
    Animated.sequence([
      Animated.parallel([
        Animated.timing(fadeAnim, { toValue: 0, duration: 150, useNativeDriver: true }),
        Animated.timing(slideAnim, { toValue: -30, duration: 150, useNativeDriver: true }),
      ]),
      Animated.parallel([
        Animated.timing(fadeAnim, { toValue: 1, duration: 200, useNativeDriver: true }),
        Animated.spring(slideAnim, { toValue: 0, friction: 6, useNativeDriver: true }),
      ]),
    ]).start();
    setTimeout(() => setCurrent(newIndex), 150);
  };

  const next = () => animateChange((current + 1) % twisters.length);
  const prev = () => animateChange((current - 1 + twisters.length) % twisters.length);

  return (
    <ScrollView style={styles.container}>
      <View style={styles.header}>
        <Text style={styles.headerEmoji}>👅</Text>
        <Text style={styles.headerTitle}>绕口令</Text>
        <Text style={styles.headerSubtitle}>挑战你的口才</Text>
      </View>

      <Animated.View style={[styles.card, { opacity: fadeAnim, transform: [{ translateX: slideAnim }] }]}>
        <Text style={styles.title}>{twisters[current].title}</Text>
        <Text style={styles.content}>{twisters[current].content}</Text>
        <View style={styles.nav}>
          <TouchableOpacity style={styles.navBtn} onPress={prev}>
            <Text style={styles.navText}>← 上一个</Text>
          </TouchableOpacity>
          <Text style={styles.counter}>{current + 1}/{twisters.length}</Text>
          <TouchableOpacity style={styles.navBtn} onPress={next}>
            <Text style={styles.navText}>下一个 →</Text>
          </TouchableOpacity>
        </View>
      </Animated.View>

      <TouchableOpacity style={styles.toggleBtn} onPress={() => setShowAll(!showAll)}>
        <Text style={styles.toggleText}>{showAll ? '📖 收起列表' : '📚 查看全部'}</Text>
      </TouchableOpacity>

      {showAll && (
        <View style={styles.list}>
          {twisters.map((t, i) => (
            <TouchableOpacity key={i} style={[styles.listItem, current === i && styles.listItemActive]} onPress={() => { animateChange(i); setShowAll(false); }}>
              <Text style={[styles.listTitle, current === i && styles.listTitleActive]}>{t.title}</Text>
            </TouchableOpacity>
          ))}
        </View>
      )}

      <View style={styles.tips}>
        <Text style={styles.tipsTitle}>💡 练习技巧</Text>
        <Text style={styles.tipsText}>• 先慢后快,逐渐加速</Text>
        <Text style={styles.tipsText}>• 注意发音准确,不要含糊</Text>
        <Text style={styles.tipsText}>• 多次重复,熟能生巧</Text>
      </View>
    </ScrollView>
  );
};

const styles = StyleSheet.create({
  container: { flex: 1, backgroundColor: '#0f0f23', padding: 16 },
  header: { alignItems: 'center', marginBottom: 24 },
  headerEmoji: { fontSize: 50, marginBottom: 8 },
  headerTitle: { fontSize: 28, fontWeight: '700', color: '#fff' },
  headerSubtitle: { fontSize: 14, color: '#888', marginTop: 4 },
  card: { backgroundColor: '#1a1a3e', padding: 24, borderRadius: 20, marginBottom: 16, borderWidth: 1, borderColor: '#e74c3c', shadowColor: '#e74c3c', shadowOffset: { width: 0, height: 8 }, shadowOpacity: 0.3, shadowRadius: 16, elevation: 10 },
  title: { fontSize: 24, fontWeight: '700', color: '#e74c3c', textAlign: 'center', marginBottom: 20 },
  content: { fontSize: 18, lineHeight: 32, color: '#fff', textAlign: 'center' },
  nav: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginTop: 24, paddingTop: 16, borderTopWidth: 1, borderTopColor: '#3a3a6a' },
  navBtn: { padding: 8 },
  navText: { color: '#4A90D9', fontSize: 14 },
  counter: { color: '#888' },
  toggleBtn: { alignItems: 'center', marginBottom: 16, padding: 12 },
  toggleText: { color: '#4A90D9', fontSize: 16 },
  list: { backgroundColor: '#1a1a3e', borderRadius: 16, marginBottom: 16, borderWidth: 1, borderColor: '#3a3a6a' },
  listItem: { padding: 16, borderBottomWidth: 1, borderBottomColor: '#3a3a6a' },
  listItemActive: { backgroundColor: '#252550' },
  listTitle: { fontSize: 16, color: '#fff' },
  listTitleActive: { color: '#e74c3c', fontWeight: '600' },
  tips: { backgroundColor: '#1a1a3e', padding: 16, borderRadius: 16, borderWidth: 1, borderColor: '#3a3a6a' },
  tipsTitle: { fontSize: 16, fontWeight: '600', marginBottom: 12, color: '#fff' },
  tipsText: { fontSize: 14, color: '#888', marginBottom: 6 },
});

七、技术要点回顾

技术点 实现方式
循环索引 % length 取模运算
滑动动画 translateX + opacity 组合
展开收起 boolean 状态 + 条件渲染
列表高亮 条件样式 current === i &&
内容切换 setTimeout 配合动画时序

这个绕口令工具展示了如何实现一个带有"翻页"效果的内容浏览器,以及如何通过列表快速定位内容。


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

Logo

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

更多推荐