Flutter 吸顶分组列表实战:语义桶分组 + 点击头平滑滚动
Flutter 吸顶分组列表实战语义桶分组 点击头平滑滚动作者FungLeo 适用Flutter 3.x场景时间类列表要按语义分组 组头吸顶 点组头跳到那一组看着简单细节全是坑。前言时间类的列表各位看官肯定都做过——待办、日程、消息、订单凡是带时间的产品迟早会提一句“能不能按时间分个组”我接到这个需求的时候心想这有啥难的。按日期排个序遍历一遍日期变了就插个头半小时的事儿。结果第一版跑出来界面上出现了两个本周。我盯着看了半天才反应过来我是按YYYY-MM-DD切的同一个自然周里有周三和周五两条数据那自然就切出来两组标题一算又都是本周可不就重复了么。这个 bug 一出整个列表看着就特别不专业。后来我把分组逻辑整个推翻重写改成语义桶才算真正对了。这篇就把这套东西完整记一下怎么分桶、吸顶头怎么做才是真吸顶、点组头怎么平滑滚过去。一、别按天切要按「语义桶」切我原来错在哪先看我第一版的错误思路// ❌ 按日期字符串分组同一周会切出好几组finalkey${t.year}-${t.month}-${t.day};问题在于用户脑子里的分组和日历上的分组压根不是一回事。用户想看到的是今天有几件事、明天有几件事、这周剩下的有几件事是个语义上的划分。而按天切切出来的是物理日期一周能给你切出七组标题还全叫本周。正确姿势先定桶再往桶里扔思路反过来先把桶定死每个桶全局只有一个头然后把每条数据算一下该扔进哪个桶。比如一个待办 / 已完成两个 Tab 的列表桶可以这么定从上到下待办未来方向 已逾期 / 今天 / 明天 / 后天 / 本周 / 下周 / 更晚 已完成过去方向镜像过来 今天 / 昨天 / 本周 / 上周 / 更早这么一来本周永远只有一个头多少条数据都往它里面塞重复头的问题从根上没了。三个函数搞定具体实现我拆成三个纯函数好写好测/// 1. 算这条数据属于哪个桶只判断哪一类不关心具体日期String_bucketKey(DateTimetime,DateTimenow){finaltodayDateTime(now.year,now.month,now.day);finaltargetDateTime(time.year,time.month,time.day);finaldifftarget.difference(today).inDays;if(diff0)returnoverdue;// 比今天早逾期if(diff0)returntoday;if(diff1)returntomorrow;if(diff2)returnday_after;// weekday: 周一1 ... 周日7所以本周还剩 (7 - weekday) 天if(diff7-today.weekday)returnthis_week;if(diff14-today.weekday)returnnext_week;returnlater;}/// 2. 给桶排序权重数字小的排前面const_bucketOrderString,int{overdue:0,today:1,tomorrow:2,day_after:3,this_week:4,next_week:5,later:6,};/// 3. 桶的中文标题const_bucketTitleString,String{overdue:已逾期,today:今天,tomorrow:明天,day_after:后天,this_week:本周,next_week:下周,later:更晚,};组装的时候就很轻松了ListGroupModel_buildGroups(ListItemModelitems,DateTimenow){finalmapString,ListItemModel{};for(finalitinitems){map.putIfAbsent(_bucketKey(it.time,now),()[]).add(it);}finalgroupsmap.entries// 空桶自然就不在 map 里不用额外过滤.map((e)GroupModel(key:e.key,title:_bucketTitle[e.key]!,// 桶内按时间升序items:e.value..sort((a,b)a.time.compareTo(b.time)),)).toList();// 桶之间按权重排groups.sort((a,b)_bucketOrder[a.key]!.compareTo(_bucketOrder[b.key]!));returngroups;}几个小讲究空桶不渲染。没数据的桶压根不进 map界面上也就不会出现一个光秃秃的下周头下面啥也没有。桶内升序桶间按权重。这两个排序是独立的别混在一起写。“已完成” Tab 就是把方向镜像一下把diff 0那一侧展开成昨天 / 本周 / 上周 / 更早逻辑完全对称改个映射表的事儿。那语义桶到底有哪些、分别对应什么判定我把它摊成一张表比看代码直白桶 key中文标题判定逻辑与今天的 diff 天数overdue已逾期diff 0today今天diff 0tomorrow明天diff 1day_after后天diff 2this_week本周diff 7 - weekdaynext_week下周diff 14 - weekdaylater更晚其余情况上面左边就是我第一版的惨状——按天切同一个自然周硬生生切出两个本周右边是语义桶一类一个头重复头从根上消失。二、吸顶头怎么做才是真吸顶这块我得多说两句因为我看到过不少写法其实是假吸顶——头会跟着列表一起滚走。做法 A真吸顶SliverMainAxisGroupSliverPersistentHeader想让组头在滚到顶部时钉住头本身就得是个Sliver而且要pinned: true。同时还得让它只在自己这一组的范围内钉住下一组上来的时候要被顶走——这就是SliverMainAxisGroupFlutter 3.16 之后可用干的活儿CustomScrollView(controller:_scrollController,slivers:[for(finalgroupin_groups)SliverMainAxisGroup(slivers:[SliverPersistentHeader(pinned:true,// 关键钉住delegate:_GroupHeaderDelegate(title:group.title,onTap:()_scrollToGroup(group.key),),),SliverList.builder(itemCount:group.items.length,itemBuilder:(ctx,i)_buildCard(group.items[i]),),],),],)_GroupHeaderDelegate要实现SliverPersistentHeaderDelegate最小实现长这样class_GroupHeaderDelegateextendsSliverPersistentHeaderDelegate{_GroupHeaderDelegate({requiredthis.title,requiredthis.onTap});finalStringtitle;finalVoidCallbackonTap;overridedoublegetminExtent36;overridedoublegetmaxExtent36;// 不需要伸缩两个值给一样即可overrideWidgetbuild(BuildContextcontext,double shrinkOffset,bool overlapsContent){returnGestureDetector(onTap:onTap,child:Container(// 必须给不透明背景否则吸顶时下面的内容会透上来糊成一片color:Colors.white,alignment:Alignment.centerLeft,padding:constEdgeInsets.symmetric(horizontal:16),child:Row(children:[Text(title,style:constTextStyle(fontWeight:FontWeight.w600)),constSpacer(),constIcon(Icons.keyboard_arrow_up,size:18),],),),);}overrideboolshouldRebuild(_GroupHeaderDelegate old)old.title!title;}做法 B不强求吸顶Column塞进SliverList说实话很多时候产品要的其实只是有个分组头 能点并不是真的非要钉在顶上。那就没必要上 Sliver 那套了直接把头和卡片拼成一个Column丢进SliverList就完事SliverList(delegate:SliverChildBuilderDelegate((ctx,i){finalgroup_groups[i];// 给每组第一张卡挂一个持久 key下面滚动要用finalfirstCardKey_groupKeys.putIfAbsent(group.key,GlobalKey.new);returnColumn(crossAxisAlignment:CrossAxisAlignment.stretch,children:[GroupStickyHeader(title:group.title,onTap:()_scrollToGroup(group.key),),KeyedSubtree(key:firstCardKey,child:_buildCards(group.items),),],);},childCount:_groups.length,),)但要说清楚这种写法组头是会跟着滚走的不会钉在顶部。我一开始就是这么写的还美滋滋地跟产品说吸顶做好了结果人家一滚动就发现头没了挺尴尬的哈。所以要真吸顶老老实实走做法 A。这里也有个取舍做法 B 把一整组塞进一个 item 里组内卡片是一次性全建出来的失去了列表懒加载的优势。组内数据量大的时候比如几百条还是得拆开或者走做法 A。两种做法到底差在哪一张表说清对比维度做法 ASliver 真吸顶做法 BColumn 假吸顶组头是否钉在顶部✅pinned: true滚到顶钉住❌ 跟着列表一起滚走组内是否懒加载✅SliverList.builder按需建❌ 整组一次性全建适用场景产品明确要求吸顶只要有头 能点即可三、点组头平滑滚动到那一组这个功能体验加分特别明显列表拉得老长点一下下周直接就跳过去了。实现靠Scrollable.ensureVisible前提是你得拿到目标位置的BuildContext——这就是上面那个GlobalKey的用途。// 按 group.key 复用 GlobalKey注意别在 build 里 newfinalMapString,GlobalKey_groupKeys{};void_scrollToGroup(StringgroupKey){finalctx_groupKeys[groupKey]?.currentContext;if(ctxnull)return;// 还没挂载出来就别滚了Scrollable.ensureVisible(ctx,alignment:0.08,// 停在距顶 8% 的位置别死贴边duration:constDuration(milliseconds:300),curve:Curves.easeInOut,);}两个参数值得说一下alignment: 0.080是贴着顶部1是贴着底部。我试了下正好贴顶的话组头会跟 AppBar 挤在一起看着很局促留 8% 的余量刚刚好。这个值各位看官按自己的顶部布局微调。durationcurve不给 duration 就是瞬间跳过去很生硬。300ms easeInOut是我觉得比较舒服的组合再长就显得拖沓了。关于GlobalKey的两个注意事项这俩我都栽过千万别在build方法里GlobalKey()新建。每次 build 都换一个新 key不仅currentContext抓不稳还会让整棵子树被重建滚动直接乱跳。用putIfAbsent按业务 key 缓存住这是关键。key 用完要清理。数据一刷新某些桶可能就空了但它的 key 还赖在 map 里。虽然一个 key 不占多少内存但架不住反复刷新还是顺手擦一下干净// 重建分组后把已经不存在的桶对应的 key 清掉_groupKeys.removeWhere((k,v)!_groups.any((g)g.keyk));平滑滚动那三个参数落张表方便各位照抄参数取值理由alignment0.080贴顶挤 AppBar留点余量刚合适duration300ms瞬间跳太生硬再长显拖沓curveCurves.easeInOut加减速自然不突兀四、剩下几个细节都是血泪1. 今天是什么时候的今天分桶依赖当前时间那这个时间什么时候取、多久重算一次是个真问题。我的做法是只在下拉刷新、页面重建、Tab 切换这几个时机重算滚动过程中绝对不重算。原因很简单——你要是搞个定时器每分钟重算一遍用户正好在跨零点那会儿滚列表整个分组啪地重排滚动位置直接飞了体验极差。宁可让用户看到几分钟的旧分组也别让列表在他手底下自己蹦迪。2. 组头一定要给不透明背景上面代码注释里提了一嘴这里再强调下。吸顶的时候组头是浮在内容上面的你要是没给背景色或者给了半透明下面滚过去的卡片就会从字缝里透出来糊成一坨灰特别难看。顺便给组头底部加一条 1px 的分割线吸顶时层次感会好很多。3. 组头别做太高吸顶头是常驻占屏的做个 32~40 的高度就够了。我见过做到 60 多的一屏的可用空间白白少一大块不值当。这几个细节我也顺手归成一张表细节正确做法踩坑后果今天何时算仅刷新 / 重建 / Tab 切换重算滚动中重排位置飞了组头背景不透明背景 底部 1px 分割线透出下方内容糊成一片组头高度32~40 足够60 白白占屏GlobalKey 清理重建后removeWhere清空桶反复刷新 key 堆积小结好啦吸顶分组列表这套东西就拆解完了。回头看这篇里技术含量最低但价值最高的其实是第一节那个**「语义桶」**的思路转变——从按数据的物理属性切转到按用户的心理预期切。这个转变一旦想通重复头的 bug 根本不会发生。后面的吸顶、滚动说白了都是查 API 的活儿。四条经验时间分组用语义桶一类一个头别按天切否则同周必出重复头。要真吸顶就得用SliverMainAxisGroupSliverPersistentHeader(pinned: true)Column塞SliverList那是假吸顶。点头跳转靠持久化的GlobalKeyScrollable.ensureVisiblekey 别在 build 里新建用完记得清。跨天重算只在刷新 / 重建时做滚动中重排是灾难。最后希望这篇文章能够对各位看官有所帮助。那么各位看官您做分组列表的时候是自己撸 Sliver还是直接上现成的 sticky header 库有没有更省事的路子欢迎在评论区交流。也请各位看官发财的小手点个小赞谢谢大家相关阅读Flutter 两个反直觉布局坑ListTile 水波纹 / VerticalDivider 踩坑实录Flutter 超长 StatefulWidget 拆分术part of extension on State 实战Flutter 骨架屏 Shimmer 实现不用 transform 的扫光法实战Flutter 401 自动刷新拦截器并发死锁_refreshQueue 死锁根治实录Flutter 可复用公共组件库设计与落地AppDialog/BottomSheet 等实战本文由 FungLeo 主导Deepseek 优化校阅转发请注明首发地址谢谢大家

