RN for OpenHarmony 小工具 App 实战:绕口令练习实现

前言
“吃葡萄不吐葡萄皮,不吃葡萄倒吐葡萄皮。”
绕口令是中国传统的语言游戏,通过快速朗读相似发音的词句来锻炼口齿清晰度。它不仅是儿童语言启蒙的好工具,也是播音员、主持人的必备训练项目。
本文将实现一个「绕口令练习」小工具,包含以下功能:
| 功能 | 描述 |
|---|---|
| 📜 内容展示 | 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); // 收起列表
}}
点击列表项后:
- 触发切换动画
- 自动收起列表
这种设计让用户快速定位后回到专注阅读模式。
当前项高亮:
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
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐


所有评论(0)