OmniCalendarView设计文档
控件概述
OmniCalendarView 是一个功能丰富、高度可定制的 Flutter 日历组件。它旨在提供一个灵活且强大的解决方案,以满足各种应用场景下的日历展示与交互需求,如日期单选、日期范围选择、酒店预订、行程规划等。
本文档将深入探讨 OmniCalendarView 的核心设计理念、架构拆解、关键技术实现以及详细的 API 使用说明,旨在为开发者提供一份清晰、全面的参考指南,帮助您更好地理解、使用甚至扩展该组件。
核心特性:
- 状态与视图分离:采用
Controller模式,将日历的业务逻辑与 UI 渲染彻底分离。 - 高性能月份切换:基于
PageView.builder实现无限滚动的月份视图,保证流畅的滑动体验。 - 灵活的选择模式:内置支持**单选(Single)和范围选择(Range)**两种模式,并可动态切换。
- 丰富的定制选项:支持显示/隐藏非本月日期、显示/隐藏农历、自定义国际化等。
- 精美的 UI 交互:为范围选择提供了平滑的背景“轨道”效果,并为农历显隐切换提供了动态高度的动画。
- 清晰的回调机制:提供完整的事件回调,如日期选择、范围选择、月份变更等,便于与外部业务逻辑集成。
设计理念与目标
在设计 OmniCalendarView 之初,我确立了以下几个核心目标:
- 解耦(Decoupling):日历组件的状态管理(如当前显示的月份、选中的日期等)通常比较复杂。如果将这些状态直接耦合在
StatefulWidget的State中,会导致组件内部逻辑混乱,难以维护和测试。因此,将状态管理逻辑抽离到独立的Controller中是我们的首要设计原则。这使得View层只负责渲染,而Controller负责处理所有业务逻辑和状态变更,实现了清晰的“关注点分离”。 - 性能(Performance):日历需要支持用户在年份和月份之间自由穿梭。如果一次性构建所有月份的视图,会造成巨大的性能开销。我们采用
PageView.builder作为核心的月份滚动容器。它是一种懒加载机制,只会在需要时构建当前、前一个和后一个月份的视图,从而实现了“无限”的、高性能的平滑滚动。 - 可控性(Controllability):在实际应用中,我们常常需要从外部代码(例如,一个按钮点击事件)来控制日历的行为,比如跳转到指定日期。通过暴露
OmniCalendarController,外部代码可以轻松持有该控制器实例,并调用其提供的方法(如jumpToDate)来命令式地操作日历,极大地增强了组件的灵活性和集成能力。 - 用户体验(User Experience):关注 UI 的细节。例如,在进行范围选择时, 给起始和结束日期加上圆形背景是不够的,我们通过精细的
BoxDecoration计算,实现了覆盖整个范围的、两端为半圆的平滑“轨道”背景。此外,当切换农历显示时,日历网格的高度会发生变化,我们使用AnimatedContainer来实现高度的平滑过渡动画,避免了界面的生硬跳动。
核心架构拆解
OmniCalendarView 的架构主要由四个核心部分组成:OmniCalendarController(状态控制器)、OmniCalendarView(视图容器)、CalendarGrid(月份网格)和 CalendarHeader(顶部栏)。
状态管理核心:OmniCalendarController
OmniCalendarController 是整个组件的“大脑”,它继承自 ChangeNotifier,是所有日历状态的单一数据源 (Single Source of Truth)。
- 角色:
- 存储所有核心状态,如当前显示的月份 (
_displayedDate)、选择的开始/结束日期 (_startDate,_endDate)、选择模式 (_selectionType)、是否显示农历 (_showLunar) 等。 - 封装所有业务逻辑,例如处理日期点击事件 (
selectDate),并根据当前的选择模式(单选/范围)更新内部状态。 - 通过
notifyListeners()方法,在状态发生变化时通知所有监听者(主要是UI部分)进行更新。
- 存储所有核心状态,如当前显示的月份 (
- 关键实现:
selectDate逻辑
这是控制器中最核心的业务逻辑。- 单选模式 (
SelectionType.single):逻辑非常简单,每次点击都直接覆盖_startDate,并确保_endDate为null。 - 范围模式 (
SelectionType.range):逻辑相对复杂,需要处理多种情况:- 选择起点:当
_startDate为null(首次选择)或_endDate不为null(已完成一轮选择,开始新一轮)时,将当前点击的日期设为新的_startDate,并清空_endDate。 - 选择终点:当
_startDate已存在但_endDate为null时,将当前点击的日期设为终点。此时,需要比较新点击的日期和_startDate的先后顺序,自动将较早的日期设为_startDate,较晚的设为_endDate,以确保范围的有效性。 - 取消选择:如果用户再次点击已选中的
_startDate,当前逻辑是什么都不做。也可以根据需求修改为取消选择。
- 选择起点:当
- 单选模式 (
视图容器:OmniCalendarView
这是暴露给开发者的主 Widget。它是一个 StatefulWidget,主要负责组装 CalendarHeader、WeekdayRow 和 PageView,并管理 PageController 的生命周期。
- 核心技术:
PageView实现无限滚动- 我们不直接使用
DateTime作为PageView的索引,而是采用了一种相对偏移量的策略。 - 设定一个非常大的初始页码
_initialPage(例如 6000),这为用户提供了足够向前和向后滑动的空间,在体验上实现了“无限滚动”。 - 通过
_calculatePageForDate(DateTime date)和_getDateForPage(int page)这两个辅助方法,实现了DateTime和PageView索引之间的双向转换。其核心是计算目标日期距离某个基准日期(如 1970年1月)所经过的月份数。
- 我们不直接使用
- 关键实现:状态同步 (
_onControllerUpdate)
OmniCalendarView的State会监听OmniCalendarController的变化。当控制器状态更新时(例如外部调用了controller.jumpToDate()),_onControllerUpdate方法会被触发。- 该方法会计算控制器中
displayedDate对应的PageView页面索引。 - 如果计算出的目标页面与当前
PageView正在显示的页面不一致,它会调用_pageController.jumpToPage()来强制PageView跳转到正确的月份。 - 重要细节:在调用
_pageController.page或jumpToPage等方法前,必须使用_pageController.hasClients进行检查。这是因为PageController只有在被附加到PageView上之后,其page属性才可用。在 Widget 构建的早期阶段或某些特殊情况下,controller可能尚未准备好,这个检查可以有效避免运行时错误。
- 该方法会计算控制器中
- 动态高度动画
农历的显示会增加每个日期单元格的高度,从而影响整个日历网格的总高度。- 我们使用
LayoutBuilder来获取组件的可用宽度。 - 根据宽度和是否显示农历 (
isLunarVisible) 计算出日历网格所需的目标高度。 - 将
PageView包裹在AnimatedContainer中,并将计算出的目标高度赋值给height属性。当isLunarVisible状态改变时,AnimatedContainer会自动在旧高度和新高度之间创建一个平滑的过渡动画。
- 我们使用
月份网格:CalendarGrid
CalendarGrid 负责渲染单个完整的月份视图(通常是一个 6x7 的网格)。
- 角色:
- 接收一个
month(DateTime) 参数,表示需要渲染哪个月份。 - 使用
AnimatedBuilder监听controller的变化。这意味着即使CalendarGrid本身是StatelessWidget,它也能在控制器状态(如选中的日期)改变时自动重建,从而更新UI,这是一种比setState更高效的局部刷新方式。
- 接收一个
- 关键实现:日期生成与状态应用 (
_generateMonthDays)- 计算网格范围:首先确定当月的第一天是星期几,然后向前推算出网格左上角的第一个日期。
- 生成42天:从这个起始日期开始,循环42次,生成一个完整的 6x7 网格所需的所有
CalendarDay对象。 - 应用状态:在生成每个
CalendarDay对象时,会从controller中读取当前的选中状态(selectedDate或selectedDateRange),并与当前日期进行比较,从而设置day.isSelected、day.isStartDate、day.isEndDate、day.isInRange等布尔值。这一步是连接数据逻辑和视觉表现的关键。
- 关键实现:日期单元格渲染 (
_buildDayCell)
这是日历视觉效果的核心。它使用Stack来分层绘制复杂的日期单元格UI。- 第一层(最底层):范围背景“轨道”
- 如果
day.isInRange为true,则绘制一个浅色的背景。 - 通过判断
day.isStartDate和day.isEndDate,为这个背景设置不同的BorderRadius:- 起点:左侧为半圆 (
BorderRadius.horizontal(left: ...)). - 终点:右侧为半圆 (
BorderRadius.horizontal(right: ...)). - 中间:矩形 (
BorderRadius.zero). - 单日范围:完整的圆形。
- 起点:左侧为半圆 (
- 这个巧妙的设计创造了连贯、平滑的范围选择视觉效果。
- 如果
- 第二层:端点圆形背景
- 如果
day.isSelected为true(即它是范围的起点或终点),则在“轨道”之上再绘制一个深色的、完全不透明的圆形背景,以突出显示端点。
- 如果
- 第三层(最顶层):文本内容
- 显示公历日期和农历日期。
- 文本的颜色和样式会根据日期的状态(是否选中、是否在范围内、是否是今天、是否是当前月)动态改变,提供了丰富的视觉反馈。
- 第一层(最底层):范围背景“轨道”
顶部导航:CalendarHeader
CalendarHeader 负责展示当前年月、提供月份切换按钮,以及显示当前的选择结果。
- 它同样使用
AnimatedBuilder监听controller,以便在displayedDate或selectedDateRange改变时能自动更新显示的文本。 - 它将月份切换的逻辑委托给了
OmniCalendarView传入的回调函数 (onPreviousMonthPressed,onNextMonthPressed),这些函数内部会调用_pageController的previousPage和nextPage方法,实现了视图和控制逻辑的解耦。 - 农历开关 (
CustomSwitch) 的onChanged回调直接调用controller.toggleLunar(value),再次体现了视图驱动控制器改变状态的设计模式。
API 参考文档
OmniCalendarView
这是需要在应用中使用的主要日历组件。
构造函数
const OmniCalendarView({
super.key,
this.selectionType = SelectionType.single,
required this.controller,
this.showSurroundingDays = true,
this.showLunar = false,
this.locale = const Locale('zh', 'CN'),
this.onHeaderTap,
this.onDateSelected,
this.onDateRangeSelected,
this.onMonthChanged,
});
参数详解
| 参数名 | 类型 | 是否必须 | 默认值 | 描述 |
|---|---|---|---|---|
controller |
OmniCalendarController |
是 | - | 日历的状态控制器,用于外部控制和状态查询。 |
selectionType |
SelectionType |
否 | SelectionType.single |
选择模式,可以是单选 (single) 或范围选择 ( range)。 |
showSurroundingDays |
bool |
否 | true |
是否在网格中显示非本月的日期(即上个月末和下个月初的几天)。 |
showLunar |
bool |
否 | false |
是否显示农历。此设置仅在中文语言环境 (locale) 下生效。 |
locale |
Locale |
否 | Locale('zh', 'CN') |
语言环境,用于格式化日期和星期文本。 |
onHeaderTap |
VoidCallback? |
否 | null |
当用户点击顶部的 "年-月" 文本时触发的回调。 |
onDateSelected |
ValueChanged<DateTime>? |
否 | null |
单选模式下,当一个日期被选择时触发的回调。 |
onDateRangeSelected |
ValueChanged<DateTimeRange>? |
否 | null |
范围模式下,当一个日期范围被完整选择(即起点和终点都已确定)时触发的回调。 |
onMonthChanged |
ValueChanged<DateTime>? |
否 | null |
当用户通过左右滑动切换月份时触发的回调,返回新月份的第一天。 |
OmniCalendarController
日历的状态管理和逻辑控制中心。
构造函数
OmniCalendarController({
DateTime? initialDate,
SelectionType initialSelectionType = SelectionType.single,
})
参数详解
| 参数名 | 类型 | 是否必须 | 默认值 | 描述 |
|---|---|---|---|---|
initialDate |
DateTime? |
否 | DateTime.now() |
日历初始显示的日期。同时,在单选模式下,它也会成为初始选中的日期。 |
initialSelectionType |
SelectionType |
否 | SelectionType.single |
控制器初始的选择模式。 |
属性 (Getters)
| 属性名 | 类型 | 描述 |
|---|---|---|
displayedDate |
DateTime |
当前日历网格正在显示的月份(始终为该月的第一天)。 |
selectedDate |
DateTime? |
仅在单选模式下有效。返回当前选中的单个日期,否则返回 null。 |
selectedDateRange |
DateTimeRange? |
仅在范围模式下有效。返回当前选中的日期范围。如果只选择了起点,返回的 DateTimeRange 的 start 和 end会是同一个日期。 |
showLunar |
bool |
当前是否显示农历。 |
selectionType |
SelectionType |
当前的选择模式。 |
方法
| 方法签名 | 描述 |
|---|---|
void jumpToDate(DateTime date) |
以编程方式控制日历跳转到包含指定 date的月份视图。 |
void setSelectionType(SelectionType type) |
动态改变日历的选择模式。切换模式可能会重置当前的选中状态。 |
void selectDate(DateTime date) |
(内部使用为主) 供UI组件调用,用于更新日期选择状态。外部也可以调用此方法来模拟用户点击。 |
void toggleLunar(bool value) |
(内部使用为主) 切换农历的显示状态。 |
void setDisplayedDate(DateTime date) |
(内部使用为主) 设置当前显示的月份。 |
使用案例
- 初始化,配置库
dependencies:
flutter:
sdk: flutter
omni_date_picker: ^1.0.4 # 时间选择器库 非必须
omni_calendar_view: ^1.0.6 # 日历组件 必须
unified_popups: ^1.0.3. # 弹框库 非必须
- 在项目中单独为日历组件封装一个组件
CalendarView
class CalendarView extends StatelessWidget {
final OmniCalendarController controller;
final SelectionType? selectionType;
final bool showLunar;
final Locale locale;
final bool showSurroundingDays;
const CalendarView({
super.key,
required this.controller,
required this.showLunar,
required this.locale,
required this.showSurroundingDays,
this.selectionType
});
@override
Widget build(BuildContext context) {
return Container(
padding: EdgeInsets.symmetric(horizontal: 24 , vertical: 24),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16)
),
child: OmniCalendarView(
selectionType: selectionType ?? SelectionType.single , // 指定日历类型 单选、范围选择
controller: controller,
showLunar: showLunar,
showSurroundingDays: showSurroundingDays,
locale: locale,
onMonthChanged: (date) {
Logger.info('Calendar', '月份切换到: ${date.year}-${date.month}');
},
onHeaderTap: () async {
OmniDatePicker.show( // 这里是点击头部会唤起时间选择器,若不需该功能,传null或不传即可
context: context,
mode: PickerMode.filterDate,
filterType: FilterType.month,
locale: locale,
initialDateTime: DateTime.now(),
onConfirm: (dateTime) {
int year = dateTime["year"];
int month = dateTime["month"];
controller.jumpToDate(DateTime(year , month));
},
);
},
),
);
}
}
- 统一管理日历弹框组件
class CalendarPopupUtils {
/// 唤起单选日历的异步函数
/// 返回 Future<DateTime?>,当用户点击确定时返回选中的日期,否则返回 null。
static Future<DateTime?> showCalendar(
BuildContext context,
OmniCalendarController controller,
) {
final Completer<DateTime?> completer = Completer<DateTime?>();
// 确保传入的 controller 是单选模式
// assert(controller.selectionMode == OmniSelectionMode.single, "Controller must be in single selection mode");
PopupManager.show( // 使用弹框展示日历,根据业务需求,弹框并非必须
PopupConfig(
onDismiss: () {
if (!completer.isCompleted) {
completer.complete(null);
}
},
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CalendarView(
controller: controller,
showLunar: false,
locale: const Locale('en', 'EN'),
showSurroundingDays: true,
),
Padding(
padding: const EdgeInsets.only(left: 10, right: 10, bottom: 10),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () {
PopupManager.hideLast();
if (!completer.isCompleted) {
completer.complete(null);
}
},
child: const Text("Cancel"),
),
const SizedBox(width: 8),
FilledButton(
onPressed: () {
PopupManager.hideLast();
if (!completer.isCompleted) {
// 对于单选,返回 selectedDate
completer.complete(controller.selectedDate);
}
},
child: const Text("Confirm"),
),
],
),
),
],
),
),
),
);
return completer.future;
}
/// 唤起日期范围选择日历的异步函数
/// 返回 Future<DateTimeRange?>,当用户点击确定时返回选中的日期范围,否则返回 null。
static Future<DateTimeRange?> showCalendarRange(
BuildContext context,
OmniCalendarController controller,
) {
final Completer<DateTimeRange?> completer = Completer<DateTimeRange?>();
PopupManager.show(
PopupConfig(
onDismiss: () {
if (!completer.isCompleted) {
completer.complete(null);
}
},
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CalendarView(
controller: controller,
showLunar: false,
selectionType: SelectionType.range,
locale: const Locale('en', 'EN'),
showSurroundingDays: true,
),
Padding(
padding: const EdgeInsets.only(left: 10, right: 10, bottom: 10),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () {
PopupManager.hideLast();
if (!completer.isCompleted) {
completer.complete(null);
}
},
child: const Text("Cancel"),
),
const SizedBox(width: 8),
FilledButton(
onPressed: () {
PopupManager.hideLast();
if (!completer.isCompleted) {
// 对于范围选择,返回 selectedDateRange
completer.complete(controller.selectedDateRange);
}
},
child: const Text("Confirm"),
),
],
),
),
],
),
),
),
);
return completer.future;
}
}
- 业务代码中的使用
final OmniCalendarController controller = OmniCalendarController(initialDate: DateTime.now());
final DateTime? selectedDate = await CalendarPopupUtils.showCalendar(context, controller);
...
总结
OmniCalendarView 通过清晰的架构分层、高效的状态管理和对细节的关注,提供了一个健壮且易于扩展的日历解决方案。其核心的 Controller-View 模式不仅使代码结构更加清晰,也为开发者提供了强大的外部控制能力。