相关新闻

如何用QtScrcpy实现电脑控制手机:跨平台Android投屏的完整解决方案

如何用QtScrcpy实现电脑控制手机:跨平台Android投屏的完整解决方案

如何用QtScrcpy实现电脑控制手机:跨平台Android投屏的完整解决方案 【免费下载链接】QtScrcpy Android real-time display control software 项目地址: https://gitcode.com/GitHub_Trending/qt/QtScrcpy 你是否曾想在电脑大屏幕上操作手机应用,却…

2026/10/24 1:57:36 阅读更多 →
货币政策工具创新与结构性优化实践

货币政策工具创新与结构性优化实践

1. 货币投放结构优化的核心逻辑 货币投放结构优化本质上是通过调整货币供给的流向、期限和工具组合,实现金融资源更精准地匹配实体经济需求。我在央行货币政策分析岗位工作期间,曾参与过多次结构性货币政策工具的设计,发现实际操作中需要重点…

2026/10/24 16:06:24 阅读更多 →
Codex写异步请求为什么容易出现状态覆盖?用请求隔离解决竞态条件

Codex写异步请求为什么容易出现状态覆盖?用请求隔离解决竞态条件

使用 Codex 修改前端项目时,有一类 Bug 很难在第一次测试中发现:页面功能看起来正常,接口也没有报错,但用户连续操作几次后,界面却显示了错误的数据。常见表现包括:用户快速切换两个账号,最后显…

