Flutter 实战:notes_app 便签应用的底部编辑器、新增编辑删除与鸿蒙适配解析
Flutter 实战:notes_app 便签应用的底部编辑器、新增编辑删除与鸿蒙适配解析
前言
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
notes_app 是一个基于 Flutter 实现的轻量便签应用。它支持新建便签、编辑便签、删除便签、空状态展示、彩色卡片列表和日期格式化。应用没有引入数据库或第三方状态管理库,而是用 StatefulWidget、TextEditingController、showModalBottomSheet 和 ListView.builder 完成了一个完整的本地便签交互闭环。
本文基于项目真实源码展开,重点分析 便签数据结构、底部编辑器实现、新增与编辑复用逻辑、列表卡片渲染、删除与空状态、日期格式化 和 鸿蒙适配关注点。文章内容可直接发布到 CSDN,不包含面向作者的检查说明。
便签应用的价值不在于代码复杂,而在于它覆盖了移动应用最常见的基础链路:输入、弹窗、保存、编辑、删除、列表、空状态和软键盘适配。
notes_app很适合作为 Flutter 表单类应用的入门样例。

图示说明:本文围绕 Flutter 便签应用的底部编辑器、列表卡片、状态更新和跨端适配展开,适合用于鸿蒙、Android、iOS 等多端应用开发复盘。
一、项目定位与功能概览
1.1 应用主题
notes_app 的定位是一个 本地便签记录工具。用户可以点击右下角加号打开底部编辑器,输入标题和内容后保存;点击已有便签卡片可以再次打开编辑器修改;点击删除按钮可以移除便签。
核心功能如下:
| 功能 | 页面表现 | 源码实现 |
|---|---|---|
| 空状态 | No notes yet |
_notes.isEmpty 分支 |
| 新建便签 | 右下角加号按钮 | _showNoteDialog() |
| 编辑便签 | 点击卡片打开编辑器 | _showNoteDialog(index: index) |
| 保存便签 | 底部弹窗勾选按钮 | _saveNote() |
| 删除便签 | 卡片右侧删除图标 | _deleteNote() |
| 日期展示 | 卡片底部时间文本 | _formatDate() |
| 彩色卡片 | 轮换浅色背景 | _getNoteColor() |
1.2 当前实现边界
源码中所有便签都保存在内存列表里:
| 能力 | 当前是否实现 | 说明 |
|---|---|---|
| 新建便签 | 是 | 标题必填,内容可为空 |
| 编辑便签 | 是 | 点击卡片进入编辑模式 |
| 删除便签 | 是 | 点击删除图标立即删除 |
| 搜索便签 | 否 | AppBar 有搜索入口,但未实现搜索逻辑 |
| 本地持久化 | 否 | 应用重启后便签不会保留 |
| 云同步 | 否 | 没有网络或账号体系 |
1.3 适合学习的点
这个项目适合学习以下 Flutter 实战能力:
- 如何用
List<Map<String, dynamic>>管理轻量业务数据。 - 如何用
showModalBottomSheet实现底部编辑器。 - 如何复用同一个弹窗完成新建和编辑。
- 如何用
ListView.builder渲染动态列表。 - 如何处理空状态和列表状态切换。
- 如何面向鸿蒙验证软键盘、滚动和弹窗适配。
二、工程结构与运行方式
2.1 工程结构
项目保持标准 Flutter 工程结构,核心逻辑集中在 lib/main.dart:
| 文件或目录 | 作用 | 说明 |
|---|---|---|
lib/main.dart |
应用入口与页面实现 | 包含便签数据、弹窗、列表和删除逻辑 |
pubspec.yaml |
依赖声明 | 使用 Flutter SDK 与 Material 图标 |
test/widget_test.dart |
Widget 测试入口 | 可扩展为便签业务测试 |
ohos/ |
鸿蒙平台工程目录 | 用于跨端构建和适配 |
2.2 依赖声明
项目没有引入复杂第三方依赖:
dependencies:
flutter:
sdk: flutter
cupertino_icons: ^1.0.8
这说明当前所有能力都由 Flutter 标准组件和 Dart 状态逻辑完成,没有数据库、文件读写、网络接口或平台通道。
2.3 常用命令
开发和验证时可以使用以下命令:
flutter pub get
flutter analyze
flutter test
flutter run
| 命令 | 作用 | 使用场景 |
|---|---|---|
flutter pub get |
获取依赖 | 首次运行或依赖变化 |
flutter analyze |
静态分析 | 检查语法和 lint |
flutter test |
执行测试 | 验证 Widget 行为 |
flutter run |
启动应用 | 本地调试界面 |
三、应用入口与主题配置
3.1 main 函数
应用入口保持 Flutter 标准写法:
void main() {
runApp(const MyApp());
}
便签应用不需要启动时初始化数据库或网络服务,因此入口非常简洁。
3.2 MyApp 根组件
根组件负责创建 MaterialApp:
class MyApp extends StatelessWidget {
const MyApp({super.key});
Widget build(BuildContext context) {
return MaterialApp(
title: 'Notes',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal),
),
home: const MyHomePage(title: 'Notes'),
);
}
}
这里有三个关键信息:
- 应用标题是
Notes。 - 主题种子色是
Colors.teal。 - 首页是
MyHomePage。
3.3 主题色选择
青绿色适合笔记、清单、效率工具类应用。源码中右下角新增按钮使用 Colors.teal,和应用主题保持一致。
当前源码没有显式设置
useMaterial3: true,因此文章以真实代码为准。如果后续需要统一 Material 3 表现,可以在ThemeData中补充该配置。
四、状态字段设计
4.1 核心状态总览
便签页面核心状态如下:
final List<Map<String, dynamic>> _notes = [];
final TextEditingController _titleController = TextEditingController();
final TextEditingController _contentController = TextEditingController();
bool _isEditing = false;
int _editingIndex = -1;
字段含义如下:
| 字段 | 类型 | 初始值 | 作用 |
|---|---|---|---|
_notes |
List<Map<String, dynamic>> |
空列表 | 保存便签数据 |
_titleController |
TextEditingController |
空 | 管理标题输入 |
_contentController |
TextEditingController |
空 | 管理内容输入 |
_isEditing |
bool |
false |
标记是否编辑模式 |
_editingIndex |
int |
-1 |
记录当前编辑下标 |
4.2 便签数据结构
新增便签时插入的数据结构如下:
{
'title': _titleController.text,
'content': _contentController.text,
'date': DateTime.now(),
'color': Colors.primaries[_notes.length % Colors.primaries.length],
}
当前列表渲染主要使用 title、content 和 date。color 字段已经写入数据,但页面背景色实际来自 _getNoteColor(index)。
4.3 状态分层
| 状态类别 | 字段 | 说明 |
|---|---|---|
| 数据状态 | _notes |
保存全部便签 |
| 输入状态 | _titleController、_contentController |
保存弹窗中的表单文本 |
| 编辑状态 | _isEditing、_editingIndex |
区分新建或编辑 |
4.4 为什么适合 StatefulWidget
这个项目是单页应用,数据量小,交互也集中在一个页面内。使用 StatefulWidget 直接管理状态,阅读成本低,也方便初学者理解状态刷新。
五、Controller 生命周期管理
5.1 两个输入控制器
页面使用两个控制器管理输入:
final TextEditingController _titleController = TextEditingController();
final TextEditingController _contentController = TextEditingController();
标题和内容分开管理,能让弹窗在新建和编辑时复用同一套输入组件。
5.2 dispose 释放资源
页面销毁时释放控制器:
void dispose() {
_titleController.dispose();
_contentController.dispose();
super.dispose();
}
这是 Flutter 表单页面中必须注意的资源管理点。
5.3 生命周期表
| 阶段 | 行为 | 对应代码 |
|---|---|---|
| 页面创建 | 创建控制器 | 字段初始化 |
| 新建便签 | 清空控制器 | _showNoteDialog() |
| 编辑便签 | 写入原内容 | _showNoteDialog(index: index) |
| 保存便签 | 读取控制器文本 | _saveNote() |
| 页面销毁 | 释放控制器 | dispose() |
六、底部编辑器实现
6.1 showNoteDialog 入口
新建和编辑都通过 _showNoteDialog():
void _showNoteDialog({int? index}) {
if (index != null) {
_titleController.text = _notes[index]['title'];
_contentController.text = _notes[index]['content'];
_isEditing = true;
_editingIndex = index;
} else {
_titleController.clear();
_contentController.clear();
_isEditing = false;
_editingIndex = -1;
}
showModalBottomSheet(...);
}
6.2 新建与编辑的区分
判断依据是 index 是否为空:
| index | 模式 | 行为 |
|---|---|---|
null |
新建 | 清空输入框 |
| 非空 | 编辑 | 填入已有标题和内容 |
6.3 底部弹窗参数
弹窗使用:
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) {
return Container(...);
},
)
isScrollControlled: true 很重要,它允许弹窗高度更灵活,也更适合多行文本编辑。
6.4 弹窗高度
弹窗高度是屏幕高度的 80%:
height: MediaQuery.of(context).size.height * 0.8
这让用户有足够空间输入便签内容,同时仍然保留底部弹窗的交互语义。
七、编辑器头部设计
7.1 标题切换
弹窗头部根据模式显示不同标题:
Text(
_isEditing ? 'Edit Note' : 'New Note',
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
)
7.2 保存按钮
保存按钮是勾选图标:
IconButton(
icon: const Icon(Icons.check),
onPressed: () {
if (_titleController.text.isNotEmpty) {
_saveNote(index: index);
Navigator.pop(context);
}
},
)
标题为空时不会保存。
7.3 关闭按钮
关闭按钮直接退出弹窗:
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(context),
)
7.4 头部布局价值
头部左侧显示当前动作,右侧提供保存和关闭两个操作,符合移动端编辑器的常见设计。
八、标题与内容输入
8.1 标题输入框
标题输入框使用较大的字体和加粗样式:
TextField(
controller: _titleController,
decoration: InputDecoration(
labelText: 'Title',
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
),
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
)
8.2 内容输入框
内容输入框支持多行:
TextField(
controller: _contentController,
decoration: InputDecoration(
labelText: 'Content',
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
alignLabelWithHint: true,
),
maxLines: 10,
minLines: 5,
)
8.3 滚动容器
输入区包裹在 SingleChildScrollView 中:
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(...),
),
)
这能降低软键盘弹出后内容被遮挡的风险。
8.4 输入校验策略
当前只要求标题非空,内容可以为空。这种策略符合便签工具常见用法:有时用户只想快速记一个标题。
九、保存便签逻辑
9.1 saveNote 方法
保存逻辑如下:
void _saveNote({int? index}) {
if (index != null) {
setState(() {
_notes[index]['title'] = _titleController.text;
_notes[index]['content'] = _contentController.text;
_notes[index]['date'] = DateTime.now();
});
} else {
setState(() {
_notes.insert(0, {
'title': _titleController.text,
'content': _contentController.text,
'date': DateTime.now(),
'color': Colors.primaries[_notes.length % Colors.primaries.length],
});
});
}
}
9.2 编辑模式
编辑时通过原索引直接写回:
_notes[index]['title'] = _titleController.text;
_notes[index]['content'] = _contentController.text;
_notes[index]['date'] = DateTime.now();
这表示编辑后更新时间也会刷新。
9.3 新建模式
新建时使用 insert(0, ...):
_notes.insert(0, {...});
新便签会插入列表头部,用户保存后能第一时间看到。
9.4 保存流程
点击保存
-> 判断标题非空
-> 调用 _saveNote
-> 新建或编辑
-> setState 刷新页面
-> 关闭底部弹窗
十、删除便签逻辑
10.1 deleteNote 方法
删除便签的实现非常直接:
void _deleteNote(int index) {
setState(() {
_notes.removeAt(index);
});
}
10.2 删除入口
列表卡片右上角有删除按钮:
IconButton(
icon: const Icon(Icons.delete_outline, color: Colors.red),
onPressed: () => _deleteNote(index),
)
10.3 删除后的状态变化
删除后列表会重新渲染。如果删除的是最后一条便签,页面会切回空状态。
10.4 删除交互边界
当前删除是即时生效,没有确认弹窗或撤销。对于演示项目足够简单;如果做正式便签产品,建议增加撤销或二次确认。
十一、空状态设计
11.1 空列表分支
页面根据 _notes.isEmpty 切换空状态和列表:
body: _notes.isEmpty
? Center(...)
: ListView.builder(...)
11.2 空状态内容
空状态展示图标和提示文案:
Icon(Icons.note_add, size: 80, color: Colors.grey.shade400)
Text(
'No notes yet',
style: TextStyle(fontSize: 24, color: Colors.grey.shade600),
)
Text(
'Tap + to create a note',
style: TextStyle(fontSize: 16, color: Colors.grey.shade500),
)
11.3 空状态的价值
空状态可以明确告诉用户当前没有内容,并引导用户点击加号创建便签。
11.4 状态切换
当新建第一条便签后,页面会从空状态切换为 ListView.builder 列表。
十二、列表卡片渲染
12.1 ListView.builder
便签列表使用构建器:
ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: _notes.length,
itemBuilder: (context, index) {
final note = _notes[index];
return Card(...);
},
)
ListView.builder 适合动态列表,即使便签数量变多,也能按需构建。
12.2 卡片点击编辑
每张卡片使用 InkWell 包裹:
InkWell(
onTap: () => _showNoteDialog(index: index),
borderRadius: BorderRadius.circular(12),
child: Padding(...),
)
点击卡片即可进入编辑模式。
12.3 标题展示
标题最多展示一行,溢出用省略号:
Text(
note['title'],
maxLines: 1,
overflow: TextOverflow.ellipsis,
)
12.4 内容摘要
内容最多展示三行:
Text(
note['content'],
maxLines: 3,
overflow: TextOverflow.ellipsis,
)
这让列表保持整洁,详细内容放到编辑弹窗中查看。
十三、卡片颜色与日期格式化
13.1 getNoteColor 方法
卡片背景色根据下标轮换:
Color _getNoteColor(int index) {
final colors = [
Colors.blue.shade50,
Colors.green.shade50,
Colors.orange.shade50,
Colors.purple.shade50,
Colors.red.shade50,
Colors.teal.shade50,
];
return colors[index % colors.length];
}
13.2 颜色轮换表
| index | 背景色 |
|---|---|
| 0 | 蓝色浅色 |
| 1 | 绿色浅色 |
| 2 | 橙色浅色 |
| 3 | 紫色浅色 |
| 4 | 红色浅色 |
| 5 | 青绿色浅色 |
13.3 formatDate 方法
日期格式化方法如下:
String _formatDate(DateTime date) {
return '${date.day}/${date.month}/${date.year} ${date.hour}:${date.minute.toString().padLeft(2, '0')}';
}
13.4 日期展示特点
当前格式显示日、月、年、小时和分钟。分钟使用 padLeft(2, '0') 保证两位展示,例如 9:05。
十四、AppBar 与搜索入口
14.1 AppBar 结构
顶部栏包含标题和搜索按钮:
appBar: AppBar(
title: Text(widget.title),
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
actions: [
IconButton(
icon: const Icon(Icons.search),
onPressed: () {
// Search functionality could be implemented here
},
),
],
)
14.2 搜索入口边界
当前搜索按钮只是预留入口,没有实现搜索逻辑。这一点在技术文章里必须说清楚,不能把它描述为已经支持搜索。
14.3 可扩展搜索状态
后续可以增加:
String _query = '';
然后根据标题和内容过滤 _notes。
14.4 搜索体验建议
搜索可以做成 AppBar 内联输入框,也可以做成弹窗或独立搜索页。便签数量少时内联搜索足够,数量多时可以增加筛选和排序。
十五、鸿蒙适配关注点
15.1 为什么适配风险较低
notes_app 主要由 Flutter 标准组件和 Dart 状态逻辑构成,不依赖数据库、文件系统、网络、相机或平台通道,因此基础适配风险较低。
| 模块 | 是否依赖平台能力 | 适配关注度 |
|---|---|---|
| 底部弹窗 | Flutter 标准组件 | 中 |
| 文本输入 | Flutter 标准输入 | 中 |
| 列表展示 | Flutter 标准组件 | 低 |
| 删除逻辑 | Dart 状态更新 | 低 |
| 数据持久化 | 当前未实现 | 高 |
15.2 软键盘与底部弹窗
鸿蒙设备上需要重点验证:
- 底部弹窗高度是否合适。
- 软键盘弹出后标题和内容输入框是否可见。
- 多行内容输入是否能正常滚动。
- 关闭弹窗后页面状态是否正常。
15.3 列表滚动与卡片点击
列表使用 ListView.builder,适合便签数量增加的场景。适配时重点看卡片点击、删除按钮触摸区域和长内容省略效果。
15.4 数据边界
当前便签只保存在内存中,应用重启后会丢失。若要作为正式便签应用,需要增加本地持久化,例如 SQLite、文件存储或键值存储。
当前项目适合作为 Flutter 便签交互样例,不应理解为具备长期数据保存能力的完整便签产品。
十六、测试设计与默认测试改造
16.1 当前测试入口
项目中的测试文件仍是默认计数器测试。对于便签应用,更有价值的是验证空状态、新建、编辑、删除和弹窗交互。
16.2 初始空状态测试
testWidgets('notes app renders empty state', (WidgetTester tester) async {
await tester.pumpWidget(const MyApp());
expect(find.text('Notes'), findsWidgets);
expect(find.text('No notes yet'), findsOneWidget);
expect(find.text('Tap + to create a note'), findsOneWidget);
});
16.3 打开新建弹窗测试
testWidgets('can open new note sheet', (WidgetTester tester) async {
await tester.pumpWidget(const MyApp());
await tester.tap(find.byIcon(Icons.add));
await tester.pumpAndSettle();
expect(find.text('New Note'), findsOneWidget);
expect(find.text('Title'), findsOneWidget);
expect(find.text('Content'), findsOneWidget);
});
16.4 新建便签测试
testWidgets('can create a note', (WidgetTester tester) async {
await tester.pumpWidget(const MyApp());
await tester.tap(find.byIcon(Icons.add));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField).first, 'Shopping');
await tester.enterText(find.byType(TextField).last, 'Buy milk');
await tester.tap(find.byIcon(Icons.check));
await tester.pumpAndSettle();
expect(find.text('Shopping'), findsOneWidget);
expect(find.text('Buy milk'), findsOneWidget);
});
16.5 删除便签测试
testWidgets('can delete a note', (WidgetTester tester) async {
await tester.pumpWidget(const MyApp());
await tester.tap(find.byIcon(Icons.add));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField).first, 'Temp');
await tester.tap(find.byIcon(Icons.check));
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.delete_outline));
await tester.pump();
expect(find.text('No notes yet'), findsOneWidget);
});
十七、可维护性优化方向
17.1 抽离 Note 模型
当前使用 Map<String, dynamic>,简单但类型不够明确。可以抽成模型:
class Note {
const Note({
required this.title,
required this.content,
required this.date,
});
final String title;
final String content;
final DateTime date;
}
17.2 增加本地持久化
便签应用最自然的下一步是持久化:
| 方案 | 适合场景 |
|---|---|
| SharedPreferences | 少量简单文本 |
| SQLite | 结构化便签数据 |
| 文件存储 | Markdown 或纯文本便签 |
| 云同步 | 多设备同步 |
17.3 增强编辑状态
当前 _isEditing 和 _editingIndex 主要用于弹窗标题和编辑定位。后续可以把编辑状态封装为对象,减少散落状态。
17.4 增加撤销删除
删除便签后可以通过 SnackBar 提供撤销,避免误删:
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Note deleted')),
);
十八、常见问题与优化建议
18.1 为什么标题不能为空
标题是列表识别便签的主要信息。源码在保存按钮里判断 _titleController.text.isNotEmpty,标题为空时不会保存。
18.2 为什么新便签插入到最前面
新便签通常最需要立即查看,所以源码使用 _notes.insert(0, ...),让最新内容显示在列表顶部。
18.3 为什么点击卡片可以编辑
卡片使用 InkWell 包裹,点击时传入当前 index 打开底部编辑器。这样列表和编辑器可以复用同一套表单。
18.4 为什么搜索按钮没有效果
源码中搜索按钮只是预留入口,当前没有实现搜索逻辑。如果要支持搜索,需要增加查询状态和过滤列表。
18.5 鸿蒙适配最应该关注什么
重点关注底部弹窗、软键盘遮挡、多行输入、卡片点击、删除按钮触摸反馈和列表滚动。当前项目没有持久化,重启后便签不会保留。
十九、完整流程复盘
19.1 页面启动流程
main()
-> runApp(MyApp)
-> MaterialApp
-> MyHomePage
-> 初始化空 notes 列表
-> build 渲染空状态
19.2 新建便签流程
点击右下角加号
-> _showNoteDialog()
-> 清空控制器
-> 显示 New Note
-> 输入标题和内容
-> 点击保存
-> _saveNote()
-> 插入到列表头部
19.3 编辑便签流程
点击便签卡片
-> _showNoteDialog(index)
-> 填入原标题和内容
-> 显示 Edit Note
-> 修改内容
-> 点击保存
-> 写回原索引
-> 更新时间
19.4 删除与空状态流程
点击删除图标
-> _deleteNote(index)
-> removeAt 移除记录
-> setState 刷新列表
-> 如果列表为空,显示 No notes yet
二十、相关资源与继续学习
20.1 Flutter 学习资源
便签应用涉及输入、底部弹窗、列表和测试,可以结合以下资源学习:
| 资源 | 内容 |
|---|---|
| Flutter Docs | Flutter 官方开发文档 |
| Dart 官方文档 | Dart 语言与核心库 |
| Widget catalog | Flutter 常用组件 |
| Flutter testing | Widget 测试与交互模拟 |
20.2 便签应用扩展方向
后续可以继续增强:
- 搜索便签。
- 本地持久化。
- 分类标签。
- 置顶便签。
- 撤销删除。
- Markdown 编辑。
- 深色模式。
- 云端同步。
20.3 跨端实践价值
notes_app 很适合作为 Flutter 适配鸿蒙的小型表单应用样例。它依赖很轻,但覆盖了底部弹窗、文本输入、列表卡片、空状态、删除操作和软键盘适配,能帮助开发者验证很多跨端基础交互。
总结
notes_app 用简洁的 Flutter 代码实现了便签应用的核心体验:用户可以新建便签、编辑便签、删除便签,并在列表中查看标题、内容摘要和更新时间。它通过 _notes 管理数据,通过两个 TextEditingController 管理输入,通过 showModalBottomSheet 复用新建和编辑入口,通过 ListView.builder 渲染动态列表。
从工程角度看,这个项目最值得学习的是“底部编辑器 + 列表卡片 + 状态复用”的组合方式。面向鸿蒙适配时,项目依赖较轻,主要需要验证软键盘、底部弹窗、滚动布局和触摸反馈。对于想学习 Flutter 表单和列表管理的开发者来说,它是一个清晰、实用且容易扩展的案例。
如果这篇文章对你有帮助,欢迎点赞、收藏、关注,你的支持是我持续创作的动力!
相关资源:
更多推荐



所有评论(0)