在这里插入图片描述

这一篇我们实现一个「记事本」小工具:支持新增/编辑/删除笔记,并且在交互上加一点“轻量但有效”的动效,让它看起来更像一个真正可用的小工具,而不是纯 CRUD。

本篇所有代码都来自仓库真实文件:

  • src/pages/Notepad.tsx
  • src/tools/index.ts
  • src/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>
))}

这里 translateXinterpolate 让新增 item 不是“原地变大”,而是带一点“从左滑入”的感觉。

同时对当前编辑的 item 做了高亮边框:

noteItemActive: { borderColor: '#4A90D9' },

在列表很长时,这个高亮能帮助用户快速定位自己正在编辑哪条。

UI/交互小结:哪些设计是“刻意的”?

  • 按钮文案有差异保存 vs 更新,避免用户误以为“保存会覆盖别的”
  • 新建按钮只在编辑态出现:减少界面噪音
  • 删除要处理编辑态冲突:否则会出现“编辑一条不存在的数据”的怪状态
  • 每条笔记独立动效值:列表项动效可控、互不影响

扩展方向:把“内存版记事本”升级为“可持久化”

当前 notes 完全存在组件内存里,退出后会清空。如果你要升级为可持久化,一般有两类方式:

  • 轻量 KV:适合小量数据、结构简单(标题/内容/时间),读写成本低
  • 数据库:适合后期要加搜索/标签/排序/多字段索引等

在现有结构上,建议你先做一个抽象:

  1. notes 的读写封装成 loadNotes() / saveNotes(nextNotes)
  2. 页面初始化 useEffectloadNotes()
  3. 每次新增/更新/删除后调用 saveNotes()

注意:当前 Note 里包含 anim: Animated.Value,它是不能直接序列化的。

更合理的做法是:

  • 持久化时只存“纯数据字段”(id/title/content/time
  • 读取后再把每条数据映射成带 anim 的 UI 模型(例如新建 new Animated.Value(1)

小结

这一篇的记事本实现,核心不在于 UI 多复杂,而在于:

  • currentNote 清晰地区分“新建/编辑”两种状态
  • 对列表项做独立动画值,新增/删除更顺滑
  • 在删除与编辑交叉时,主动处理边界状态

如果需要引入持久化存储,建议将 anim 与可序列化的笔记数据字段分离,避免将动画状态写入存储层。


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

Logo

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

更多推荐