2026/10/24 2:50:08 阅读更多 →

最新新闻

Electron桌面应用开发实战:从环境搭建到打包发布全流程

Electron桌面应用开发实战:从环境搭建到打包发布全流程

做前端这些年,我大部分时间都在跟浏览器标签页打交道,直到第一次把桌面应用跑起来,才发现原来自己一直被限制在地址栏和刷新按钮围起来的框框里。Electron这个词,最开始是在某段时间频繁出现在同事的讨论里,一个用HTML…

2026/10/24 11:09:31 阅读更多 →
滑盖智能锁T109深度解析:结构原理、安装调试与故障排查

滑盖智能锁T109深度解析:结构原理、安装调试与故障排查

简介:亚太天能T109滑盖智能锁产品说明书是一份PDF格式的官方技术文档,主要面向智能锁安装人员、终端用户及售后维护者,帮助快速掌握产品安装、配置与常见故障处理。全包仅1个PDF文件,大小4.37MB,内容结构清晰&#xff…

2026/10/24 17:47:00 阅读更多 →
PMSM弱磁控制原理与Simulink建模:高速AGV驱动系统设计指南

PMSM弱磁控制原理与Simulink建模:高速AGV驱动系统设计指南

上个月我在调试一台高速AGV的驱动系统,整车要求空载跑到8m/s,电机转速已经拉到额定点,驱动器的输出电压也顶到了母线电压极限附近。再往上踩速度指令,转速纹丝不动,三相电流反而开始震荡。那会儿我的第一反应就是&…

