
个人主页:ujainu
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
文章目录
-
- [一、为什么需要标签?------ 信息组织的升维](#一、为什么需要标签?—— 信息组织的升维)
- [二、数据模型升级:Note 新增 tags 字段](#二、数据模型升级:Note 新增 tags 字段)
- 三、编辑页增强:标签输入区域
-
- [UI 部分:添加标签输入区](#UI 部分:添加标签输入区)
- 四、主界面重构:多维筛选系统
-
- [1. 状态变量定义](#1. 状态变量定义)
- [2. 标签筛选栏:可滚动 Chip 列表](#2. 标签筛选栏:可滚动 Chip 列表)
- [3. 时间筛选:SegmentedButton(Material 3)](#3. 时间筛选:SegmentedButton(Material 3))
- 五、核心逻辑:三重筛选合并
- 六、辅助逻辑:自动提取全局标签
- [七、完整可运行代码(Flutter + OpenHarmony)](#七、完整可运行代码(Flutter + OpenHarmony))
- 结语
在前两篇中,我们构建了一个功能完整的 内存版鸿蒙记事本 :第一篇实现了基础的 CRUD 操作 ,第二篇加入了 实时全局搜索。然而,随着笔记数量持续增长,仅靠"标题+内容"和关键词搜索仍显不足------用户需要更精细、更结构化的组织方式。
此时,标签(Tags)系统 便成为提升信息架构清晰度的关键。通过为笔记打上如 #工作、#灵感、#购物清单 等标签,用户可按主题快速聚合相关内容。同时,结合 时间维度筛选 (今日/本周/全部),我们能构建一个 多维、高效、直观 的笔记管理体系。
本文将为记事本引入 标签功能 与 分类视图,支持:
- 在编辑页添加/修改标签
- 主界面顶部展示 可滚动标签筛选栏
- 使用 分段按钮(SegmentedButton) 切换时间范围
- 动态合并 搜索 + 标签 + 时间 三重过滤逻辑
最终,让每一条笔记都"各归其位",真正实现 井井有条。
一、为什么需要标签?------ 信息组织的升维
OpenHarmony 倡导"自然、高效"的用户体验,而 标签化管理 正是这一理念的体现:
| 组织方式 | 优点 | 缺点 |
|---|---|---|
| 纯列表 | 简单直接 | 信息杂乱,难定位 |
| 全局搜索 | 快速匹配关键词 | 依赖记忆,无法主动分类 |
| 标签 + 时间 | 主动分类 + 上下文感知 | 需额外输入,但收益巨大 |
✅ 标签的核心价值:
- 非互斥分类:一条笔记可同时属于"工作"和"会议"
- 语义化聚合 :点击
#灵感即可查看所有创意片段- 降低认知负荷:无需记住具体内容,只需回忆主题
二、数据模型升级:Note 新增 tags 字段
首先,扩展 Note 类,支持标签列表:
dart
class Note {
final String id;
String title;
String content;
final DateTime createdAt;
List<String> tags; // 新增字段
Note({
required this.title,
this.content = '',
this.tags = const [], // 默认空列表
}) : id = DateTime.now().microsecondsSinceEpoch.toString(),
createdAt = DateTime.now();
Note.withId({
required this.id,
required this.title,
required this.content,
required this.createdAt,
required this.tags,
});
// 工具方法:判断是否包含某标签
bool hasTag(String tag) => tags.contains(tag);
}
🔍 设计考量:
tags为List<String>,便于存储与匹配- 提供
hasTag()方法简化筛选逻辑- 构造函数兼容旧代码(默认空标签)
三、编辑页增强:标签输入区域
在 NoteEditorPage 中添加标签输入框。为符合鸿蒙简洁风格,我们采用 逗号分隔文本输入 + 自动解析 的方案:
dart
class _NoteEditorPageState extends State<NoteEditorPage> {
late final TextEditingController _titleController;
late final TextEditingController _contentController;
late final TextEditingController _tagsController; // 新增
@override
void initState() {
super.initState();
// ... 初始化 title 和 content
if (widget.existingNote != null) {
_tagsController = TextEditingController(
text: widget.existingNote!.tags.join(', '), // 用逗号连接
);
} else {
_tagsController = TextEditingController();
}
}
@override
void dispose() {
// ... dispose 其他 controller
_tagsController.dispose();
super.dispose();
}
List<String> _parseTags(String input) {
return input
.split(',')
.map((e) => e.trim())
.where((e) => e.isNotEmpty)
.toSet() // 去重
.toList();
}
void _saveNote() {
final title = _titleController.text.trim();
if (title.isEmpty) return;
final tags = _parseTags(_tagsController.text);
final note = widget.existingNote != null
? Note.withId(
id: widget.existingNote!.id,
title: title,
content: _contentController.text.trim(),
createdAt: widget.existingNote!.createdAt,
tags: tags,
)
: Note(
title: title,
content: _contentController.text.trim(),
tags: tags,
);
Navigator.pop(context, note);
}
}
UI 部分:添加标签输入区
dart
@override
Widget build(BuildContext context) {
return Scaffold(
// ... AppBar
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
// 标题 TextField
const Divider(height: 24),
// 内容 TextField
const SizedBox(height: 16),
TextField(
controller: _tagsController,
decoration: const InputDecoration(
hintText: '标签(用逗号分隔,如:工作, 会议)',
border: OutlineInputBorder(),
focusedBorder: OutlineInputBorder(),
),
maxLines: 1,
),
],
),
),
);
}
✅ 交互优化:
- 用户输入
工作, 会议, 工作→ 自动去重为[工作, 会议]- 提示文案明确格式要求
- 使用标准
OutlineInputBorder区分于标题/内容
四、主界面重构:多维筛选系统
现在,主界面需同时处理 搜索、标签、时间 三种筛选条件。
1. 状态变量定义
dart
class _HomePageState extends State<HomePage> {
final List<Note> _allNotes = [];
List<Note> _filteredNotes = [];
String _searchQuery = '';
String? _selectedTag; // 当前选中的标签
TimeFilter _timeFilter = TimeFilter.all; // 时间筛选枚举
Set<String> _allTags = {}; // 所有唯一标签集合
// ... 其他状态(FocusNode, Timer 等)
}
定义时间筛选枚举:
dart
enum TimeFilter { all, today, thisWeek }
2. 标签筛选栏:可滚动 Chip 列表
使用 SingleChildScrollView + Row + FilterChip 实现:
dart
Widget _buildTagFilterBar() {
if (_allTags.isEmpty) return const SizedBox.shrink();
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
_buildFilterChip('全部', null, _selectedTag == null),
..._allTags.map((tag) {
return _buildFilterChip(tag, tag, _selectedTag == tag);
}).toList(),
],
),
);
}
Widget _buildFilterChip(String label, String? value, bool isSelected) {
return FilterChip(
label: Text(label),
selected: isSelected,
onSelected: (selected) {
setState(() {
_selectedTag = selected ? value : null;
_applyFilters(); // 重新应用所有筛选
});
},
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
);
}
📌 鸿蒙风格适配:
- 圆角
16符合 HarmonyOS Design- 水平滚动避免换行,节省垂直空间
- "全部"选项始终置顶
3. 时间筛选:SegmentedButton(Material 3)
dart
Widget _buildTimeFilter() {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: SegmentedButton<TimeFilter>(
segments: const [
ButtonSegment<TimeFilter>(value: TimeFilter.all, label: Text('全部')),
ButtonSegment<TimeFilter>(value: TimeFilter.today, label: Text('今日')),
ButtonSegment<TimeFilter>(value: TimeFilter.thisWeek, label: Text('本周')),
],
selected: {_timeFilter},
onSelectionChanged: (Set<TimeFilter> newSelection) {
setState(() {
_timeFilter = newSelection.first;
_applyFilters();
});
},
style: ButtonStyle(
shape: WidgetStatePropertyAll(RoundedRectangleBorder(borderRadius: BorderRadius.circular(12))),
),
),
);
}
✅ 优势:
SegmentedButton是 Material 3 推荐组件,视觉现代- 单选模式天然契合时间筛选需求
- 圆角
12与卡片风格统一
五、核心逻辑:三重筛选合并
创建 _applyFilters() 方法,统一处理所有条件:
dart
void _applyFilters() {
List<Note> result = _allNotes;
// 1. 时间筛选
result = _filterByTime(result, _timeFilter);
// 2. 标签筛选
if (_selectedTag != null) {
result = result.where((note) => note.hasTag(_selectedTag!)).toList();
}
// 3. 搜索筛选
if (_searchQuery.isNotEmpty) {
final lowerQuery = _searchQuery.toLowerCase();
result = result.where((note) {
return note.title.toLowerCase().contains(lowerQuery) ||
note.content.toLowerCase().contains(lowerQuery) ||
note.tags.any((tag) => tag.toLowerCase().contains(lowerQuery));
}).toList();
}
setState(() {
_filteredNotes = result;
});
}
List<Note> _filterByTime(List<Note> notes, TimeFilter filter) {
if (filter == TimeFilter.all) return notes;
final now = DateTime.now();
return notes.where((note) {
if (filter == TimeFilter.today) {
return note.createdAt.year == now.year &&
note.createdAt.month == now.month &&
note.createdAt.day == now.day;
} else if (filter == TimeFilter.thisWeek) {
final weekStart = now.subtract(Duration(days: now.weekday - 1));
final weekEnd = weekStart.add(const Duration(days: 7));
return note.createdAt.isAfter(weekStart) && note.createdAt.isBefore(weekEnd);
}
return true;
}).toList();
}
⚠️ 关键细节:
- 搜索也覆盖标签内容(
note.tags.any(...))- 时间计算考虑跨年/跨月边界
- 所有过滤链式执行,顺序可调整
六、辅助逻辑:自动提取全局标签
每次 _allNotes 变更时,需更新 _allTags:
dart
void _updateAllTags() {
final tags = <String>{};
for (final note in _allNotes) {
tags.addAll(note.tags);
}
setState(() {
_allTags = tags;
});
}
// 在 CRUD 操作后调用
void _onAddNote() {
// ... 保存后
_updateAllTags();
_applyFilters();
}
七、完整可运行代码(Flutter + OpenHarmony)
以下为整合 CRUD + 搜索 + 标签 + 时间筛选 的完整代码:
dart
// main.dart - 支持标签与多维筛选的鸿蒙记事本
import 'package:flutter/material.dart';
import 'dart:async';
// ==================== 数据模型 ====================
class Note {
final String id;
String title;
String content;
final DateTime createdAt;
List<String> tags;
Note({
required this.title,
this.content = '',
this.tags = const [],
}) : id = DateTime.now().microsecondsSinceEpoch.toString(),
createdAt = DateTime.now();
Note.withId({
required this.id,
required this.title,
required this.content,
required this.createdAt,
required this.tags,
});
bool hasTag(String tag) => tags.contains(tag);
}
enum TimeFilter { all, today, thisWeek }
// ==================== 编辑页面 ====================
class NoteEditorPage extends StatefulWidget {
final Note? existingNote;
const NoteEditorPage({this.existingNote, super.key});
@override
State<NoteEditorPage> createState() => _NoteEditorPageState();
}
class _NoteEditorPageState extends State<NoteEditorPage> {
late final TextEditingController _titleController;
late final TextEditingController _contentController;
late final TextEditingController _tagsController;
@override
void initState() {
super.initState();
if (widget.existingNote != null) {
_titleController = TextEditingController(text: widget.existingNote!.title);
_contentController = TextEditingController(text: widget.existingNote!.content);
_tagsController = TextEditingController(text: widget.existingNote!.tags.join(', '));
} else {
_titleController = TextEditingController();
_contentController = TextEditingController();
_tagsController = TextEditingController();
}
}
@override
void dispose() {
_titleController.dispose();
_contentController.dispose();
_tagsController.dispose();
super.dispose();
}
List<String> _parseTags(String input) {
return input
.split(',')
.map((e) => e.trim())
.where((e) => e.isNotEmpty)
.toSet()
.toList();
}
void _saveNote() {
final title = _titleController.text.trim();
if (title.isEmpty) return;
final tags = _parseTags(_tagsController.text);
final note = widget.existingNote != null
? Note.withId(
id: widget.existingNote!.id,
title: title,
content: _contentController.text.trim(),
createdAt: widget.existingNote!.createdAt,
tags: tags,
)
: Note(title: title, content: _contentController.text.trim(), tags: tags);
Navigator.pop(context, note);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.existingNote == null ? '新建笔记' : '编辑笔记'),
backgroundColor: Colors.white,
foregroundColor: Colors.black,
elevation: 0,
actions: [IconButton(icon: const Icon(Icons.save), onPressed: _saveNote)],
),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
TextField(
controller: _titleController,
decoration: const InputDecoration(
hintText: '标题',
border: InputBorder.none,
focusedBorder: InputBorder.none,
),
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
maxLines: 1,
),
const Divider(height: 24),
Expanded(
child: TextField(
controller: _contentController,
decoration: const InputDecoration(
hintText: '写下你的想法...',
border: InputBorder.none,
focusedBorder: InputBorder.none,
),
maxLines: null,
keyboardType: TextInputType.multiline,
),
),
const SizedBox(height: 16),
TextField(
controller: _tagsController,
decoration: const InputDecoration(
hintText: '标签(用逗号分隔,如:工作, 会议)',
border: OutlineInputBorder(),
focusedBorder: OutlineInputBorder(),
),
maxLines: 1,
),
],
),
),
);
}
}
// ==================== 主界面 ====================
class HomePage extends StatefulWidget {
const HomePage({super.key});
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
final List<Note> _allNotes = [];
List<Note> _filteredNotes = [];
String _searchQuery = '';
String? _selectedTag;
TimeFilter _timeFilter = TimeFilter.all;
Set<String> _allTags = {};
late FocusNode _searchFocusNode;
bool _isSearching = false;
Timer? _debounceTimer;
@override
void initState() {
super.initState();
_searchFocusNode = FocusNode();
_filteredNotes = _allNotes;
}
@override
void dispose() {
_debounceTimer?.cancel();
_searchFocusNode.dispose();
super.dispose();
}
void _updateAllTags() {
final tags = <String>{};
for (final note in _allNotes) {
tags.addAll(note.tags);
}
setState(() {
_allTags = tags;
});
}
void _applyFilters() {
List<Note> result = _allNotes;
result = _filterByTime(result, _timeFilter);
if (_selectedTag != null) {
result = result.where((note) => note.hasTag(_selectedTag!)).toList();
}
if (_searchQuery.isNotEmpty) {
final lowerQuery = _searchQuery.toLowerCase();
result = result.where((note) {
return note.title.toLowerCase().contains(lowerQuery) ||
note.content.toLowerCase().contains(lowerQuery) ||
note.tags.any((tag) => tag.toLowerCase().contains(lowerQuery));
}).toList();
}
setState(() {
_filteredNotes = result;
});
}
List<Note> _filterByTime(List<Note> notes, TimeFilter filter) {
if (filter == TimeFilter.all) return notes;
final now = DateTime.now();
return notes.where((note) {
if (filter == TimeFilter.today) {
return note.createdAt.year == now.year &&
note.createdAt.month == now.month &&
note.createdAt.day == now.day;
} else if (filter == TimeFilter.thisWeek) {
final weekStart = now.subtract(Duration(days: now.weekday - 1));
final weekEnd = weekStart.add(const Duration(days: 7));
return note.createdAt.isAfter(weekStart) && note.createdAt.isBefore(weekEnd);
}
return true;
}).toList();
}
void _enterSearchMode() {
setState(() {
_isSearching = true;
_searchQuery = '';
});
_searchFocusNode.requestFocus();
_applyFilters();
}
void _exitSearchMode() {
setState(() {
_isSearching = false;
_searchQuery = '';
});
_searchFocusNode.unfocus();
_applyFilters();
}
void _onSearchQueryChanged(String query) {
_debounceTimer?.cancel();
_debounceTimer = Timer(const Duration(milliseconds: 300), () {
setState(() {
_searchQuery = query.trim();
});
_applyFilters();
});
}
void _onAddNote() {
Navigator.push(context, MaterialPageRoute(builder: (_) => const NoteEditorPage()))
.then((result) {
if (result != null && result is Note) {
setState(() {
_allNotes.insert(0, result);
});
_updateAllTags();
_applyFilters();
}
});
}
void _onEditNote(Note note) {
Navigator.push(
context,
MaterialPageRoute(builder: (_) => NoteEditorPage(existingNote: note)),
).then((result) {
if (result != null && result is Note) {
setState(() {
final index = _allNotes.indexWhere((n) => n.id == result.id);
if (index != -1) {
_allNotes[index] = result;
}
});
_updateAllTags();
_applyFilters();
}
});
}
void _onDeleteNote(Note note) {
setState(() {
_allNotes.removeWhere((n) => n.id == note.id);
});
_updateAllTags();
_applyFilters();
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('已删除 "${note.title}"')));
}
String _formatTime(DateTime time) {
return '${time.year}-${time.month.toString().padLeft(2, '0')}-${time.day.toString().padLeft(2, '0')} '
'${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}';
}
Widget _buildNoteCard(Note note) {
return Card(
margin: const EdgeInsets.only(bottom: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(note.title, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.grey),
maxLines: 1, overflow: TextOverflow.ellipsis),
const SizedBox(height: 8),
if (note.content.isNotEmpty)
Text(note.content, maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle(color: Colors.grey)),
const SizedBox(height: 8),
if (note.tags.isNotEmpty)
Wrap(
spacing: 6,
runSpacing: 4,
children: note.tags.map((tag) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: Colors.blue.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Text('#$tag', style: const TextStyle(color: Colors.blue, fontSize: 12)),
);
}).toList(),
),
const SizedBox(height: 8),
Text(_formatTime(note.createdAt), style: const TextStyle(color: Colors.grey, fontSize: 12)),
],
),
),
);
}
Widget _buildNoteItem(Note note, int index) {
return Dismissible(
key: Key(note.id),
direction: DismissDirection.endToStart,
background: Container(
color: Colors.red,
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 20),
child: const Icon(Icons.delete, color: Colors.white),
),
onDismissed: (direction) => _onDeleteNote(note),
child: GestureDetector(
onTap: () => _onEditNote(note),
child: _buildNoteCard(note),
),
);
}
Widget _buildSearchField() {
if (!_isSearching) {
return const Text('我的笔记', style: TextStyle(fontWeight: FontWeight.w500));
}
return TextField(
focusNode: _searchFocusNode,
onChanged: _onSearchQueryChanged,
decoration: InputDecoration(
hintText: '搜索标题、内容或标签...',
hintStyle: const TextStyle(color: Colors.grey),
border: InputBorder.none,
focusedBorder: InputBorder.none,
prefixIcon: const Icon(Icons.search, color: Colors.grey),
suffixIcon: _searchQuery.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear, size: 18),
onPressed: () {
_onSearchQueryChanged('');
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_searchFocusNode.hasFocus) {
_searchFocusNode.requestFocus();
}
});
},
)
: null,
),
style: const TextStyle(fontSize: 16),
);
}
Widget _buildTagFilterBar() {
if (_allTags.isEmpty) return const SizedBox.shrink();
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
_buildFilterChip('全部', null, _selectedTag == null),
..._allTags.map((tag) {
return _buildFilterChip(tag, tag, _selectedTag == tag);
}).toList(),
],
),
);
}
Widget _buildFilterChip(String label, String? value, bool isSelected) {
return FilterChip(
label: Text(label),
selected: isSelected,
onSelected: (selected) {
setState(() {
_selectedTag = selected ? value : null;
_applyFilters();
});
},
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
);
}
Widget _buildTimeFilter() {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: SegmentedButton<TimeFilter>(
segments: const [
ButtonSegment<TimeFilter>(value: TimeFilter.all, label: Text('全部')),
ButtonSegment<TimeFilter>(value: TimeFilter.today, label: Text('今日')),
ButtonSegment<TimeFilter>(value: TimeFilter.thisWeek, label: Text('本周')),
],
selected: {_timeFilter},
onSelectionChanged: (Set<TimeFilter> newSelection) {
setState(() {
_timeFilter = newSelection.first;
_applyFilters();
});
},
style: ButtonStyle(
shape: WidgetStatePropertyAll(RoundedRectangleBorder(borderRadius: BorderRadius.circular(12))),
),
),
);
}
Widget _buildNoteList() {
if (_filteredNotes.isEmpty) {
if (_searchQuery.isNotEmpty || _selectedTag != null) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.search_off, size: 64, color: Colors.grey),
const SizedBox(height: 16),
const Text('未找到匹配的笔记', style: TextStyle(color: Colors.grey)),
],
),
);
} else {
return const Center(child: Text('暂无笔记', style: TextStyle(color: Colors.grey)));
}
}
return ListView.builder(
padding: const EdgeInsets.only(top: 8),
itemCount: _filteredNotes.length,
itemBuilder: (context, index) => _buildNoteItem(_filteredNotes[index], index),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Colors.white,
foregroundColor: Colors.black,
elevation: 0,
title: _buildSearchField(),
leading: _isSearching
? IconButton(icon: const Icon(Icons.arrow_back), onPressed: _exitSearchMode)
: null,
actions: !_isSearching
? [IconButton(icon: const Icon(Icons.search), onPressed: _enterSearchMode)]
: null,
),
body: Column(
children: [
if (!_isSearching) ...[
_buildTimeFilter(),
_buildTagFilterBar(),
],
Expanded(child: _buildNoteList()),
],
),
floatingActionButton: !_isSearching
? FloatingActionButton(onPressed: _onAddNote, child: const Icon(Icons.add))
: null,
);
}
}
// ==================== 主程序入口 ====================
void main() {
runApp(MaterialApp(
debugShowCheckedModeBanner: false,
theme: ThemeData(useMaterial3: true),
home: HomePage(),
));
}
运行界面

结语
本文成功为鸿蒙记事本引入了 标签系统 与 多维分类视图 ,通过 时间筛选 + 标签筛选 + 全局搜索 的三重组合,让用户在海量笔记中依然能 快速定位、高效管理。所有 UI 元素均严格遵循 OpenHarmony 的简洁、圆角、留白设计规范。
至此,我们的记事本已具备专业级信息组织能力。下一步,我们将把内存数据持久化到 本地数据库(Drift),确保笔记在 App 重启后依然安全留存。