公司动态

Flutter 吸顶分组列表实战:语义桶分组 + 点击头平滑滚动

📅 2026/8/10 15:29:40
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 优化校阅转发请注明首发地址谢谢大家