2026/10/24 18:34:36 阅读更多 →
C++核心知识点:从内存管理到STL的实战指南

C++核心知识点:从内存管理到STL的实战指南

C 这门语言我前前后后折腾了十几年,用它写过嵌入式驱动、做过游戏服务端,也搭过基础组件库。每次看到新人一头扎进"C 核心知识点"这个搜索词,我其实挺感慨的——这门语言的难点从来不是语法多,而是知识点之间的耦合太深…

2026/10/24 9:46:55 阅读更多 →
T109滑盖智能锁怎么装?安装调试与故障排查全攻略

T109滑盖智能锁怎么装?安装调试与故障排查全攻略

简介:亚太天能T109滑盖智能锁产品说明书以PDF格式发布,适合智能锁用户、安装人员与售后维修人员阅读。内容围绕T109系列指纹密码刷卡锁展开,先介绍前面板、后面板、锁体、机械钥匙、电池盒盖等部件名称与功能,再给出完整安装步骤&…

2026/10/24 3:09:39 阅读更多 →
LLM知识降熵:从信息垄断到理解权下放

LLM知识降熵:从信息垄断到理解权下放

你有没有过这种经历:翻开源码注释、论文摘要或者一份专业报告,每个字都认识,连在一起却像天书。想找人聊聊,身边要么没人懂,要么懂的懒得跟你解释。后来我试着把同一段内容丢给一个大语言模型,几秒钟之后&a…

2026/10/24 21:09:59 阅读更多 →

日新闻

周新闻

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/24 9:15:56 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/24 11:09:35 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/24 5:28:28 阅读更多 →