鸿蒙Flutter实战:水平滑动分类标签筛选栏
当备忘录数量增长到几十上百条后,分类筛选成为刚需。用户需要快速找到"工作"相关的备忘录,或者只看"学习"分类下的内容。移动端最常见的分类筛选交互是**水平滑动的 Chip 标签栏**——和抖音的话题标签、淘宝的商品分类属于同一设计范式。

前言
当备忘录数量增长到几十上百条后,分类筛选成为刚需。用户需要快速找到"工作"相关的备忘录,或者只看"学习"分类下的内容。移动端最常见的分类筛选交互是水平滑动的 Chip 标签栏——和抖音的话题标签、淘宝的商品分类属于同一设计范式。
鸿蒙 Flutter 备忘录使用 Material 3 的 ChoiceChip 构建了一个完整的分类筛选栏,支持"全部"默认项、水平滚动、选中高亮和与 Provider 数据的联动过滤。
项目仓库:todo_flutter_harmony
需求分析
- 水平滚动:分类数量可变(最少 2 个,最多不限),超出屏幕宽度时水平滚动
- "全部"项:始终在第一位,点击后显示所有分类的内容
- 选中态:当前选中的分类有明显的高亮样式
- 数据联动:选中分类后,列表自动过滤显示对应分类的备忘录
分类模型
class MemoCategory {
final int? id;
final String name;
final String icon; // Emoji 图标
final int sortOrder;
const MemoCategory({
this.id,
required this.name,
this.icon = '📋',
this.sortOrder = 0,
});
Map<String, dynamic> toMap() => {
'id': id,
'name': name,
'icon': icon,
'sortOrder': sortOrder,
};
factory MemoCategory.fromMap(Map<String, dynamic> map) => MemoCategory(
id: map['id'],
name: map['name'] ?? '',
icon: map['icon'] ?? '📋',
sortOrder: map['sortOrder'] ?? 0,
);
}
CategoryProvider
class CategoryProvider extends ChangeNotifier {
List<MemoCategory> _categories = [];
int? _selectedCategoryId; // null 表示"全部"
List<MemoCategory> get categories => List.unmodifiable(_categories);
int? get selectedCategoryId => _selectedCategoryId;
void loadCategories() async {
_categories = await DatabaseHelper.instance.getAllCategories();
notifyListeners();
}
void selectCategory(int? categoryId) {
_selectedCategoryId = categoryId;
notifyListeners();
}
bool get isAllSelected => _selectedCategoryId == null;
Future<void> addCategory(MemoCategory category) async {
await DatabaseHelper.instance.insertCategory(category);
await loadCategories();
}
Future<void> deleteCategory(int id) async {
await DatabaseHelper.instance.deleteCategory(id);
await loadCategories();
}
}
核心设计:_selectedCategoryId 为 null 时表示选中"全部",非 null 时表示选中了某个具体分类。
分类标签栏组件
class CategoryFilterBar extends StatelessWidget {
const CategoryFilterBar({super.key});
Widget build(BuildContext context) {
return Consumer<CategoryProvider>(
builder: (context, provider, _) {
final categories = provider.categories;
if (categories.isEmpty) {
return const SizedBox.shrink();
}
return SizedBox(
height: 44,
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: categories.length + 1, // +1 for "全部"
itemBuilder: (context, index) {
if (index == 0) {
// "全部"标签
return _buildChip(
label: '全部',
emoji: '🏠',
isSelected: provider.isAllSelected,
onTap: () => provider.selectCategory(null),
);
}
final category = categories[index - 1];
return _buildChip(
label: category.name,
emoji: category.icon,
isSelected: provider.selectedCategoryId == category.id,
onTap: () => provider.selectCategory(category.id),
);
},
),
);
},
);
}
单个 Chip 的实现
Widget _buildChip({
required String label,
required String emoji,
required bool isSelected,
required VoidCallback onTap,
}) {
return Padding(
padding: const EdgeInsets.only(right: 8),
child: GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
decoration: BoxDecoration(
color: isSelected
? const Color(0xFF4DB6AC)
: Colors.grey.shade100,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(emoji, style: const TextStyle(fontSize: 14)),
const SizedBox(width: 4),
Text(
label,
style: TextStyle(
fontSize: 13,
color: isSelected ? Colors.white : Colors.grey.shade700,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
),
),
],
),
),
),
);
}
}
用 AnimatedContainer 而非 ChoiceChip 的原因:AnimatedContainer 的动画控制更灵活,且样式(圆角、内边距、emoji 图标)可以完全自定义。ChoiceChip 受 Material 3 规范的约束较大。
数据过滤联动
MemoProvider 中的过滤逻辑:
class MemoProvider extends ChangeNotifier {
List<Memo> _allMemos = [];
int? _categoryFilter;
String _searchQuery = '';
List<Memo> get filteredMemos {
var result = _allMemos;
// 1. 分类过滤
if (_categoryFilter != null) {
result = result.where((m) => m.categoryId == _categoryFilter).toList();
}
// 2. 搜索过滤
if (_searchQuery.isNotEmpty) {
result = result.where((m) =>
m.title.toLowerCase().contains(_searchQuery.toLowerCase()) ||
m.content.toLowerCase().contains(_searchQuery.toLowerCase())
).toList();
}
// 3. 排序:置顶的在前,然后按时间倒序
result.sort((a, b) {
if (a.isPinned != b.isPinned) return a.isPinned ? -1 : 1;
return b.createdAt.compareTo(a.createdAt);
});
return result;
}
void setCategoryFilter(int? categoryId) {
_categoryFilter = categoryId;
notifyListeners();
}
}
关键设计:filteredMemos 是一个计算属性(getter),每次访问时实时过滤和排序。notifyListeners() 触发 UI 重建,重建时自动调用 filteredMemos,拿到最新过滤结果。
列表页组合
class MemoListPage extends StatelessWidget {
Widget build(BuildContext context) {
return Column(
children: [
// 分类筛选栏
CategoryFilterBar(),
const Divider(height: 1),
// 备忘录列表
Expanded(
child: Consumer<MemoProvider>(
builder: (context, provider, _) {
final memos = provider.filteredMemos;
if (memos.isEmpty) {
return _buildEmptyState();
}
return ListView.builder(
itemCount: memos.length,
itemBuilder: (context, index) {
return AnimatedListItem(
delay: index * 50,
child: MemoCard(memo: memos[index]),
);
},
);
},
),
),
],
);
}
}
空状态处理
分类筛选中一个容易被忽略的细节:当用户选中某个分类但该分类下没有备忘录时,不应显示空白列表,而是给出友好的空状态提示:
Widget _buildEmptyState() {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.inbox_outlined, size: 72, color: Colors.grey.shade300),
const SizedBox(height: 16),
Text(
'暂无疑似备忘录',
style: TextStyle(
fontSize: 16,
color: Colors.grey.shade500,
),
),
const SizedBox(height: 8),
Text(
'点击右下角 + 按钮新建',
style: TextStyle(
fontSize: 13,
color: Colors.grey.shade400,
),
),
],
),
);
}
与分类管理页的联动
用户可以在分类管理页中增删分类。删除一个分类后,如果当前筛选器正好选中该分类,需要自动回退到"全部":
Future<void> deleteCategory(int id) async {
await DatabaseHelper.instance.deleteCategory(id);
if (_selectedCategoryId == id) {
_selectedCategoryId = null; // 回退到"全部"
}
await loadCategories();
}
鸿蒙兼容性
分类标签栏组件完全基于 Flutter 框架层:
ListView.builder(scrollDirection: Axis.horizontal):水平滚动GestureDetector+AnimatedContainer:点击选中动画Consumer<CategoryProvider>:Provider 响应式数据绑定
零原生依赖,鸿蒙 OHOS 上直接可用。
总结
分类筛选标签栏的实现可以归纳为三个层次:
- 数据层:
CategoryProvider._selectedCategoryId(null=全部,非null=具体分类) - UI 层:水平
ListView+AnimatedContainer芯片样式 - 联动层:
MemoProvider.filteredMemos计算属性实时过滤
三个层次通过 Provider 的 notifyListeners() 串在一起,一个分类点击触发全链路响应式更新。
完整项目代码见:todo_flutter_harmony
更多推荐



所有评论(0)