如何使用CustomScrollView结合Slivers实现带月份联动锁定的水平日期滚动?
如何使用CustomScrollView结合Slivers实现带月份联动锁定的水平日期滚动?
嘿,我完全懂你要的这种效果——就是那种滑日期的时候,月份标题会跟着同步切换,到月与月的边界还能稳稳锁定住的日历滚动对吧?既然你明确要求必须用Slivers,那咱们就基于CustomScrollView来一步步搭,逻辑其实没那么复杂,拆解成几个核心步骤就行:
一、先把数据层理清楚
首先得把日期和对应的月份绑定好,不然滚动的时候没法快速判断当前属于哪个月。我一般会定义个轻量的模型类来关联:
// 绑定日期与所属月份的模型 class DateWithMonth { final DateTime date; final String monthName; // 比如"2024年6月" DateWithMonth(this.date, this.monthName); }
提前把全年的日期按月份分组初始化好,比如用循环生成1-12月的所有日期,给每个日期打上对应月份的标签,这样后面滚动时能直接取到月份信息。
二、搭好CustomScrollView的Sliver基础结构
因为要做水平滚动的日期,所以把CustomScrollView的scrollDirection设为Axis.horizontal,然后塞两个核心Sliver:
- 一个固定的月份标题SliverPersistentHeader:开启
pinned: true让它始终停在垂直方向的顶部,不会跟着水平滚动跑。 - 一个水平排列的日期列表SliverList:用来渲染所有日期项,宽度要和后面滚动计算的数值对应上。
基础结构代码大概是这样:
final ScrollController _scrollController = ScrollController(); String _currentMonth = "2024年6月"; List<DateWithMonth> _allDates = []; // 提前初始化好所有日期数据 @override Widget build(BuildContext context) { return CustomScrollView( controller: _scrollController, scrollDirection: Axis.horizontal, slivers: [ // 固定在顶部的月份标题头 SliverPersistentHeader( pinned: true, delegate: MonthHeaderDelegate( monthName: _currentMonth, height: 55, ), ), // 水平滚动的日期列表Sliver SliverList( delegate: SliverChildBuilderDelegate( (context, index) { final dateItem = _allDates[index]; return Container( width: 65, // 每个日期项的固定宽度 alignment: Alignment.center, padding: const EdgeInsets.symmetric(vertical: 12), child: Column( children: [ Text("${dateItem.date.day}", style: const TextStyle(fontSize: 16)), Text(dateItem.date.weekday == 7 ? "日" : "${dateItem.date.weekday}", style: const TextStyle(fontSize: 12, color: Colors.grey)), ], ), ); }, childCount: _allDates.length, ), ), ], ); } // 自定义月份Header的Delegate,控制SliverPersistentHeader的样式 class MonthHeaderDelegate extends SliverPersistentHeaderDelegate { final String monthName; final double height; MonthHeaderDelegate({required this.monthName, required this.height}); @override Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) { return Container( height: height, alignment: Alignment.center, color: Colors.white, child: Text(monthName, style: const TextStyle(fontSize: 19, fontWeight: FontWeight.w600)), ); } @override double get maxExtent => height; @override double get minExtent => height; @override bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate) { return (oldDelegate as MonthHeaderDelegate).monthName != monthName; } }
三、实现月份与日期的联动锁定(核心逻辑)
这步是关键——我们要监听滚动位置,判断当前显示的核心区域属于哪个月,并且在月与月的交界处实现“锁定”效果:
- 先在
initState里给滚动控制器加监听,记得 dispose 时销毁:
@override void initState() { super.initState(); _scrollController.addListener(_onScroll); // 这里可以加初始化_allDates的逻辑,比如生成2024年的所有日期 } @override void dispose() { _scrollController.dispose(); super.dispose(); }
- 实现
_onScroll方法,计算当前可视区域核心位置对应的月份,实现联动与锁定:
void _onScroll() { final offset = _scrollController.offset; final dateItemWidth = 65.0; // 和SliverList里的日期项宽度完全一致 final viewportWidth = MediaQuery.of(context).size.width; // 取可视区域的中间位置作为判断基准——这就是实现"锁定"的关键 final middleOffset = offset + viewportWidth / 2; final currentIndex = (middleOffset / dateItemWidth).floor(); // 边界判断,避免索引越界 if (currentIndex >= 0 && currentIndex < _allDates.length) { final targetMonth = _allDates[currentIndex].monthName; // 只有当月份真的变化时才更新,避免频繁刷新UI if (targetMonth != _currentMonth) { setState(() { _currentMonth = targetMonth; }); } } }
这个逻辑的妙处在于:只有当可视区域的中间点进入下一个月的日期时,月份才会切换。滚动到月中时,中间点始终在当前月内,月份就会保持锁定,完全符合你要的效果!
四、优化细节让体验更丝滑
- 给月份标题加个过渡动画:把
MonthHeaderDelegate里的Text用AnimatedSwitcher包裹,切换月份时加个淡入淡出效果,视觉上更流畅。 - 高亮当前日期:在SliverList的日期项里,判断如果是今天的日期,就给个不同的样式(比如红色文字+背景圆点)。
- 处理边缘空白:在日期列表的首尾加几个空白的占位Sliver,避免第一个月的日期贴死左边界,最后一个月的日期贴死右边界。
内容来源于stack exchange
相关产品推荐
相关产品推荐

