鸿蒙Flutter实战:分类管理页BottomSheet CRUD

前言

备忘录的分类是动态的------用户需要能跟自己的需求变化新增、重命名、删除分类。"工作"和"个人"是初始分类,但随着时间推移,可能需要增加"健身"、"读书笔记"、"旅行计划"等分类。

鸿蒙 Flutter 备忘录提供了一个完整的分类管理页面,支持新增(BottomSheet 输入)、编辑(重命名)和删除(确认对话框)。本文拆解分类管理的完整 CRUD 实现,重点放在 BottomSheet 交互和删除时的数据迁移逻辑。

项目仓库:todo_flutter_harmony

分类管理页整体布局

dart 复制代码
class CategoryPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('分类管理'),
      ),
      body: Consumer<CategoryProvider>(
        builder: (context, provider, _) {
          final categories = provider.categories;

          if (categories.isEmpty) {
            return Center(
              child: Column(
                mainAxisSize: MainAxisSize.min,
                children: [
                  Icon(Icons.folder_outlined, size: 64, color: Colors.grey.shade300),
                  const SizedBox(height: 12),
                  Text('暂无分类', style: TextStyle(color: Colors.grey.shade500)),
                  const SizedBox(height: 16),
                  FilledButton.icon(
                    onPressed: () => _showAddCategorySheet(context, provider),
                    icon: const Icon(Icons.add),
                    label: const Text('新建分类'),
                  ),
                ],
              ),
            );
          }

          return ListView.builder(
            itemCount: categories.length,
            itemBuilder: (context, index) {
              return _buildCategoryItem(context, categories[index], provider);
            },
          );
        },
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () => _showAddCategorySheet(context,
            context.read<CategoryProvider>()),
        child: const Icon(Icons.add),
      ),
    );
  }

新增分类:BottomSheet

新增分类不使用全屏页面导航,而是用 showModalBottomSheet------这是一个从底部弹出的半屏面板,交互更轻量:

dart 复制代码
void _showAddCategorySheet(BuildContext context, CategoryProvider provider,
    {MemoCategory? existingCategory}) {
  final nameController = TextEditingController(text: existingCategory?.name ?? '');
  final emojiController = TextEditingController(text: existingCategory?.icon ?? '📋');
  final isEditing = existingCategory != null;

  showModalBottomSheet(
    context: context,
    isScrollControlled: true,  // 键盘弹出时 BottomSheet 跟着上移
    shape: const RoundedRectangleBorder(
      borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
    ),
    builder: (ctx) {
      return Padding(
        padding: EdgeInsets.only(
          left: 20,
          right: 20,
          top: 20,
          bottom: MediaQuery.of(ctx).viewInsets.bottom + 20,  // 为键盘留空间
        ),
        child: Column(
          mainAxisSize: MainAxisSize.min,
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            // 拖拽指示条
            Center(
              child: Container(
                width: 40,
                height: 4,
                decoration: BoxDecoration(
                  color: Colors.grey.shade300,
                  borderRadius: BorderRadius.circular(2),
                ),
              ),
            ),
            const SizedBox(height: 20),
            Text(
              isEditing ? '编辑分类' : '新建分类',
              style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
            ),
            const SizedBox(height: 20),
            // Emoji 输入
            TextField(
              controller: emojiController,
              maxLength: 2,
              decoration: const InputDecoration(
                labelText: '图标 (Emoji)',
                border: OutlineInputBorder(),
                counterText: '',
              ),
            ),
            const SizedBox(height: 16),
            // 分类名称输入
            TextField(
              controller: nameController,
              autofocus: true,
              decoration: InputDecoration(
                labelText: '分类名称',
                border: const OutlineInputBorder(),
                errorText: _validateName(nameController.text),
              ),
            ),
            const SizedBox(height: 20),
            // 确认按钮
            FilledButton(
              onPressed: () {
                final name = nameController.text.trim();
                if (name.isEmpty) return;

                if (isEditing) {
                  provider.updateCategory(
                    existingCategory!.copyWith(name: name, icon: emojiController.text),
                  );
                } else {
                  provider.addCategory(MemoCategory(
                    name: name,
                    icon: emojiController.text.isNotEmpty ? emojiController.text : '📋',
                    sortOrder: provider.categories.length,
                  ));
                }

                Navigator.pop(ctx);
              },
              child: Text(isEditing ? '保存' : '创建'),
            ),
            const SizedBox(height: 8),
          ],
        ),
      );
    },
  );
}

