前言
此前我曾经实现并开源过一个时间选择器控件,这是曾经在某米工作时的一个收获,算是磨砺出了自己做一些纯flutter开发包的经验,但是到了新公司,我很快发现了一些问题,时间选择器,并不能满足所有场景。
比如说:
- 用户无法直观看到所以的日期是周几,无法直观的感受到当月有多少天;
- 有些页面不需要弹框来展示时间选择,只需要一个日历组件,需要平铺在页面上;
- 最重要的是无法看到农历、节日等
基于以上几点考量,我觉得从头开发一个日历组件,不在原来的代码上增加或者修改; 遵循着单一职责这个设计模式,我会从头创建一个新的flutter package omni_calendar_view
,用来实现上面所说的这些需求。
Omni Calendar View
话不多说,我们直接进入正题,看看这个
omni_calendar_view
是如何从 0 到 1 被构建出来的。
前期准备
在动手写代码之前,充分的调研是必不可少的。我不想闭门造车,而是希望站在巨人的肩膀上。
-
首先我参考了flutter官方的
showDatePicker
。它是最基础的日历视图,代码结构清晰,尤其是在日期计算(比如一个月有多少天,第一天是周几)方面,DateUtils
这个类给了我很多启发。但它的功能也确实基础,样式定制性差,更不用提农历和范围选择了。 -
又查看了大量的安卓、ios自带的添加提醒闹钟的时间选择。我发现原生组件的交互体验非常丝滑,动画过渡自然,这是我需要重点学习和实现的目标。
-
还查看了前端组件库
element-plus
、antd
等等。Web端的组件库功能非常强大,范围选择、多选、自定义单元格内容等,这些都极大地拓宽了我的思路,让我明确了日历组件可以具备哪些高级功能。 -
结合公司内部需求,最终确定了自己的设计方向:交互要流畅,功能要灵活,UI要简洁可控。
-
苹果

- element

