前言
大家好,作为一名Flutter开发者,在日常的业务开发中,我们几乎不可避免地会和"时间"打交道。Flutter官方提供了 showDatePicker
和 showTimePicker
,它们在标准场景下表现良好。但现实世界的业务需求,往往比标准要复杂得多。
你是否也遇到过这些令人头疼的场景?
- 产品经理:"我需要一个能按'周'筛选报表的功能,用户一点就能选定一整周。"
- UI设计师:"这个日期选择器的样式和我们App整体风格不符,标题和选项样式能改吗?"
- 测试同学:"这个设置预约结束时间的页面,居然能选择比开始时间还早的时间,这是个Bug!"
- 你自己:"这个项目里写好的日期组件,下个项目又得复制粘贴一遍,改个需求要同步好几个地方,太痛苦了!"
每当遇到这些需求,我们可能会去 pub.dev
上寻找第三方库,但它们要么功能过于臃肿,要么定制性不足。于是,我下定决心,从零到一打造一个既能满足复杂业务场景,又具备高度可定制性的日期选择器组件。
今天,我想把这个过程中的思考、设计和实现全部分享出来,希望能对大家有所启发。
先来看看最终的成果



场景驱动:一个好的组件,源于对真实需求的洞察
在动手写代码之前,我首先梳理了开发中遇到的最典型的几个场景,这些场景成为了组件设计的基石。
场景一:数据报表的周期筛选
在一个数据分析或后台管理类的App中,最常见的功能就是按时间维度筛选数据。用户需要快速切换"本周"、"上月"、"今年"的数据。
- 痛点:官方选择器只能选单日,无法满足"周"或"月"的范围选择。
- 解决方案 :组件需要支持一种
filterDate
(筛选) 模式,并能指定FilterType
为week
,month
, 或year
。返回的结果不再是单个DateTime
,而是一个时间范围,如{"startTime": ..., "endTime": ...}
。
场景二:预约与排期
在预约会议、预订酒店或课程排期等功能中,用户需要选择一个开始时间和结束时间。
- 痛点:需要确保结束时间不能早于开始时间,这需要额外的逻辑校验。
- 解决方案 :组件需要支持
startTime
和endTime
两种模式。在endTime
模式下,可以传入一个startTime
参数,组件内部会自动处理约束,用户无法选择早于开始时间的日期。
场景三:生日或提醒设置
用户设置生日时,通常只能选择过去的日期;设置未来的提醒时,又不能选择已经过去的时间。
- 痛点:需要根据业务限制可选的日期范围。
- 解决方案 :组件提供一个
showLaterTime: false
的布尔参数。当它为false
时,所有未来的日期、时间都将变为不可选状态,从源头上避免了用户误操作。
这三个场景几乎涵盖了各种情况,如果有特殊情况,还可以再加。
设计思路:三层分离,让复杂变得简单
有了明确的目标,接下来就是架构设计。我遵循了几个核心原则,力求让组件结构清晰、易于维护和扩展。
分层与解耦 (Layering & Decoupling)
我将整个组件拆分成了三个层次,各司其职:
- 底层引擎 -
CustomPicker
:这是最核心的滚动选择器。我将它设计成一个纯粹的"哑组件"(Dumb Component),它不关心任何业务逻辑,只负责根据传入的startValue
,endValue
和initialValue
来提供滚动列表。最关键的是,它通过一个itemBuilder
回调函数,将列表项的UI构建权完全交还给调用方。这使得CustomPicker
具备了极高的复用性,就像ListView.builder
一样灵活。 - 业务逻辑层 -
EditDate
,FilterDate
,EditTime
:这些组件是"聪明"的,它们基于CustomPicker
构建,并封装了各自的业务逻辑。例如,EditDate
负责处理年、月、日之间的联动(比如切换到2月,天数最多只有29天),并处理与startTime
和now
之间的复杂约束。FilterDate
则负责生成周、月、年的数据列表。 - 入口/展示层 -
AppDatePicker
:这是暴露给开发者的最顶层API,我运用了"外观模式"(Facade Pattern)。开发者无需关心内部有多少个复杂的子组件,只需要调用AppDatePicker.show()
这一个静态方法。它内部使用Overlay
来创建一个全局浮层,将整个组件呈现在屏幕上,并管理其生命周期,极大地降低了使用门槛。
第一层:底层引擎 - CustomPicker
这是整个选择器的基石,一个纯粹的滚动选择器引擎。我给它的定位是**"哑组件" (Dumb Component)**------它对业务一无所知,不关心自己滚动的是年份、月份,还是商品分类。它的唯一职责,就是提供一个可滚动的列表,并把如何构建列表项的权力完全交出去。
【代码细节】 看看它的构造函数,你会发现它有多么"纯粹":
dart
// in custom_picker.dart
class CustomPicker extends StatefulWidget {
final int startValue;
final int endValue;
final int initialValue;
final ValueChanged<int> onValueChanged;
final Widget Function(BuildContext context, int value, bool isSelected) itemBuilder;
const CustomPicker({
super.key,
required this.startValue,
required this.endValue,
required this.initialValue,
required this.onValueChanged,
required this.itemBuilder, // <-- 核心中的核心
});
// ...
}
它的核心是 itemBuilder
,一个回调函数。在 build
方法中,它就像 ListView.builder
一样,将构建UI的责任委托给了调用方:
ini
// in custom_picker.dart build method
ListWheelScrollView.useDelegate(
// ...
onSelectedItemChanged: (index) {
final newValue = widget.startValue + index;
widget.onValueChanged(newValue);
},
childDelegate: ListWheelChildBuilderDelegate(
builder: (context, index) {
final int value = widget.startValue + index;
final bool isSelected = (value == _selectedValue);
// 将构建任务完全交给外部
return widget.itemBuilder(context, value, isSelected);
},
childCount: itemCount,
),
)
这种设计使得 CustomPicker
具备了极高的复用性,为上层业务组件的灵活实现打下了坚实的基础。
第二层:业务逻辑层 - EditDate & FilterDate & EditTime
这一层是**"聪明"**的,它负责具体的业务。它会"使用"底层的 CustomPicker
来实现具体的功能,比如年份选择、月份选择等。
【代码细节】 以 EditDate
组件中的年份选择器为例,看看它是如何"消费" CustomPicker
的:
less
// in edit_date.dart
Widget _buildYearPicker() {
return Expanded(
child: CustomPicker(
startValue: widget.startTime?.year ?? 1970, // 提供业务相关的起始值
endValue: _now.year, // 提供业务相关的结束值
initialValue: _selectedDate.year, // 提供业务相关的初始值
onValueChanged: (year) { // 提供业务相关的回调逻辑
_handleDateChanged(DateTime(
year,
_selectedDate.month,
_selectedDate.day,
));
},
itemBuilder: (context, value, isSelected) { // 提供业务相关的UI构建逻辑
// 这里,我们告诉 CustomPicker 如何显示一个"年份"
return _buildPickerItem(value.toString(), "年", isSelected);
},
),
);
}
看到了吗?EditDate
为 CustomPicker
注入了灵魂:它定义了滚动的范围(从1970年到当前年份),定义了选中值变化后的行为(更新日期状态),最重要的是,它通过 itemBuilder
定义了每一项应该如何显示(一个数字加上一个"年"字)。
这就是组合优于继承 的典型实践。我们没有去继承 CustomPicker
,而是将它作为一个积木块来使用,极大地提高了灵活性。
第三层:入口/展示层 - AppDatePicker
这一层是暴露给最终开发者的API,我运用了**"外观模式"(Facade Pattern)**。开发者无需关心内部有多少个复杂的子组件,只需要调用 AppDatePicker.show()
这一个静态方法。
【代码细节】 show
方法的实现,完美地隐藏了内部的复杂性:
scss
// in date_picker.dart
class AppDatePicker {
static void show({
required BuildContext context,
required AppDatePickerMode mode,
required Function(dynamic) onConfirm,
// ... 其他所有配置参数
}) {
// 1. 获取 OverlayState
OverlayState overlayState = Overlay.of(context);
late OverlayEntry overlayEntry;
// 2. 创建一个 OverlayEntry
overlayEntry = OverlayEntry(
builder: (context) {
// 3. 在 Overlay 中构建我们的主组件 DatePickerOverlay
return Material(
color: Colors.transparent,
child: DatePickerOverlay(
// 4. 将所有配置参数传递下去
mode: mode,
onConfirm: (selectedTime) {
onConfirm(selectedTime);
overlayEntry.remove(); // 确认后移除自身
},
onCancel: () {
// ...
overlayEntry.remove(); // 取消后移除自身
},
// ...
),
);
},
);
// 5. 将 Entry 插入到 Overlay 中,显示UI
overlayState.insert(overlayEntry);
}
}
用户只需要调用一个简单的方法,而创建浮层、传递参数、构建内部UI、处理销毁等一系列繁琐的工作,都被优雅地封装在了内部。
统一状态管理 (Centralized State Management)
核心逻辑:健壮的状态管理
在 EditDate
中,处理年月日时分之间的联动和约束是最大的挑战。如果使用多个分散的 int
变量(如 selectedYear
, selectedMonth
)来管理状态,代码会变得混乱不堪,极易出错(比如从31号的月份切换到2月,忘记修正天数)。
我的解决方案是:使用一个单一的 DateTime
对象 _selectedDate
作为唯一数据源 (Single Source of Truth)。
【代码细节】 追踪一次"年份"选择的完整流程:
- 用户滚动年份选择器
CustomPicker
的 onValueChanged
回调被触发,并返回新的年份值,例如 2024
。
- 调用统一处理入口
_handleDateChanged
在年份选择器的回调中,我们不直接 setState
,而是用新的年份值和 _selectedDate
中旧的月、日、时、分信息,创建一个临时的、可能非法的 DateTime
对象 ,然后将它传递给 _handleDateChanged
方法。
dart
// in edit_date.dart -> _buildYearPicker -> onValueChanged
(year) {
_handleDateChanged(DateTime(
year,
_selectedDate.month, // 使用旧的月份
_selectedDate.day, // 使用旧的日期
_selectedDate.hour,
_selectedDate.minute,
));
}
- 核心约束方法
_constrainDate
_handleDateChanged
的核心是调用 _constrainDate
。这个方法是所有约束逻辑的"守门员",它接收一个日期,然后像流水线一样对它进行检查和修正,确保最终输出的日期绝对合法。
dart
// in edit_date.dart
DateTime _constrainDate(DateTime date) {
DateTime constrained = date;
// 关卡1: 检查是否早于允许的最小开始时间
if (widget.startTime != null && constrained.isBefore(widget.startTime!)) {
constrained = widget.startTime!;
}
// 关卡2: 如果不允许选择未来,检查是否晚于当前时间
if (!widget.showLaterTime && constrained.isAfter(_now)) {
constrained = _now;
}
// 关卡3: 修正日!这是最关键的一步
// 比如,如果之前是 2023-03-31,用户切换年份到 2024(闰年)
// 再切换月份到 2 月,此时 date 可能是 2024-02-31,这是非法的。
final daysInMonth = DateTime(constrained.year, constrained.month + 1, 0).day;
if (constrained.day > daysInMonth) {
// 将日期修正为当月的最后一天,如 2024-02-29
constrained = DateTime(constrained.year, constrained.month, daysInMonth,
constrained.hour, constrained.minute);
}
return constrained;
}
- 更新状态并回调
_handleDateChanged
拿到经过 _constrainDate
"净化"后的合法日期后,才调用 setState
更新 _selectedDate
,并触发 widget.onDateChanged
通知外部。
dart
// in edit_date.dart
void _handleDateChanged(DateTime newDate) {
final constrainedDate = _constrainDate(newDate);
if (constrainedDate != _selectedDate) {
setState(() {
_selectedDate = constrainedDate;
});
}
widget.onDateChanged(constrainedDate);
}
通过这种方式,所有状态的变更都流经一个统一的、可预测的管道,无论用户如何操作,UI上显示的和最终回调出去的日期永远是合法的。这极大地提升了组件的健壮性。
走向灵活:API的扩展与定制化
一个组件封装好后,新的需求总会接踵而至。我的同事在使用时提出了两个非常好的建议:"默认的标题'编辑日期'太通用了,我想改成'请选择生日'怎么办?"、"这个周选择的UI样式,和我们项目的设计稿不太一样,能自定义吗?"
这促使我对API进行了扩展,让组件在保持易用性的同时,拥有了"逃生舱"式的定制能力。
我新增了两个可选参数:title
和 weekItemBuilder
。
title: String?
这个参数非常直接,允许调用者传入一个字符串来覆盖默认的标题。如果不传,组件会像以前一样根据 mode
自动显示"编辑日期"、"筛选时间"等。
dart
// 使用自定义标题
AppDatePicker.show(
context: context,
mode: AppDatePickerMode.editDate,
title: "请选择您的生日", // 自定义标题
onConfirm: (dateTime) { /* ... */ },
);
weekItemBuilder: WeekItemBuilder?
这是一个更强大的定制接口。它是一个函数类型,定义如下:
typedef WeekItemBuilder = Widget Function(BuildContext context, String weekData, bool isSelected);
在 filterDate
的 week
模式下提供了这个构建器,组件将放弃默认的周列表UI,转而使用函数来构建每一项。这给予了完全的控制权,可以实现任何想要的设计。
dart
// 使用自定义的周列表项UI
AppDatePicker.show(
context: context,
mode: AppDatePickerMode.filterDate,
filterType: FilterType.week,
onConfirm: (dateRange) { /* ... */ },
weekItemBuilder: (context, weekData, isSelected) {
// weekData 的格式: "2024.01.01~2024.01.07(本周)"
final dateRange = weekData.split('(').first;
return Card(
color: isSelected ? Colors.blue.shade50 : Colors.white,
child: Center(
child: Text(
dateRange,
style: TextStyle(
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
color: isSelected ? Colors.blue.shade800 : Colors.grey.shade700,
),
),
),
);
},
);
通过这两个简单的参数,组件的灵活性得到了质的飞跃。
如何使用:一份简明的API指南
经过封装,使用这个组件变得异常简单。
第一步:导入
- 通过复制粘贴,将我的代码复制过去
arduino
import 'package:app_date_picker/date_picker.dart';
- 作为私有 Git 仓库包(强烈建议使用这个方式)
在需要使用这个日期选择器的任何一个 Flutter 项目中,打开 pubspec.yaml 文件
yaml
dependencies:
flutter:
sdk: flutter
# 从 Git 仓库引用日期选择器包
app_date_picker:
git:
url: https://gitee.com/lin-ruiqiang/app_date_picker
ref: v1.0.2 # 稳定版本
后续我也会继续积极维护这个插件 在这里欢迎各位给我点⭐,提出建议 gitee源码
第二步:调用 AppDatePicker.show()
less
AppDatePicker.show(
context: context,
mode: AppDatePickerMode.endTime, // 选择模式
title: "请选择结束日期", // [可选] 自定义标题
initialDateTime: endTime, // 初始日期
startTime: startTime, // 约束条件:不能早于开始时间
showLaterTime: false, // 约束条件:不能选择未来
onConfirm: (date) { // 确认回调
setState(() {
endTime = date;
});
},
onChange: (date) { // [可选] 实时变化回调
print("当前选择: $date");
},
onCancel: () { // [可选] 取消回调
print("用户取消了选择");
},
);
核心API参数一览:
参数 | 类型 | 描述 |
---|---|---|
context |
BuildContext |
构建上下文 |
mode |
AppDatePickerMode |
核心: 选择器模式 |
onConfirm |
Function(dynamic) |
核心: 点击"确定"的回调 |
title |
String? |
自定义选择器顶部的标题 |
weekItemBuilder |
WeekItemBuilder? |
自定义周筛选模式下的列表项UI |
initialDateTime |
DateTime? |
初始显示的日期时间 |
filterType |
FilterType? |
当 mode 为 filterDate 时,指定筛选类型 |
startTime |
DateTime? |
可选的最小开始时间,用于限制选择范围 |
showLaterTime |
bool? |
是否允许选择未来的时间 |
使用示例
dart
SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
// --- 示例 : 编辑完整日期 ---
const Text("选择结果:", style: TextStyle(fontWeight: FontWeight.bold)),
Text(_selectedDateTime.toString()),
const SizedBox(height: 8),
ElevatedButton(
onPressed: () {
// 3. 调用 show 方法
AppDatePicker.show(
context: context,
mode: AppDatePickerMode.editDate,
initialDateTime: _selectedDateTime,
onConfirm: (dateTime) {
// 4. 在回调中用 setState 更新状态
setState(() {
_selectedDateTime = dateTime;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('已选择: $dateTime')),
);
},
);
},
child: const Text('编辑日期 (年/月/日 时:分)'),
),
const Divider(height: 32),
// --- 示例 : 按周筛选 ---
const Text("筛选结果:", style: TextStyle(fontWeight: FontWeight.bold)),
Text(_filterResult.toString()),
const SizedBox(height: 8),
ElevatedButton(
onPressed: () {
AppDatePicker.show(
context: context,
mode: AppDatePickerMode.filterDate,
filterType: FilterType.week, // 指定筛选类型为周
onConfirm: (result) {
setState(() {
_filterResult = result;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('已筛选: $result')),
);
},
);
},
child: const Text('筛选时间 (按周)'),
),
const Divider(height: 32),
// --- 示例 : 自定义标题 ---
ElevatedButton(
onPressed: () {
AppDatePicker.show(
context: context,
mode: AppDatePickerMode.editDate,
title: "请选择一个幸运日", // [NEW] 使用自定义标题
onConfirm: (dateTime) {},
);
},
child: const Text('编辑日期 (自定义标题)'),
),
// --- 示例:编辑提醒时间
ElevatedButton(
onPressed: () {
AppDatePicker.show(
context: context,
mode: AppDatePickerMode.editTime,
onConfirm: (dateTime) {
print("dateTime:$dateTime");
},
);
},
child: const Text('提醒日期'),
),
// --- 示例 : 自定义周筛选UI ---
ElevatedButton(
onPressed: () {
AppDatePicker.show(
context: context,
mode: AppDatePickerMode.filterDate,
filterType: FilterType.week,
title: "选择统计周期",
onConfirm: (result) {},
weekItemBuilder: (context, weekData, isSelected) {
final dateRange = weekData.split('(').first;
final isThisWeek = weekData.contains('本周');
return Card(
color: isSelected ? Colors.blue.shade50 : Colors.white,
elevation: isSelected ? 2 : 0,
child: Center(
child: Row(
mainAxisAlignment:MainAxisAlignment.center,
children: [
Text(
dateRange,
textAlign: TextAlign.center,
style: TextStyle(
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
color: isSelected ? Colors.blue.shade800 : Colors.grey.shade700,
),
),
if(isThisWeek)
const Icon(Icons.star, color: Colors.amber, size: 16)
],
),
),
);
},
);
},
child: const Text('筛选时间-周 (自定义UI)'),
),
// --- 示例 : 设置开始和结束时间 ---
const Text("开始时间:", style: TextStyle(fontWeight: FontWeight.bold)),
Text(_startTime.toLocal().toString().split(' ')[0]),
ElevatedButton(
onPressed: () {
AppDatePicker.show(
context: context,
mode: AppDatePickerMode.startTime,
initialDateTime: _startTime,
showLaterTime: false, // 开始时间通常不能是未来
onConfirm: (date) {
setState(() {
_startTime = date;
// 如果结束时间早于新的开始时间,则重置结束时间
if (_endTime.isBefore(_startTime)) {
_endTime = _startTime;
}
});
},
);
},
child: const Text('选择开始时间'),
),
const SizedBox(height: 16),
const Text("结束时间:", style: TextStyle(fontWeight: FontWeight.bold)),
Text(_endTime.toLocal().toString().split(' ')[0]),
ElevatedButton(
onPressed: () {
AppDatePicker.show(
context: context,
mode: AppDatePickerMode.endTime,
initialDateTime: _endTime,
startTime: _startTime, // 关键:传入开始时间作为限制
onConfirm: (date) {
setState(() {
_endTime = date;
});
},
);
},
child: const Text('选择结束时间'),
),
],
),
),
),
后续具体逻辑,拿到参数如何使用,由自己业务决定
总结与展望
从最初被复杂需求困扰,到如今拥有一个结构清晰、功能强大且高度可定制的日期选择器组件,这个过程让我对组件化开发的理解更进了一步。一个好的组件不仅仅是代码的堆砌,更是对业务场景的深刻洞察和对软件设计原则的灵活运用。
通过分层解耦 、统一状态管理 和提供定制化接口,我们成功地打造了一个能够从容应对多变需求的"瑞士军刀"。
当然,这个组件还有可以完善的空间,比如更丰富的主题定制(Theming)、国际化(i18n)支持等,这些都将是我后续迭代的方向。
希望这次的分享能对你有所帮助。如果你也正在被类似的日期选择问题困扰,不妨动手实践一下,打造属于你自己的解决方案。欢迎在评论区交流你的想法和经验!