当前位置: 首页 > news >正文

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

前言

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

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

项目仓库:todo_flutter_harmony

分类管理页整体布局

classCategoryPageextendsStatelessWidget{@overrideWidgetbuild(BuildContextcontext){returnScaffold(appBar:AppBar(title:constText('分类管理'),),body:Consumer<CategoryProvider>(builder:(context,provider,_){finalcategories=provider.categories;if(categories.isEmpty){returnCenter(child:Column(mainAxisSize:MainAxisSize.min,children:[Icon(Icons.folder_outlined,size:64,color:Colors.grey.shade300),constSizedBox(height:12),Text('暂无分类',style:TextStyle(color:Colors.grey.shade500)),constSizedBox(height:16),FilledButton.icon(onPressed:()=>_showAddCategorySheet(context,provider),icon:constIcon(Icons.add),label:constText('新建分类'),),],),);}returnListView.builder(itemCount:categories.length,itemBuilder:(context,index){return_buildCategoryItem(context,categories[index],provider);},);},),floatingActionButton:FloatingActionButton(onPressed:()=>_showAddCategorySheet(context,context.read<CategoryProvider>()),child:constIcon(Icons.add),),);}

新增分类:BottomSheet

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

void_showAddCategorySheet(BuildContextcontext,CategoryProviderprovider,{MemoCategory?existingCategory}){finalnameController=TextEditingController(text:existingCategory?.name??'');finalemojiController=TextEditingController(text:existingCategory?.icon??'📋');finalisEditing=existingCategory!=null;showModalBottomSheet(context:context,isScrollControlled:true,// 键盘弹出时 BottomSheet 跟着上移shape:constRoundedRectangleBorder(borderRadius:BorderRadius.vertical(top:Radius.circular(20)),),builder:(ctx){returnPadding(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),),),),constSizedBox(height:20),Text(isEditing?'编辑分类':'新建分类',style:constTextStyle(fontSize:18,fontWeight:FontWeight.bold),),constSizedBox(height:20),// Emoji 输入TextField(controller:emojiController,maxLength:2,decoration:constInputDecoration(labelText:'图标 (Emoji)',border:OutlineInputBorder(),counterText:'',),),constSizedBox(height:16),// 分类名称输入TextField(controller:nameController,autofocus:true,decoration:InputDecoration(labelText:'分类名称',border:constOutlineInputBorder(),errorText:_validateName(nameController.text),),),constSizedBox(height:20),// 确认按钮FilledButton(onPressed:(){finalname=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?'保存':'创建'),),constSizedBox(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参数即可:

Widget_buildCategoryItem(BuildContextcontext,MemoCategorycategory,CategoryProviderprovider){returnListTile(leading:Text(category.icon,style:constTextStyle(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)=>[constPopupMenuItem(value:'edit',child:Text('编辑')),constPopupMenuItem(value:'delete',child:Text('删除')),],),);}

删除分类 + 数据迁移

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

void_confirmDeleteCategory(BuildContextcontext,CategoryProviderprovider,MemoCategorycategory){showDialog(context:context,builder:(ctx)=>AlertDialog(title:constText('删除分类'),content:Text('确定要删除「${category.name}」分类吗?\n\n该分类下的备忘录将被移至"未分类"。',),actions:[TextButton(onPressed:()=>Navigator.pop(ctx),child:constText('取消'),),TextButton(onPressed:()async{awaitprovider.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:constText('删除'),),],),);}

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

Future<void>deleteCategory(int id)async{// 1. 将该分类下的所有备忘录设为"未分类"finaldb=DatabaseHelper.instance;finalmemos=awaitdb.getAllMemos();for(finalmemoinmemos){if(memo.categoryId==id){awaitdb.updateMemo(memo.copyWith(categoryId:null));}}// 2. 删除分类本身awaitdb.deleteCategory(id);// 3. 如果当前筛选器选中该分类,回退到"全部"if(_selectedCategoryId==id){_selectedCategoryId=null;}// 4. 重新加载awaitloadCategories();// 注意:需要通知 MemoProvider 也重新加载}

重名检测

新增分类时应检测同名:

String?_validateName(Stringname,CategoryProviderprovider,{MemoCategory?exclude}){if(name.trim().isEmpty)return'名称不能为空';finalexists=provider.categories.any((c)=>c.name.trim().toLowerCase()==name.trim().toLowerCase()&&c.id!=exclude?.id// 编辑时排除自身);if(exists)return'该分类名称已存在';returnnull;}

CategoryProvider 完整接口

classCategoryProviderextendsChangeNotifier{List<MemoCategory>_categories=[];List<MemoCategory>getcategories=>List.unmodifiable(_categories);Future<void>loadCategories()async{_categories=awaitDatabaseHelper.instance.getAllCategories();_categories.sort((a,b)=>a.sortOrder.compareTo(b.sortOrder));notifyListeners();}Future<void>addCategory(MemoCategorycategory)async{awaitDatabaseHelper.instance.insertCategory(category);awaitloadCategories();}Future<void>updateCategory(MemoCategorycategory)async{awaitDatabaseHelper.instance.updateCategory(category);awaitloadCategories();}Future<void>deleteCategory(int id)async{// 迁移关联备忘录到"未分类"finaldb=DatabaseHelper.instance;finalmemos=awaitdb.getAllMemos();for(finalmemoinmemos){if(memo.categoryId==id){awaitdb.updateMemo(memo.copyWith(categoryId:null));}}awaitdb.deleteCategory(id);awaitloadCategories();}}

鸿蒙兼容性

  • 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

http://www.rkmt.cn/news/1419259.html

相关文章:

  • 终极热键侦探:3分钟快速定位Windows快捷键占用程序
  • 基于YOLOv5与ESP32的智能垃圾分类系统:从AI视觉到硬件控制的完整实践
  • PyTorch如何重塑工程师思维:从动态图到模块化设计的工程实践
  • 告别XDMA限制:用开源Riffa框架在Linux下轻松搭建多通道PCIe DMA系统(Kintex-7实测)
  • AI重塑客户关系:从智能客服到个性化体验的七大核心优势
  • AI时代文案人价值重构:从文字工作者到策略沟通者
  • 面试不再慌!Java面试常见问题及解答
  • 别急着买机器人!用FANUC ROBOGUIDE的Handling Pro模块,零成本搞定涂胶方案验证
  • 保姆级教程:手动搞定Visual C++运行库,彻底解决Wireshark安装失败
  • 从MATLAB到FPGA板卡:手把手教你用COE文件为Xilinx FIR滤波器生成并加载系数
  • 告别高延迟!在Unity中低延时接入海康威视摄像头的两种实战方案(UMP vs SDK)
  • 第13篇|景点 POI 叠加:附近推荐如何和照片记忆共存
  • 病灶溯源:论波普尔证伪主义作为西方伪科学体系的逻辑毒根
  • 告别信号死角:手把手解读3GPP R17覆盖增强的三大核心黑科技(PUSCH/TBoMS/DMRS)
  • Heroku上快速部署PostGIS:从零构建地理空间数据库实战
  • 用Matlab和Robotics Toolbox搞定SCARA机器人建模:从DH参数到工作空间可视化(附KUKA KR 6 R500 Z200实例代码)
  • 从钽电容烧毁到系统稳定:我的电源滤波电路“踩坑”与修复实录
  • 从模拟退火到量子退火:一个物理学家的奇思妙想是如何变成D-Wave机器的
  • 告别手画UML!用IntelliJ IDEA Sequence Diagram插件自动生成时序图,还能导出PlantUML
  • BarTender 2022的Print Portal服务启动失败?手把手教你排查与修复
  • Franka机械臂开发避坑指南:解决‘Eigen/Core找不到’及CMakeLists配置的那些坑
  • 别再手动点开了!Element Table 数据刷新后自动保持展开项的两种实用方案
  • 别再乱选Canvas渲染模式了!从UI穿模到性能优化,一次讲透Unity三种模式的实战选择
  • 微信投票怎么操作,云帆投票(新手实操全流程) - 投票小程序
  • Keil浮动许可证停留时间优化与配置技巧
  • 在Ubuntu 18.04上用Docker Compose一键部署OAI 5G核心网(v1.4.0镜像版)
  • ADI DSP硬件工程师必看:14针JTAG接口那个被掰断的针脚,到底有什么用?
  • 从校园网到企业网:用Packet Tracer 8.2模拟真实办公网络隔离(VLAN+三层交换实战)
  • 别光看原理了!手把手教你用STM32CubeMX配置PLL,把8MHz晶振超频到72MHz
  • 【juc第三章】:AQS机制全解