需求分析
经过调研,我将需求拆分为了UI、逻辑和交互三个层面。
ui层
- 头部(Header) : 需要清晰地展示当前年月,并提供切换月份的入口。同时,要有一个开关来控制农历的显示/隐藏。当用户选择了日期或范围时,头部右侧需要实时反馈当前的选择结果。
- 星期行(WeekdayRow) : 标准的 "日" 到 "六" 或 "一" 到 "日" 的展示,需要支持国际化。
- 日期网格(CalendarGrid) : 这是核心区域,一个 6x7 的网格。单元格需要能展示公历、农历,并能根据不同状态(如:今天、选中、范围开始/结束、范围内、非本月)呈现不同的样式。
- 动态高度: 当显示农历时,每个单元格的高度需要增加,整个日历的高度也应该有平滑的动画过渡,而不是生硬地跳变。
逻辑层
- 头部(Header) : 需要清晰地展示当前年月,并提供切换月份的入口。同时,要有一个开关来控制农历的显示/隐藏。当用户选择了日期或范围时,头部右侧需要实时反馈当前的选择结果。
- 星期行(WeekdayRow) : 标准的 "日" 到 "六" 或 "一" 到 "日" 的展示,需要支持国际化。
- 日期网格(CalendarGrid) : 这是核心区域,一个 6x7 的网格。单元格需要能展示公历、农历,并能根据不同状态(如:今天、选中、范围开始/结束、范围内、非本月)呈现不同的样式。
- 动态高度: 当显示农历时,每个单元格的高度需要增加,整个日历的高度也应该有平滑的动画过渡,而不是生硬地跳变。
交互
- 左右滑动: 平滑地切换上一个月和下一个月。
- 点击(Tap) : 单击一个日期,实现单选功能。
- 长按(Long Press) : 这是实现范围选择的关键。我的设想是:当用户已经选择了一个日期后,长按另一个日期,可以快速创建或更新一个日期范围。再次长按范围的端点,可以取消范围选择。这种交互比常规的"点选开始-点选结束"更快捷。
- 外部控制: 能够通过 Controller 从外部代码命令日历跳转到指定日期,或者更新状态。
设计思路
有了明确的需求,接下来就是如何用代码实现。 对于日历这种状态相对集中的组件,我选择了 Flutter 内置的 ChangeNotifier
和 ChangeNotifierProvider
(或者直接用 AnimatedBuilder
)作为状态管理方案。它足够轻量,而且完全能满足需求,避免了引入更重的状态管理框架。
我创建了 OmniCalendarController
,它继承自 ChangeNotifier
,作为我们日历的"大脑"。
dart
class OmniCalendarController extends ChangeNotifier {
// 内部状态
DateTime _displayedDate;
DateTime? _selectedDate;
DateTime? _startDate;
DateTime? _endDate;
DateTime? _rangeAnchorDate; // 创建范围选择时的"锚点"日期
bool _showLunar = true;
SelectionType _selectionType = SelectionType.single;
// ... 省略构造函数和Getters ...
// 外部控制的方法
void jumpToDate(DateTime date) {
_displayedDate = DateTime(date.year, date.month, 1);
notifyListeners();
}
void toggleLunar(bool value) {
if (_showLunar != value) {
_showLunar = value;
notifyListeners();
}
}
void selectDate(DateTime date) {
_selectionType = SelectionType.single;
_selectedDate = date;
_startDate = null;
_endDate = null;
_rangeAnchorDate = null; // 清除范围选择的锚点
notifyListeners();
}
void handleLongPress(DateTime longPressedDate) {
// ... 核心的范围选择逻辑 ...
notifyListeners();
}
}
设计亮点:
- 单一数据源 : 所有的状态都由
OmniCalendarController
统一管理。UI 组件只需要监听这个 Controller 的变化并作出响应即可。 _rangeAnchorDate
: 这是实现"长按创建范围"交互的精髓。当用户单选了一个日期后,这个日期就成了"锚点"。之后长按任何其他日期,都会以这个锚点来创建或更新范围,逻辑非常清晰。notifyListeners()
: 每当状态发生改变(如切换月份、选择日期),就调用此方法通知所有监听者更新UI。
响应式UI:组合与动画
UI 的构建我遵循了组件化拆分的原则,将日历拆分为 CalendarHeader
、WeekdayRow
和 CalendarGrid
。
1. 月份切换:PageView
为了实现流畅的左右滑动切换月份,PageView
是不二之选。我设置了一个非常大的初始页码 _initialPage
,这样用户就可以近似"无限"地向前或向后滑动。
dart
// 在 _OmniCalendarViewState 中
late final PageController _pageController;
static const int _initialPage = 6000;
// ...
PageView.builder(
controller: _pageController,
onPageChanged: (page) {
final newMonth = _getDateForPage(page);
widget.controller.setDisplayedDate(newMonth); // 更新Controller
widget.onMonthChanged?.call(newMonth);
},
itemBuilder: (context, index) {
final month = _getDateForPage(index);
return CalendarGrid(...); // 每一页都是一个月的网格
},
),
2. 状态驱动的UI更新:AnimatedBuilder
CalendarHeader
和 CalendarGrid
都需要根据 OmniCalendarController
的状态来更新自己。这里我使用了 AnimatedBuilder
,它可以精准地只重建需要更新的Widget,性能更优。
dart
// 在 CalendarGrid 中
@override
Widget build(BuildContext context) {
// ...
return AnimatedBuilder(
animation: controller, // 监听controller
builder: (context, _) {
// 这里的代码会在 controller.notifyListeners() 时执行
final days = _generateMonthDays();
return GridView.builder(...);
},
);
}
3. 复杂的单元格样式:Stack
日期单元格的UI是最多变的。一个单元格可能同时是"范围的开始"和"今天"。为了优雅地处理这些样式的叠加,我用了 Stack
布局。
dart
// 在 _buildDayCell 方法中
Widget _buildDayCell(CalendarDay day, bool isLunarVisible) {
// ... 计算各种状态 ...
// 1. 确定背景"轨道"的样式 (rangeDecoration)
// 2. 确定文本颜色和样式
return GestureDetector(
onTap: () => onDateSelected(day.date),
onLongPress: () => onDateLongPressed(day.date),
child: Stack(
alignment: Alignment.center,
children: [
// 第一层:绘制连续的背景"轨道"
if (rangeDecoration != null)
Container(
margin: const EdgeInsets.symmetric(vertical: 2),
decoration: rangeDecoration,
),
// 第二层:在端点上绘制深色的圆形背景
if (day.isSelected) // isSelected 代表是范围的端点
Container(
decoration: BoxDecoration(
color: Colors.blue.shade300,
shape: BoxShape.circle,
),
),
// 第三层:放置文本内容(公历和农历)
Column(...),
],
),
);
}
- 时间范围时样式展示
通过 Stack
,我们可以清晰地分层绘制:最底层是范围选择的浅色"轨道"背景,中间层是端点的高亮圆形背景,最上层是日期文本。这样无论状态如何组合,显示效果都是正确且优雅的。
4. 动态高度:LayoutBuilder
+ AnimatedContainer
当显示/隐藏农历时,GridView
的 childAspectRatio
会变化,导致其总高度改变。为了让这个高度变化有动画效果,我用 LayoutBuilder
获取容器宽度,计算出目标高度,然后用 AnimatedContainer
包裹 PageView
,实现平滑的过渡。
dart
// 在 OmniCalendarView 的 build 方法中
LayoutBuilder(
builder: (context, constraints) {
// 动态计算高度
final double cellAspectRatio = isLunarVisible ? 0.9 : 1;
final double calendarHeight = (constraints.maxWidth / 7) * 6 / cellAspectRatio;
return AnimatedContainer(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
height: calendarHeight,
child: PageView.builder(...),
);
},
),
使用方式
得益于 OmniCalendarController
的设计,使用这个日历组件变得非常简单直观。
1. 基础使用
你只需要创建一个 OmniCalendarController
实例,然后把它传给 OmniCalendarView
即可。
dart
// 在你的StatefulWidget的State中
late final OmniCalendarController _controller;
@override
void initState() {
super.initState();
_controller = OmniCalendarController();
}
@override
Widget build(BuildContext context) {
return OmniCalendarView(
controller: _controller,
onDateSelected: (date) {
print('Selected date: $date');
},
onDateRangeSelected: (range) {
print('Selected range: ${range.start} to ${range.end}');
},
);
}
2. 外部控制
你可以持有 _controller
的引用,在任何地方调用它的方法来控制日历。比如,添加一个"回到今天"的按钮。
dart
ElevatedButton(
child: Text('回到今天'),
onPressed: () {
_controller.jumpToDate(DateTime.now());
_controller.selectDate(DateTime.now());
},
)
3. 自定义
组件提供了丰富的参数来自定义行为和外观。
dart
OmniCalendarView(
controller: _controller,
showSurroundingDays: false, // 不显示非本月的日期
showLunar: true, // 默认显示农历
locale: const Locale('en', 'US'), // 切换到英文环境
),
-
展示农历
-
英文模式
使用方式
yaml
omni_calendar_view: ^1.0.1
写在结尾
从一个简单的想法,到查阅资料、分析需求,再到设计架构、编写代码,omni_calendar_view
的诞生过程,是我对 Flutter 组件化开发的一次深度实践。
回头看,最重要的设计决策就是将状态与UI分离 。OmniCalendarController
作为状态的核心,让整个组件的逻辑变得内聚、清晰。而UI部分则可以专注于如何根据状态来高效、美观地渲染自己,无论是 PageView
的滑动、Stack
的分层渲染,还是 AnimatedContainer
的平滑过渡,都变得水到渠成。
当然,这个组件还有很多可以完善的地方,比如:
- 支持标记(Markers),在特定日期下显示小圆点或自定义图标。
- 更丰富的样式自定义API。
- 性能优化,尤其是在快速滑动大量月份时。
开源的意义在于分享和共建。我把它分享出来,希望能对有类似需求的同学有所帮助,也欢迎大家去我的 pubdev 上提issue或者star,一起让它变得更好!