关键细节:

  1. isScrollControlled: true:让 BottomSheet 在键盘弹出时自动上移,输入框不会被键盘遮挡
  2. MediaQuery.of(ctx).viewInsets.bottom:底部 padding 动态跟随键盘高度
  3. 拖拽指示条:顶部一个 40×4 的灰色小横条,暗示 BottomSheet 可以下拉关闭
  4. autofocus: true:打开 BottomSheet 后键盘自动弹出,聚焦到名称输入框
  5. 同一组件处理新增和编辑 :通过 isEditing 参数区分

编辑分类

编辑复用同一个 BottomSheet,传入 existingCategory 参数即可:

dart 复制代码
Widget _buildCategoryItem(
    BuildContext context, MemoCategory category, CategoryProvider provider) {
  return ListTile(
    leading: Text(category.icon, style: const TextStyle(fontSize: 24)),
    title: Text(category.name),
    trailing: PopupMenuButton<String>(
      onSelected: (value) {
        switch (value) {
          case 'edit':
            _showAddCategorySheet(context, provider, existingCategory: category);
            break;
          case 'delete':
            _confirmDeleteCategory(context, provider, category);
            break;
        }
      },
      itemBuilder: (ctx) => [
        const PopupMenuItem(value: 'edit', child: Text('编辑')),
        const PopupMenuItem(value: 'delete', child: Text('删除')),
      ],
    ),
  );
}

删除分类 + 数据迁移

删除分类时有一个关键问题:该分类下的备忘录怎么办?粗暴的做法是直接删除关联备忘录,但这对用户来说是数据丢失。更好的做法是:将关联的备忘录迁移到"未分类"(即 categoryId = null)。

dart 复制代码
void _confirmDeleteCategory(
    BuildContext context, CategoryProvider provider, MemoCategory category) {
  showDialog(
    context: context,
    builder: (ctx) => AlertDialog(
      title: const Text('删除分类'),
      content: Text(
        '确定要删除「${category.name}」分类吗?\n\n该分类下的备忘录将被移至"未分类"。',
      ),
      actions: [
        TextButton(
          onPressed: () => Navigator.pop(ctx),
          child: const Text('取消'),
        ),
        TextButton(
          onPressed: () async {
            await provider.deleteCategory(category.id!);
            if (ctx.mounted) {
              Navigator.pop(ctx);
              ScaffoldMessenger.of(context).showSnackBar(
                SnackBar(content: Text('已删除分类「${category.name}」')),
              );
            }
          },
          style: TextButton.styleFrom(foregroundColor: Colors.red),
          child: const Text('删除'),
        ),
      ],
    ),
  );
}

Provider 中的删除逻辑负责数据迁移:

dart 复制代码
Future<void> deleteCategory(int id) async {
  // 1. 将该分类下的所有备忘录设为"未分类"
  final db = DatabaseHelper.instance;
  final memos = await db.getAllMemos();
  for (final memo in memos) {
    if (memo.categoryId == id) {
      await db.updateMemo(memo.copyWith(categoryId: null));
    }
  }

  // 2. 删除分类本身
  await db.deleteCategory(id);

  // 3. 如果当前筛选器选中该分类,回退到"全部"
  if (_selectedCategoryId == id) {
    _selectedCategoryId = null;
  }

  // 4. 重新加载
  await loadCategories();
  // 注意:需要通知 MemoProvider 也重新加载
}

