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

前言

此前我曾经实现并开源过一个时间选择器控件,这是曾经在某米工作时的一个收获,算是磨砺出了自己做一些纯flutter开发包的经验,但是到了新公司,我很快发现了一些问题,时间选择器,并不能满足所有场景。

比如说:

  • 用户无法直观看到所以的日期是周几,无法直观的感受到当月有多少天;
  • 有些页面不需要弹框来展示时间选择,只需要一个日历组件,需要平铺在页面上;
  • 最重要的是无法看到农历、节日等

基于以上几点考量,我觉得从头开发一个日历组件,不在原来的代码上增加或者修改; 遵循着单一职责这个设计模式,我会从头创建一个新的flutter package omni_calendar_view,用来实现上面所说的这些需求。

Omni Calendar View

话不多说,我们直接进入正题,看看这个 omni_calendar_view 是如何从 0 到 1 被构建出来的。

前期准备

在动手写代码之前,充分的调研是必不可少的。我不想闭门造车,而是希望站在巨人的肩膀上。

  • 首先我参考了flutter官方的showDatePicker。它是最基础的日历视图,代码结构清晰,尤其是在日期计算(比如一个月有多少天,第一天是周几)方面,DateUtils 这个类给了我很多启发。但它的功能也确实基础,样式定制性差,更不用提农历和范围选择了。

  • 又查看了大量的安卓、ios自带的添加提醒闹钟的时间选择。我发现原生组件的交互体验非常丝滑,动画过渡自然,这是我需要重点学习和实现的目标。

  • 还查看了前端组件库element-plusantd等等。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 内置的 ChangeNotifierChangeNotifierProvider(或者直接用 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 的构建我遵循了组件化拆分的原则,将日历拆分为 CalendarHeaderWeekdayRowCalendarGrid

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

CalendarHeaderCalendarGrid 都需要根据 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

当显示/隐藏农历时,GridViewchildAspectRatio 会变化,导致其总高度改变。为了让这个高度变化有动画效果,我用 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,一起让它变得更好!

相关推荐
YGY Webgis糕手之路2 小时前
OpenLayers 综合案例-轨迹回放
前端·经验分享·笔记·vue·web
90后的晨仔2 小时前
🚨XSS 攻击全解:什么是跨站脚本攻击?前端如何防御?
前端·vue.js
Ares-Wang2 小时前
JavaScript》》JS》 Var、Let、Const 大总结
开发语言·前端·javascript
90后的晨仔2 小时前
Vue 模板语法完全指南:从插值表达式到动态指令,彻底搞懂 Vue 模板语言
前端·vue.js
你的人类朋友2 小时前
❤️‍🔥微服务的拆分策略
后端·微服务·架构
德育处主任2 小时前
p5.js 正方形square的基础用法
前端·数据可视化·canvas
烛阴2 小时前
Mix - Bilinear Interpolation
前端·webgl
90后的晨仔2 小时前
Vue 3 应用实例详解:从 createApp 到 mount,你真正掌握了吗?
前端·vue.js
德育处主任3 小时前
p5.js 矩形rect绘制教程
前端·数据可视化·canvas
前端工作日常3 小时前
我学习到的babel插件移除Flow 类型注解效果
前端·babel·前端工程化