Flutter 日期选择器封装:灵活易用的日期、时间筛选和编辑组件

898 阅读6分钟

前言

在移动应用开发中,日期和时间选择是非常常见的需求。无论是设置日程、安排提醒,还是筛选数据,我们都离不开日期的选择功能。尤其是当用户需要进行灵活的日期选择、筛选或编辑时,我们希望能够通过一个统一的组件来满足这些需求,而不是每次都重新开发。

确定好这个方向后,我们就开始着手准备做一个通用时间选择组件。

需求

假设正在开发一个待办事项应用,用户可能需要进行以下操作:

  • 编辑日期:用户希望选择某个具体日期来编辑待办事项的日期,类似于日历上的日期选择。
  • 筛选日期:用户可能需要在某些场景下筛选出特定的日期范围,例如选择某一周、一月或一年内的任务。
  • 编辑提醒时间:在设置提醒时,用户希望能够编辑具体的时间(包括小时和分钟)。

总结

  • 这个组件需要三种类型
    • 选择特定日期(也需要具体的时间)
    • 筛选日期(周、月、年三个维度)
    • 提供时间选择(只有时分)

这一块主要还是和产品去沟通,看看是否符合自己公司的开发需求,我这里只是陈述我得到的需求,可以将我的实现方法做一个思路去扩展到自己的项目中~

实现效果

1.gif

  • 安卓机渲染效果

2.png

  • 选择时和点击确定、取消的回调

思路

1. 多种模式切换

