OmniCalendarView设计文档

控件概述

OmniCalendarView 是一个功能丰富、高度可定制的 Flutter 日历组件。它旨在提供一个灵活且强大的解决方案,以满足各种应用场景下的日历展示与交互需求,如日期单选、日期范围选择、酒店预订、行程规划等。

本文档将深入探讨 OmniCalendarView 的核心设计理念、架构拆解、关键技术实现以及详细的 API 使用说明,旨在为开发者提供一份清晰、全面的参考指南,帮助您更好地理解、使用甚至扩展该组件。

核心特性:

  • 状态与视图分离:采用 Controller 模式,将日历的业务逻辑与 UI 渲染彻底分离。
  • 高性能月份切换:基于 PageView.builder 实现无限滚动的月份视图,保证流畅的滑动体验。
  • 灵活的选择模式:内置支持**单选(Single)范围选择(Range)**两种模式,并可动态切换。
  • 丰富的定制选项:支持显示/隐藏非本月日期、显示/隐藏农历、自定义国际化等。
  • 精美的 UI 交互:为范围选择提供了平滑的背景“轨道”效果,并为农历显隐切换提供了动态高度的动画。
  • 清晰的回调机制:提供完整的事件回调,如日期选择、范围选择、月份变更等,便于与外部业务逻辑集成。

设计理念与目标

在设计 OmniCalendarView 之初,我确立了以下几个核心目标:

  • 解耦(Decoupling):日历组件的状态管理(如当前显示的月份、选中的日期等)通常比较复杂。如果将这些状态直接耦合在 StatefulWidgetState 中,会导致组件内部逻辑混乱,难以维护和测试。因此,将状态管理逻辑抽离到独立的 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,并确保 _endDatenull
    • 范围模式 (SelectionType.range):逻辑相对复杂,需要处理多种情况:
      1. 选择起点:当 _startDatenull(首次选择)或 _endDate 不为 null(已完成一轮选择,开始新一轮)时,将当前点击的日期设为新的 _startDate,并清空 _endDate
      2. 选择终点:当 _startDate 已存在但 _endDatenull 时,将当前点击的日期设为终点。此时,需要比较新点击的日期和 _startDate 的先后顺序,自动将较早的日期设为 _startDate,较晚的设为 _endDate,以确保范围的有效性。
      3. 取消选择:如果用户再次点击已选中的 _startDate,当前逻辑是什么都不做。也可以根据需求修改为取消选择。

视图容器:OmniCalendarView

这是暴露给开发者的主 Widget。它是一个 StatefulWidget,主要负责组装 CalendarHeaderWeekdayRowPageView,并管理 PageController 的生命周期。

  • 核心技术:PageView 实现无限滚动
    • 我们不直接使用 DateTime 作为 PageView 的索引,而是采用了一种相对偏移量的策略。
    • 设定一个非常大的初始页码 _initialPage(例如 6000),这为用户提供了足够向前和向后滑动的空间,在体验上实现了“无限滚动”。
    • 通过 _calculatePageForDate(DateTime date)_getDateForPage(int page) 这两个辅助方法,实现了 DateTimePageView 索引之间的双向转换。其核心是计算目标日期距离某个基准日期(如 1970年1月)所经过的月份数。
  • 关键实现:状态同步 (_onControllerUpdate)
    OmniCalendarViewState 会监听 OmniCalendarController 的变化。当控制器状态更新时(例如外部调用了 controller.jumpToDate()),_onControllerUpdate 方法会被触发。
    • 该方法会计算控制器中 displayedDate 对应的 PageView 页面索引。
    • 如果计算出的目标页面与当前 PageView 正在显示的页面不一致,它会调用 _pageController.jumpToPage() 来强制 PageView 跳转到正确的月份。
    • 重要细节:在调用 _pageController.pagejumpToPage 等方法前,必须使用 _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)
    1. 计算网格范围:首先确定当月的第一天是星期几,然后向前推算出网格左上角的第一个日期。
    2. 生成42天:从这个起始日期开始,循环42次,生成一个完整的 6x7 网格所需的所有 CalendarDay 对象。
    3. 应用状态:在生成每个 CalendarDay 对象时,会从 controller 中读取当前的选中状态(selectedDateselectedDateRange),并与当前日期进行比较,从而设置 day.isSelectedday.isStartDateday.isEndDateday.isInRange 等布尔值。这一步是连接数据逻辑和视觉表现的关键。
  • 关键实现:日期单元格渲染 (_buildDayCell)
    这是日历视觉效果的核心。它使用 Stack 来分层绘制复杂的日期单元格UI。
    • 第一层(最底层):范围背景“轨道”
      • 如果 day.isInRangetrue,则绘制一个浅色的背景。
      • 通过判断 day.isStartDateday.isEndDate,为这个背景设置不同的 BorderRadius
        • 起点:左侧为半圆 (BorderRadius.horizontal(left: ...)).
        • 终点:右侧为半圆 (BorderRadius.horizontal(right: ...)).
        • 中间:矩形 (BorderRadius.zero).
        • 单日范围:完整的圆形。
      • 这个巧妙的设计创造了连贯、平滑的范围选择视觉效果。
    • 第二层:端点圆形背景
      • 如果 day.isSelectedtrue(即它是范围的起点或终点),则在“轨道”之上再绘制一个深色的、完全不透明的圆形背景,以突出显示端点。
    • 第三层(最顶层):文本内容
      • 显示公历日期和农历日期。
      • 文本的颜色和样式会根据日期的状态(是否选中、是否在范围内、是否是今天、是否是当前月)动态改变,提供了丰富的视觉反馈。

顶部导航:CalendarHeader

CalendarHeader 负责展示当前年月、提供月份切换按钮,以及显示当前的选择结果。

  • 它同样使用 AnimatedBuilder 监听 controller,以便在 displayedDateselectedDateRange 改变时能自动更新显示的文本。
  • 它将月份切换的逻辑委托给了 OmniCalendarView 传入的回调函数 (onPreviousMonthPressed, onNextMonthPressed),这些函数内部会调用 _pageControllerpreviousPagenextPage 方法,实现了视图和控制逻辑的解耦。
  • 农历开关 (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? 仅在范围模式下有效。返回当前选中的日期范围。如果只选择了起点,返回的 DateTimeRangestartend会是同一个日期。
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 模式不仅使代码结构更加清晰,也为开发者提供了强大的外部控制能力。

Libraries

omni_calendar_view