鸿蒙Flutter实战:置顶功能的数据库与UI实现
前言备忘录列表的第 0 条和第 1 条拥有最高的视觉优先级——用户打开应用第一眼看到的就是它们。如果用户有一条本周待办汇总的备忘录每次都滚动到底部去找体验是很糟糕的。置顶功能正是解决这个问题的——把某条备忘录钉在列表最上方无论列表怎么排序它始终排在第一。微信聊天、邮件客户端、备忘录应用都有这个功能。本文拆解鸿蒙 Flutter 备忘录中置顶功能的完整实现从模型层的布尔字段到数据库的排序逻辑到 UI 的视觉区分和交互触发。项目仓库todo_flutter_harmony模型层isPinned 字段classMemo{finalint?id;finalStringtitle;finalStringcontent;finalint?categoryId;finalbool isPinned;// ← 核心字段finalDateTimecreatedAt;finalDateTime?updatedAt;constMemo({this.id,requiredthis.title,this.content,this.categoryId,this.isPinnedfalse,requiredthis.createdAt,this.updatedAt,});MemocopyWith({int?id,String?title,String?content,int?categoryId,bool?isPinned,DateTime?createdAt,DateTime?updatedAt,}){returnMemo(id:id??this.id,title:title??this.title,content:content??this.content,categoryId:categoryId??this.categoryId,isPinned:isPinned??this.isPinned,createdAt:createdAt??this.createdAt,updatedAt:updatedAt??this.updatedAt,);}MapString,dynamictoMap(){id:id,title:title,content:content,categoryId:categoryId,isPinned:isPinned?1:0,// JSON 中存 0/1createdAt:createdAt.millisecondsSinceEpoch,updatedAt:updatedAt?.millisecondsSinceEpoch,};factoryMemo.fromMap(MapString,dynamicmap)Memo(id:map[id],title:map[title]??,content:map[content]??,categoryId:map[categoryId],isPinned:(map[isPinned]??0)1,createdAt:DateTime.fromMillisecondsSinceEpoch(map[createdAt]),updatedAt:map[updatedAt]!null?DateTime.fromMillisecondsSinceEpoch(map[updatedAt]):null,);}Provider 中的排序逻辑排序规则很简单先按isPinned降序true 在前再按createdAt降序新的在前。classMemoProviderextendsChangeNotifier{ListMemo_allMemos[];ListMemogetfilteredMemos{varresultListMemo.from(_allMemos);// 分类过滤if(_categoryFilter!null){resultresult.where((m)m.categoryId_categoryFilter).toList();}// 搜索过滤if(_searchQuery.isNotEmpty){resultresult.where((m)m.title.toLowerCase().contains(_searchQuery.toLowerCase())||m.content.toLowerCase().contains(_searchQuery.toLowerCase())).toList();}// 排序置顶优先 时间倒序result.sort((a,b){if(a.isPinned!b.isPinned){returna.isPinned?-1:1;// true false → true 排前面}returnb.createdAt.compareTo(a.createdAt);// 新的排前面});returnresult;}FuturevoidtogglePin(int id)async{finalmemo_allMemos.firstWhere((m)m.idid);finalupdatedmemo.copyWith(isPinned:!memo.isPinned,updatedAt:DateTime.now(),);awaitDatabaseHelper.instance.updateMemo(updated);awaitloadMemos();}FuturevoidloadMemos()async{_allMemosawaitDatabaseHelper.instance.getAllMemos();notifyListeners();}}关键细节togglePin调用copyWith创建一个新对象不可变模式然后通过DatabaseHelper持久化最后重新加载数据。这种方式保证数据一致性——UI 总是反映存储层的真实状态。UI 中的置顶视觉区分置顶的备忘录需要在视觉上与普通备忘录有所区别但又不应该过于突兀classMemoCardextendsStatelessWidget{finalMemomemo;constMemoCard({super.key,requiredthis.memo});overrideWidgetbuild(BuildContextcontext){returnAnimatedContainer(duration:constDuration(milliseconds:300),decoration:BoxDecoration(borderRadius:BorderRadius.circular(12),border:memo.isPinned?Border.all(color:constColor(0xFF4DB6AC).withOpacity(0.4),width:1):null,boxShadow:memo.isPinned?[BoxShadow(color:constColor(0xFF4DB6AC).withOpacity(0.08),blurRadius:8,offset:constOffset(0,2),),]:null,),child:Card(elevation:memo.isPinned?2:1,shape:RoundedRectangleBorder(borderRadius:BorderRadius.circular(12)),child:Padding(padding:constEdgeInsets.all(14),child:Row(crossAxisAlignment:CrossAxisAlignment.start,children:[// 置顶图钉图标if(memo.isPinned)Padding(padding:constEdgeInsets.only(right:8,top:2),child:Icon(Icons.push_pin,size:16,color:constColor(0xFF4DB6AC).withOpacity(0.7),),),// 内容区Expanded(child:Column(crossAxisAlignment:CrossAxisAlignment.start,children:[Text(memo.title,style:constTextStyle(fontSize:16,fontWeight:FontWeight.w600,),),if(memo.content.isNotEmpty)...[constSizedBox(height:4),Text(memo.content,maxLines:2,overflow:TextOverflow.ellipsis,style:TextStyle(fontSize:14,color:Colors.grey.shade600,),),],],),),],),),),);}}视觉设计要点图钉图标仅置顶项显示淡化颜色70% 透明度避免抢眼边框40% 透明度的主题色边框暗示这是个特殊卡片阴影8% 透明度的主题色光晕微微提亮elevation从 1 升到 2轻微的抬起感触发置顶/取消置顶的交互在滑动操作组件中置顶按钮SlideActionTile(leftActions:[SlideAction(label:memo.isPinned?取消置顶:置顶,icon:memo.isPinned?Icons.push_pin_outlined:Icons.push_pin,color:Colors.orange,onTap:()context.readMemoProvider().togglePin(memo.id!),),],// ...)点击后Provider 的togglePin切换isPinned状态 →loadMemos()重新加载并排序 →notifyListeners()重建列表。置顶的卡片瞬间移动到列表最上方视觉上同时展示图钉图标、边框和阴影效果。置顶数量的限制要不要限制置顶数量有些应用限制最多 3 条置顶防止置顶滥用。是否加这个限制取决于产品需求FuturevoidtogglePin(int id)async{finalmemo_allMemos.firstWhere((m)m.idid);// 如果要置顶检查当前置顶数量if(!memo.isPinned){finalpinnedCount_allMemos.where((m)m.isPinned).length;if(pinnedCount5){// 超出了可以弹窗提醒或直接拒绝return;}}// 正常切换...}鸿蒙 Flutter 备忘录应用目前没有加这个限制用户数据量本就不大但如果用户量增长这是一个值得考虑的防御性设计。DatabaseHelper 中的更新操作classDatabaseHelper{FuturevoidupdateMemo(Memomemo)async{finalindex_cache[memos]!.indexWhere((m)m[id]memo.id);if(index!-1){_cache[memos]![index]memo.toMap();await_persistToFile();}}Futurevoid_persistToFile()async{finaldirawaitStoragePath.getAppDir();finalfileFile($dir/.memo_app/data.json);awaitfile.writeAsString(jsonEncode(_cache));}}由于使用的是纯 JSON 文件存储更新操作就是找到缓存中的对应项 → 替换 → 全量写入文件。对于个人备忘录这种数据量通常几十到几百条这个性能开销完全可以接受。鸿蒙兼容性置顶功能完全是数据层的逻辑——一个布尔字段的切换和排序规则的变化。不涉及任何平台 API在 Android、iOS、鸿蒙 OHOS 上行为一致。总结置顶功能的实现可以分解为三层数据层isPinned: boolJSON 中存 0/1逻辑层排序规则isPinned DESC, createdAt DESCUI 层图钉图标 边框 阴影三重视觉区分滑动操作触发togglePin整个功能的核心代码不超过 20 行但对用户体验的提升是显著的。完整项目代码见todo_flutter_harmony