RN for OpenHarmony 小工具 App 实战:记事本实现

这一篇我们实现一个「记事本」小工具:支持新增/编辑/删除笔记,并且在交互上加一点“轻量但有效”的动效,让它看起来更像一个真正可用的小工具,而不是纯 CRUD。
本篇所有代码都来自仓库真实文件:
src/pages/Notepad.tsxsrc/tools/index.tssrc/screens/ToolScreen.tsx
最终效果与能力清单
- 新增笔记:输入标题/内容,点击保存创建新笔记
- 编辑笔记:点击列表某一条进入编辑态,再点击更新
- 删除笔记:删除时先播放消失动画,再真正从数组移除
- 动效细节:
- 保存按钮缩放反馈
- 编辑器整体轻微缩放反馈
- 新笔记入场(缩放 + 横向位移 + 渐显)
- 删除笔记退场(缩放到 0 + 渐隐)
说明:当前实现是“内存版”(
useState存数组),App 重启后数据会清空。本篇末尾会给出持久化扩展思路。
这个工具在项目里是怎么挂载的?
这个小工具并不是一个独立 App,而是工具箱里的一项。
1) 工具列表配置
文件:src/tools/index.ts
export interface Tool {
id: number;
name: string;
description: string;
icon: string;
component: string;
}
export const toolsList2: Tool[] = [
// ...
{ id: 44, name: '记事本', description: '简单文本记事', icon: '📒', component: 'Notepad' },
// ...
];
这里的核心是 component: 'Notepad',它会在工具页里被映射到真正的 React 组件。
2) 工具页的 componentMap 映射
文件:src/screens/ToolScreen.tsx
import * as Pages from '../pages';
const componentMap: { [key: string]: React.FC } = {
// ...
Notepad: Pages.Notepad,
// ...
};
export const ToolScreen: React.FC<Props> = ({ tool, onBack }) => {
const Component = componentMap[tool.component];
return (
<SafeAreaView style={styles.container}>
<Header title={tool.name} showBack onBack={onBack} />
<View style={styles.content}>
{Component ? <Component /> : null}
</View>
</SafeAreaView>
);
};
这也是我比较推荐的“工具箱类 App”组织方式:
- 工具只是配置(id/name/icon/component)
- 页面是实现(
Pages.Notepad) - 工具页只负责拿配置渲染具体页面
核心数据模型:一条笔记要保存什么?
文件:src/pages/Notepad.tsx
interface Note {
id: number;
title: string;
content: string;
time: string;
anim: Animated.Value;
}
这里的设计点在于 anim: Animated.Value:
- 每条笔记都有自己的动画值,因此新增/删除只影响当前 item
- 如果用一个全局动画值,会导致列表里所有 item 一起动,体验很怪
状态拆分:列表态 vs 编辑态
同一个页面里既要“编辑区”,又要“列表区”,所以建议把状态分成两层:
- 笔记集合:
notes - 当前编辑的笔记:
currentNote(有值表示编辑态;为null表示新建态) - 输入框内容:
title/content
对应代码如下:
export const Notepad: React.FC = () => {
const [notes, setNotes] = useState<Note[]>([]);
const [currentNote, setCurrentNote] = useState<Note | null>(null);
const [title, setTitle] = useState('');
const [content, setContent] = useState('');
const saveAnim = useRef(new Animated.Value(1)).current;
const editorAnim = useRef(new Animated.Value(1)).current;
// ...
};
为什么 currentNote 不直接复用 title/content?
因为 currentNote 是“正在编辑的对象标识”,它用来决定:
- 保存按钮文案显示
保存还是更新 - 保存时走“新增”还是“更新”逻辑
- 删除时如果删的是正在编辑的那条,需要把编辑区清空
而 title/content 是“输入框即时值”,两者职责不同。
保存逻辑:一次函数同时覆盖新增与更新
文件:src/pages/Notepad.tsx 中的 saveNote()
const saveNote = () => {
if (!content.trim()) return;
Animated.sequence([
Animated.timing(saveAnim, { toValue: 0.9, duration: 100, useNativeDriver: true }),
Animated.spring(saveAnim, { toValue: 1, friction: 3, useNativeDriver: true }),
]).start();
const now = new Date().toLocaleString('zh-CN');
if (currentNote) {
setNotes(notes.map(n => n.id === currentNote.id ? { ...n, title: title || '无标题', content, time: now } : n));
} else {
const newNote: Note = { id: Date.now(), title: title || '无标题', content, time: now, anim: new Animated.Value(0) };
setNotes([newNote, ...notes]);
setTimeout(() => {
Animated.spring(newNote.anim, { toValue: 1, friction: 5, useNativeDriver: true }).start();
}, 50);
}
setCurrentNote(null);
setTitle('');
setContent('');
};
这一段有几个值得写出来的点:
1) 只校验 content,允许“空标题”
if (!content.trim()) return; 说明我们把“内容”当做必要输入;标题为空时默认 无标题。
2) 更新逻辑用 map(),新增逻辑直接把新对象 unshift
- 更新:
notes.map(...)找到同 id 的 item 做不可变更新 - 新增:
setNotes([newNote, ...notes])让新笔记出现在最上面
3) 新增笔记的入场动画为什么要 setTimeout(..., 50)?
因为 newNote.anim 初始是 0,但它只有在 React 完成一次渲染后才真正对应到屏幕上的那个 item。
稍微延迟一下再 spring 到 1,可以确保“先渲染后开动”,动画会稳定触发。
进入编辑态:点击列表项回填输入框
const editNote = (note: Note) => {
Animated.sequence([
Animated.timing(editorAnim, { toValue: 0.98, duration: 100, useNativeDriver: true }),
Animated.spring(editorAnim, { toValue: 1, friction: 4, useNativeDriver: true }),
]).start();
setCurrentNote(note);
setTitle(note.title);
setContent(note.content);
};
编辑态的本质是两步:
- 标记:
setCurrentNote(note) - 回填:把
title/content写回输入框
这里额外加了一个 editorAnim 的缩放序列,让用户明确感知“我进入了编辑状态”。
删除逻辑:先动画,再移除数据
const deleteNote = (id: number) => {
const note = notes.find(n => n.id === id);
if (note) {
Animated.timing(note.anim, { toValue: 0, duration: 200, useNativeDriver: true }).start(() => {
setNotes(notes.filter(n => n.id !== id));
if (currentNote?.id === id) {
setCurrentNote(null);
setTitle('');
setContent('');
}
});
}
};
这里的关键点是:在动画回调里再 filter 掉该条数据。
- 如果你直接
setNotes(filter...),UI 会立刻少一项,看起来就是“瞬移消失” - 现在的做法是:先把该条
anim动到 0(缩放到 0,透明到 0),动画结束后再真正移除
同时还处理了一个用户体验边界:
如果删除的是正在编辑的那条笔记,需要把编辑器清空并退出编辑态。
UI 渲染结构:编辑器 + 列表
1) 编辑器区域
<Animated.View style={[styles.editor, { transform: [{ scale: editorAnim }] }]}>
<TextInput
style={styles.titleInput}
value={title}
onChangeText={setTitle}
placeholder="标题"
placeholderTextColor="#666"
/>
<TextInput
style={styles.contentInput}
value={content}
onChangeText={setContent}
placeholder="开始记录..."
placeholderTextColor="#666"
multiline
/>
<View style={styles.btnRow}>
<Animated.View style={{ flex: 1, transform: [{ scale: saveAnim }] }}>
<TouchableOpacity style={styles.btn} onPress={saveNote} activeOpacity={0.8}>
<Text style={styles.btnText}>{currentNote ? '💾 更新' : '💾 保存'}</Text>
</TouchableOpacity>
</Animated.View>
{currentNote && (
<TouchableOpacity style={[styles.btn, styles.btnSecondary]} onPress={newNote} activeOpacity={0.8}>
<Text style={styles.btnText}>📄 新建</Text>
</TouchableOpacity>
)}
</View>
</Animated.View>
你会看到这里有两个交互细节:
- 保存按钮动效:
saveAnim只包住按钮区域 - 新建按钮只在编辑态出现:
{currentNote && (...)}
这能明显减少“新建/更新”混淆:
- 新建态:只有保存(保存即新增)
- 编辑态:更新 + 新建(新建用于快速清空编辑区)
2) 列表区域(入场:缩放 + 位移 + 渐显)
{notes.map(note => (
<Animated.View
key={note.id}
style={{
transform: [
{ scale: note.anim },
{
translateX: note.anim.interpolate({
inputRange: [0, 1],
outputRange: [-50, 0],
}),
},
],
opacity: note.anim,
}}
>
<TouchableOpacity
style={[styles.noteItem, currentNote?.id === note.id && styles.noteItemActive]}
onPress={() => editNote(note)}
activeOpacity={0.7}
>
<View style={styles.noteContent}>
<Text style={styles.noteTitle} numberOfLines={1}>{note.title}</Text>
<Text style={styles.notePreview} numberOfLines={2}>{note.content}</Text>
<Text style={styles.noteTime}>{note.time}</Text>
</View>
<TouchableOpacity style={styles.deleteBtn} onPress={() => deleteNote(note.id)}>
<Text style={styles.deleteText}>×</Text>
</TouchableOpacity>
</TouchableOpacity>
</Animated.View>
))}
这里 translateX 的 interpolate 让新增 item 不是“原地变大”,而是带一点“从左滑入”的感觉。
同时对当前编辑的 item 做了高亮边框:
noteItemActive: { borderColor: '#4A90D9' },
在列表很长时,这个高亮能帮助用户快速定位自己正在编辑哪条。
UI/交互小结:哪些设计是“刻意的”?
- 按钮文案有差异:
保存vs更新,避免用户误以为“保存会覆盖别的” - 新建按钮只在编辑态出现:减少界面噪音
- 删除要处理编辑态冲突:否则会出现“编辑一条不存在的数据”的怪状态
- 每条笔记独立动效值:列表项动效可控、互不影响
扩展方向:把“内存版记事本”升级为“可持久化”
当前 notes 完全存在组件内存里,退出后会清空。如果你要升级为可持久化,一般有两类方式:
- 轻量 KV:适合小量数据、结构简单(标题/内容/时间),读写成本低
- 数据库:适合后期要加搜索/标签/排序/多字段索引等
在现有结构上,建议你先做一个抽象:
- 把
notes的读写封装成loadNotes()/saveNotes(nextNotes) - 页面初始化
useEffect里loadNotes() - 每次新增/更新/删除后调用
saveNotes()
注意:当前
Note里包含anim: Animated.Value,它是不能直接序列化的。
更合理的做法是:
- 持久化时只存“纯数据字段”(
id/title/content/time) - 读取后再把每条数据映射成带
anim的 UI 模型(例如新建new Animated.Value(1))
小结
这一篇的记事本实现,核心不在于 UI 多复杂,而在于:
- 用
currentNote清晰地区分“新建/编辑”两种状态 - 对列表项做独立动画值,新增/删除更顺滑
- 在删除与编辑交叉时,主动处理边界状态
如果需要引入持久化存储,建议将 anim 与可序列化的笔记数据字段分离,避免将动画状态写入存储层。
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐
所有评论(0)