需要在同一个组件中实现三种不同的使用模式:

  • 编辑日期模式(editDate :在这个模式下,用户需要选择一个日期并进行编辑。这类似于我们日常生活中在日历上选择某天来安排事件或修改事件时间。
  • 筛选日期模式(filterDate :这个模式主要用于筛选特定日期范围或者周期。用户可以选择按“周”、“月”或“年”来筛选,这个需求常见于数据筛选或任务过滤等场景。
  • 编辑提醒时间模式(editTime :这个模式允许用户选择一个具体的时间(包括小时和分钟)。比如在设置提醒的时候,用户需要指定提醒的具体时间,这时一个精确的时间编辑器就变得尤为重要。

2. 灵活的配置项

日期选择器需要能够灵活地接受不同的配置项,以满足多种使用场景:

  • 初始时间(initialDateTime :支持传入一个初始日期时间(DateTime类型),以便用户能基于某个默认时间来进行操作。如果没有传入该值,则默认为当前时间。
  • 选择器类型(mode:支持传入一个mode(String类型),来区别展示哪种时间选择器。
  • 筛选类型(filterType :用户可以选择筛选的时间粒度(周、月、年)。通过这个配置,用户可以根据不同的需求选择不同的筛选方式。
  • 往回显示的年份数(yearsBack :在filterType == week时,可能有时需要设定过去若干年内的日期进行选择。这一配置项可以帮助限制可选择的日期范围。

3. 封装和回调设计

为了提高组件的复用性,我们需要通过回调函数来传递用户的选择结果。这个组件将暴露以下回调:

  • onChange:每当用户选择一个新的日期或时间时触发,能够让外部接收到最新的选择结果。
  • onConfirm:用户确认选择时触发,组件将返回最终选择的日期或时间。
  • onCancel:用户取消操作时触发,关闭日期选择弹窗。

4. Overlay 弹窗

为了让日期选择器在页面上方以弹窗的形式展示,我们使用了 Overlay 来插入浮层。通过 OverlayEntry 动态插入日期选择器,使得日期选择器能够在不影响页面其他内容的情况下展示,并且支持自定义关闭操作。

这样,用户可以通过点击空白区域来关闭弹窗,或者点击按钮来确认或取消。

5. UI 构建

根据不同的模式,选择器的界面会有所变化。 在实现时,我们将不同模式的UI分离成单独的小组件(如 EditDateEditTimeFilterDate),这样可以根据用户的需求动态加载不同的组件。每个组件都有自己特定的行为,确保在各种场景下都能提供最佳的用户体验。

通过这些设计,保证了组件的灵活性和可定制性的同时,也能够根据自己的需求选择性地使用日期选择器,满足各种功能场景。

代码实现

1. 封装CustomDatePicker

是一个静态类,用于展示日期选择器,接受多个配置项,如上下文、初始化时间、筛选类型、回调函数等。show 方法负责插入弹窗并展示不同的选择器。

class CustomDatePicker {
  static void show({
    required BuildContext context,
    required String mode,
    String? filterType,
    DateTime? initialDateTime,
    int? yearsBack,
    required Function(dynamic) onChange,
    required Function(dynamic) onConfirm,
    required Function() onCancel,
  }) {
    OverlayState overlayState = Overlay.of(context);
    late OverlayEntry overlayEntry;
    overlayEntry = OverlayEntry(
      builder: (context) {
        return Material(
          color: Colors.transparent,
          child: DatePickerOverlay(
            mode: mode,
            initialDateTime: initialDateTime,
            onChange: onChange,
            filterType: filterType,
            yearsBack: yearsBack,
            onConfirm: (selectedTime) {
              onConfirm(selectedTime);
              overlayEntry.remove();
            },
            onCancel: () {
              onCancel();
              overlayEntry.remove();
            },
          ),
        );
      },
    );
    overlayState.insert(overlayEntry);
  }
}

2. 封装DatePickerOverlay

具体的日期选择器UI组件,根据不同的模式展示不同的日期选择界面,如 EditDateEditTimeFilterDate

_buildPicker() 方法来根据传入的 mode 显示不同的选择器。

_buildButtons :在底部展示两个按钮:“取消”和“确定”,点击后执行相应的回调

class DatePickerOverlay extends StatefulWidget {
  final String mode;
  final DateTime? initialDateTime;
  final String? filterType;
  final int? yearsBack;
  final Function(dynamic) onChange;
  final Function(dynamic) onConfirm;
  final Function() onCancel;

  const DatePickerOverlay({
    Key? key,
    required this.mode,
    this.initialDateTime,
    this.filterType,
    this.yearsBack = 2,
    required this.onChange,
    required this.onConfirm,
    required this.onCancel,
  }) : super(key: key);
  @override
  _DatePickerOverlayState createState() => _DatePickerOverlayState();
}
class _DatePickerOverlayState extends State<DatePickerOverlay> {
  late DateTime selectedDateTime;
  late String filterType;
  Map<String ,dynamic> filterDate = {};
  @override
  void initState() {
    super.initState();
    filterType = widget.filterType ?? "week";
    selectedDateTime = widget.initialDateTime ?? DateTime.now();
  }
  void _onMomentChange(DateTime date) {
    setState(() {
      selectedDateTime = DateTime(
        date.year,
        date.month,
        date.day,
        date.hour,
        date.minute
      );
    });
    widget.onChange(selectedDateTime);
  }
  void _onEditTimeChange(dynamic date){
    setState(() {
      filterDate = date;
    });
    widget.onChange(date);
  }
  void _onFilterDateChange(dynamic date){
    setState(() {
      filterDate = date;
    });
    widget.onChange(date);
  }
  void _onConfirm(){
    if(widget.mode == 'editDate'){
      widget.onConfirm(selectedDateTime);
    }
    if(widget.mode == 'filterDate'){
      widget.onConfirm(filterDate);
    }
    if(widget.mode == 'editTime'){
      widget.onConfirm(filterDate);
    }
  }
  @override
  Widget build(BuildContext context) {
    return Stack(
      children: [
        Positioned.fill(
          child: GestureDetector(
            onTap: widget.onCancel,
            child: Container(color: Colors.black54),
          ),
        ),
        Positioned(
          bottom: MediaQuery.of(context).padding.bottom + 20,
          left: 0,
          right: 0,
          child: Container(
            padding: const EdgeInsets.symmetric(vertical: 24 , horizontal: 24),
            margin: const EdgeInsets.symmetric(horizontal: 12),
            decoration: BoxDecoration(
              color: Colors.white,
              borderRadius: BorderRadius.circular(24),
            ),
            child: Column(
              children: [
                Padding(padding: const EdgeInsets.only(bottom: 16) ,
                  child: Text(getPickerTitle(widget.mode), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold , color: Colors.black),),
                ),
                _buildPicker(),
                _buildButtons(),
              ],
            ),
          ),
        ),
      ],
    );
  }
  Widget _buildPicker() {
    switch (widget.mode) {
      case "editDate":
        return _buildDateTime(initialDate: widget.initialDateTime ?? DateTime.now() );
      case "filterDate":
        return _buildFilterPicker(
          filterType: filterType,
          initialDate: widget.initialDateTime?? DateTime.now() ,
          yearsBack: widget.yearsBack
        );
      case "editTime":
        return _buildTimePicker(initialDate: widget.initialDateTime ?? DateTime.now() );
      default:
        return Container();
    }
  }
  Widget _buildDateTime({
    required DateTime initialDate
  }){
    return EditDate(
      initialDate: initialDate,
      onDateChanged: _onMomentChange,
    );
  }
  Widget _buildTimePicker({
    required DateTime initialDate
  }) {
    return EditTime(
      initialDate: initialDate,
      onDateChanged: _onEditTimeChange
    );
  }
  Widget _buildFilterPicker({
    required DateTime initialDate,
    required String filterType,
    int? yearsBack,
  }){
    return FilterDate(
      type:filterType,
      initialDate: initialDate,
      yearsBack: yearsBack,
      onDateChanged: _onFilterDateChange
    );
  }
  Widget _buildButtons() {
    return Padding(padding: EdgeInsets.only(top: 12) ,
      child: Row(
        children: [
          Expanded(
            child: TextButton(
              style: ButtonStyle(
                backgroundColor: MaterialStateProperty.all<Color>(
                  const Color.fromRGBO(240, 240, 240, 1),
                ),
                padding: MaterialStateProperty.all<EdgeInsets>(
                  const EdgeInsets.symmetric(vertical: 14, horizontal: 0),
                ),
              ),
              onPressed: widget.onCancel,
              child: const Text("取消", style: TextStyle(color: Colors.black ,  fontWeight: FontWeight.w600)),
            ),
          ),
          const SizedBox(width: 12,),
          Expanded(
            child: TextButton(
              style: ButtonStyle(
                backgroundColor: MaterialStateProperty.all<Color>(
                  const Color.fromRGBO(52, 130, 255, 1),
                ),
                padding: MaterialStateProperty.all<EdgeInsets>(
                  const EdgeInsets.symmetric(vertical: 14, horizontal: 0),
                ),
              ),
              onPressed: _onConfirm,
              child: const Text("确定", style: TextStyle(color: Colors.white , fontWeight: FontWeight.w600)),
            ),
          ),
        ],
      )
    );
  }
}

3. 封装自定义CustomPicker

...待补充