请添加图片描述

前言

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

鸿蒙 Flutter 备忘录使用 Material 3 的 ChoiceChip 构建了一个完整的分类筛选栏,支持"全部"默认项、水平滚动、选中高亮和与 Provider 数据的联动过滤。

项目仓库:todo_flutter_harmony

需求分析

  1. 水平滚动:分类数量可变(最少 2 个,最多不限),超出屏幕宽度时水平滚动
  2. "全部"项:始终在第一位,点击后显示所有分类的内容
  3. 选中态:当前选中的分类有明显的高亮样式
  4. 数据联动:选中分类后,列表自动过滤显示对应分类的备忘录

分类模型

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();
  }
}

核心设计:_selectedCategoryIdnull 时表示选中"全部",非 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 上直接可用。

总结

分类筛选标签栏的实现可以归纳为三个层次:

  1. 数据层CategoryProvider._selectedCategoryId(null=全部,非null=具体分类)
  2. UI 层:水平 ListView + AnimatedContainer 芯片样式
  3. 联动层MemoProvider.filteredMemos 计算属性实时过滤

三个层次通过 Provider 的 notifyListeners() 串在一起,一个分类点击触发全链路响应式更新。

完整项目代码见:todo_flutter_harmony

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