Flutter 实战:notes_app 便签应用的底部编辑器、新增编辑删除与鸿蒙适配解析

前言

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

notes_app 是一个基于 Flutter 实现的轻量便签应用。它支持新建便签、编辑便签、删除便签、空状态展示、彩色卡片列表和日期格式化。应用没有引入数据库或第三方状态管理库,而是用 StatefulWidgetTextEditingControllershowModalBottomSheetListView.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 实战能力:

  1. 如何用 List<Map<String, dynamic>> 管理轻量业务数据。
  2. 如何用 showModalBottomSheet 实现底部编辑器。
  3. 如何复用同一个弹窗完成新建和编辑。
  4. 如何用 ListView.builder 渲染动态列表。
  5. 如何处理空状态和列表状态切换。
  6. 如何面向鸿蒙验证软键盘、滚动和弹窗适配。

二、工程结构与运行方式

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],
}

当前列表渲染主要使用 titlecontentdatecolor 字段已经写入数据,但页面背景色实际来自 _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 表单和列表管理的开发者来说,它是一个清晰、实用且容易扩展的案例。

如果这篇文章对你有帮助,欢迎点赞、收藏、关注,你的支持是我持续创作的动力!


相关资源:

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