重名检测

新增分类时应检测同名:

dart 复制代码
String? _validateName(String name, CategoryProvider provider,
    {MemoCategory? exclude}) {
  if (name.trim().isEmpty) return '名称不能为空';

  final exists = provider.categories.any((c) =>
    c.name.trim().toLowerCase() == name.trim().toLowerCase() &&
    c.id != exclude?.id  // 编辑时排除自身
  );

  if (exists) return '该分类名称已存在';
  return null;
}

CategoryProvider 完整接口

dart 复制代码
class CategoryProvider extends ChangeNotifier {
  List<MemoCategory> _categories = [];

  List<MemoCategory> get categories => List.unmodifiable(_categories);

  Future<void> loadCategories() async {
    _categories = await DatabaseHelper.instance.getAllCategories();
    _categories.sort((a, b) => a.sortOrder.compareTo(b.sortOrder));
    notifyListeners();
  }

  Future<void> addCategory(MemoCategory category) async {
    await DatabaseHelper.instance.insertCategory(category);
    await loadCategories();
  }

  Future<void> updateCategory(MemoCategory category) async {
    await DatabaseHelper.instance.updateCategory(category);
    await loadCategories();
  }

  Future<void> deleteCategory(int id) async {
    // 迁移关联备忘录到"未分类"
    final db = DatabaseHelper.instance;
    final memos = await db.getAllMemos();
    for (final memo in memos) {
      if (memo.categoryId == id) {
        await db.updateMemo(memo.copyWith(categoryId: null));
      }
    }
    await db.deleteCategory(id);
    await loadCategories();
  }
}

鸿蒙兼容性

  • showModalBottomSheet:Material 组件,Flutter 框架层实现
  • MediaQuery.viewInsets:Flutter 框架从引擎获取键盘高度信息------这在鸿蒙上依赖 flutter_ohos 引擎正确报告键盘状态
  • 数据迁移逻辑:纯 Dart 代码,与平台无关

如果鸿蒙引擎在键盘高度报告上有不准确的情况,viewInsets.bottom 可能不会是期望的值。这可以通过在 OHOS 端 EntryAbility 中做额外处理来修正。

总结

分类管理的 CRUD 实现关键点:

  1. 新增/编辑showModalBottomSheet + isScrollControlled,轻量半屏交互
  2. 删除:确认对话框 + 关联数据迁移到"未分类",防止数据丢失
  3. 重名检测:大小写不敏感的字符串比较
  4. BottomSheet 键盘适配viewInsets.bottom 动态调整底部间距

完整项目代码见:todo_flutter_harmony

相关推荐
倔强的石头_10 小时前
《Kingbase护城河》——数据库存储空间全景探测与精细化瘦身实战
数据库
程序员老刘11 小时前
跨平台开发地图 | 2026年6月
flutter·ai编程·客户端
冬奇Lab1 天前
每日一个开源项目(第134篇):Zvec - 阿里开源的嵌入式向量数据库,向量搜索界的 SQLite
数据库·人工智能·llm
悟空瞎说1 天前
Flutter 架构详解:新手必懂底层原理
flutter
SoaringHeart1 天前
Flutter最佳实践:IM聊天文字链接自动识别跳转
前端·flutter
ClouGence1 天前
Oracle CDC 架构优化:从主库直连到 DataGuard 备库同步
数据库·后端·oracle
Junerver1 天前
把 DevEco Code 的 HarmonyOS 开发能力装进口袋——harmonyos-dev-skill
harmonyos
无响应de神1 天前
三、用户与权限管理
数据库·mysql
恋猫de小郭2 天前
KMP / CMP 鸿蒙版本 Beta 发布,他有什么特别之处?
android·前端·flutter
程序猿追2 天前
那个右下角的小数字怎么“卡”住我打字——我用 HarmonyOS 自己写了一个字数限制输入框
pytorch·华为·harmonyos