Flutter - 手搓一个日历组件,集成单日选择、日期范围选择、国际化、农历和节气显示

前言

此前我曾经实现并开源过一个时间选择器控件,这是曾经在某米工作时的一个收获,算是磨砺出了自己做一些纯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层

  1. 头部(Header) : 需要清晰地展示当前年月,并提供切换月份的入口。同时,要有一个开关来控制农历的显示/隐藏。当用户选择了日期或范围时,头部右侧需要实时反馈当前的选择结果。
  2. 星期行(WeekdayRow) : 标准的 "日" 到 "六" 或 "一" 到 "日" 的展示,需要支持国际化。
  3. 日期网格(CalendarGrid) : 这是核心区域,一个 6x7 的网格。单元格需要能展示公历、农历,并能根据不同状态(如:今天、选中、范围开始/结束、范围内、非本月)呈现不同的样式。
  4. 动态高度: 当显示农历时,每个单元格的高度需要增加,整个日历的高度也应该有平滑的动画过渡,而不是生硬地跳变。

逻辑层

  1. 头部(Header) : 需要清晰地展示当前年月,并提供切换月份的入口。同时,要有一个开关来控制农历的显示/隐藏。当用户选择了日期或范围时,头部右侧需要实时反馈当前的选择结果。
  2. 星期行(WeekdayRow) : 标准的 "日" 到 "六" 或 "一" 到 "日" 的展示,需要支持国际化。
  3. 日期网格(CalendarGrid) : 这是核心区域,一个 6x7 的网格。单元格需要能展示公历、农历,并能根据不同状态(如:今天、选中、范围开始/结束、范围内、非本月)呈现不同的样式。
  4. 动态高度: 当显示农历时,每个单元格的高度需要增加,整个日历的高度也应该有平滑的动画过渡,而不是生硬地跳变。

交互

  1. 左右滑动: 平滑地切换上一个月和下一个月。
  2. 点击(Tap) : 单击一个日期,实现单选功能。
  3. 长按(Long Press) : 这是实现范围选择的关键。我的设想是:当用户已经选择了一个日期后,长按另一个日期,可以快速创建或更新一个日期范围。再次长按范围的端点,可以取消范围选择。这种交互比常规的"点选开始-点选结束"更快捷。
  4. 外部控制: 能够通过 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();
  }
}

设计亮点:

  1. 单一数据源 : 所有的状态都由 OmniCalendarController 统一管理。UI 组件只需要监听这个 Controller 的变化并作出响应即可。
  2. _rangeAnchorDate: 这是实现"长按创建范围"交互的精髓。当用户单选了一个日期后,这个日期就成了"锚点"。之后长按任何其他日期,都会以这个锚点来创建或更新范围,逻辑非常清晰。
  3. 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,一起让它变得更好!

相关推荐
IT_陈寒3 小时前
Redis内存暴涨,没想到是AOF重写捣的鬼
前端·人工智能·后端
ikoala3 小时前
让 Codex 自己查账:一个省 token 的小技巧
前端·后端·ai编程
ikoala3 小时前
AI 写页面像说明书,修 Bug 像贴膏药?把这两句话写进 AGENTS.md
前端·javascript·ai编程
好的佩奇4 小时前
Dart 之扩展方法
android·flutter·dart
去伪存真5 小时前
AI Agent Harness 深度测评与技术选型实战
前端·agent
好的佩奇6 小时前
蓝牙之经典蓝牙
android·flutter·蓝牙
isboyjc8 小时前
Lomo Agent:面向创作型、持续协作 Agent 的架构设计与工程实践
人工智能·架构·agent
FRing038 小时前
vue2干货总结7(自定义指令、插槽、单页应用程序、路由的使用(VueRouter))
开发语言·前端·vue.js·vue2
传奇开心果编程9 小时前
【Flutter踩坑排雷】第19课:路由硬编码坑|字符串路由书写错误,页面跳转失败排查
android·flutter·ios·移动开发·跨平台·ui框架·声明式ui
聪明的笨猪猪9 小时前
AI应用架构选型:架构师的核心决策困境与权衡
经验分享·面试·架构