【Flutter】自定义特殊的星期与日历选择控件

529 阅读9分钟

Flutter组合控件,自定义星期与日历选择

前言

在 Flutter 开发中,自定义控件是实现个性化交互与复杂 UI 的核心能力。根据实现方式的不同,Flutter 自定义控件可分为两大类:组合控件(通过组合现有组件实现)与自绘控件(通过 Canvas API 从零绘制)。前者以高效开发、灵活复用见长,后者则适用于高度定制化的视觉效果。

对于大多数业务场景,组合控件能够以更低的成本满足需求。通过分层封装基础组件(如 Row、Column、Text),开发者可以快速构建出功能丰富、逻辑清晰的复合控件,同时继承 Flutter 内置的布局优化与平台适配能力。

image.png

上面的控件是星期显示的控件,需要展示双星期,并且支持选中回调,支持过期的与复显,并且支持自定义区间的选择。

下面的控件的是日期的选择弹窗,需要展示固定的UI,需要支持区间的选择,如果支持的区间跨月跨年需要支持左右滑动选择。

总之高度定制化的东西,实在是在 pub 上找不到合适的,无奈自己写了,顺便帮大家回顾一下自定义组合控件的思路。

一、星期组合控件

组合控件就是控件的组合而成,在这里我们只是多了根据日期对应星期的逻辑而已。

我们分解设计图有三块地方:

image.png

最上面的文本与ICON不必多说,主要是星期的生成与星期日期的生成。

对于星期我们可以用一个 控件封装 Text:

day_of_week_cell.dart

import 'package:cs_resources/theme/app_colors_theme.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';

class DayOfWeekCell extends StatelessWidget {
  const DayOfWeekCell({
    super.key,
    required this.date,
  });

  final DateTime date;

  String get locale => "en"; //国际化
  String get text => DateFormat.E(locale).format(date); //星期显示

  @override
  Widget build(BuildContext context) {
    return Align(
      alignment: Alignment.bottomCenter,
      child: Text(
        text,
        style: TextStyle(
          fontSize: 13,
          fontWeight: FontWeight.w500,
          color: _isWeekend(date) ? context.appColors.textBlack : context.appColors.textBlack,
        ),
      ),
    );
  }

  //是否是周末
  bool _isWeekend(DateTime date) {
    return date.weekday == DateTime.saturday || date.weekday == DateTime.sunday;
  }
}

根据指定的一个日期生成对应7天的星期,逻辑就是:

day_of_week_view.dart

import 'package:flutter/material.dart';

import 'day_of_week_cell.dart';

/// 根据7天生成对应没每一天的星期
class DayOfWeekView extends StatelessWidget {
  const DayOfWeekView({
    super.key,
    required this.weekdays,
  });

  final List<DateTime> weekdays;

  @override
  Widget build(BuildContext context) {
    return Table(
      children: [
        TableRow(
          children: weekdays
              .map(
                (date) => DayOfWeekCell(
                  date: date,
                ),
              )
              .toList(),
        ),
      ],
    );
  }
}

那么在自定义组合控件的入口,我们传入一些指定的参数之后就可以根据对应的参数填充对应的布局了:

weekly_calendar.dart

/// 总入口,整合顶部的Week控件与底部的Days的PageView控件
class WeeklyCalendar extends StatefulWidget {
  const WeeklyCalendar({
    super.key,
    this.onChangedSelectedDate,
    required this.maxDate,
    this.selectedDate,
  });

  final DateTime? selectedDate; //当前选中的日期
  final DateTime maxDate; //可选的最大日期
  final Function(DateTime)? onChangedSelectedDate; //选择日期的回调

  @override
  State<StatefulWidget> createState() => _WeeklyCalendarState();
}

class _WeeklyCalendarState extends State<WeeklyCalendar> {
  final DateTime now = DateTime.now();
  DateTime? selectedDate;
  late List<DateTime> currentDateList;

  @override
  void initState() {
    initializeDateFormatting("en"); //日期国际化指定
    super.initState();

    currentDateList = get2Weekdays(now, 0); //双周的数据
    selectedDate = widget.selectedDate ?? now; //默认选中的数据
  }

  @override
  void didUpdateWidget(covariant WeeklyCalendar oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (widget.selectedDate != oldWidget.selectedDate) {
      //查看当前页面Date数据是否包含选中的 selectedDate
      bool hasSelected = hasSelectedDate(currentDateList, widget.selectedDate ?? now);

      setState(() {
        selectedDate = widget.selectedDate;

        if (!hasSelected) {
          //如果不包含,需要更换数据源
          Log.d("选中日期不在当前页面,需要更换数据源");
          currentDateList = get2Weekdays(widget.selectedDate ?? now, 0);
        }
      });
    }
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisSize: MainAxisSize.min,
      children: [
        //顶部是固定的星期布局
        customDayOfWeek(),

        const SizedBox(height: 12),

        //底部是当前二周的数据
        //待完成
      ],
    );
  }

  //星期的文本显示
  Widget customDayOfWeek() {
    final weekdays = getWeekdays(now, 0);
    return DayOfWeekView(weekdays: weekdays);
  }
}

接下来就是底部双周的显示了,细心的同学发现了,我在自定义控件初始化的时候已经传递的参数就已经拿到了双周的数据

late List<DateTime> currentDateList; 
...
currentDateList = get2Weekdays(now, 0); //双周的数据

我们的双周控件就是:

day_table_view.dart

import 'package:flutter/material.dart';
import 'calendar_utils.dart';
import 'day_cell.dart';

/// 一周的数据为一行,展示两周共两行
class DayTableView extends StatelessWidget {
  const DayTableView({
    super.key,
    required this.weekdays,
    required this.onSelect,
    required this.selectedDate,
    required this.maxDate,
    required this.currentDate,
  });

  final List<DateTime> weekdays;
  final Function(DateTime)? onSelect;
  final DateTime selectedDate;
  final DateTime maxDate;
  final DateTime currentDate;

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        // 第一行
        Row(
          mainAxisAlignment: MainAxisAlignment.spaceAround,
          children: weekdays.sublist(0, 7).map(
            (date) {
              return GestureDetector(
                onTap: isPastDate(currentDate, date) || isAfterMaxDate(maxDate, date) ? null : () => onSelect?.call(date),
                child: SizedBox(
                  width: 40,
                  height: 40,
                  child: DayCell(
                    display: date,
                    selected: selectedDate,
                    maxDate: maxDate,
                    current: currentDate,
                  ),
                ),
              );
            },
          ).toList(),
        ),

        const SizedBox(height: 10),

        // 第二行
        Row(
          mainAxisAlignment: MainAxisAlignment.spaceAround,
          children: weekdays.sublist(7, 14).map(
            (date) {
              return GestureDetector(
                onTap: isPastDate(currentDate, date) || isAfterMaxDate(maxDate, date) ? null : () => onSelect?.call(date),
                child: SizedBox(
                  width: 40,
                  height: 40,
                  child: DayCell(
                    display: date,
                    selected: selectedDate,
                    maxDate: maxDate,
                    current: currentDate,
                  ),
                ),
              );
            },
          ).toList(),
        ),
      ],
    );
  }
}

这里我们定义了最小区间与最大的区间,最小默认是今天,最大默认是100天,通过对两周的数据 weekdays 的拆分,分别在上和下填充了两组7个 DayCell 的控件。

day_cell.dart

import 'package:cs_resources/theme/app_colors_theme.dart';
import 'package:flutter/material.dart';
import 'package:shared/utils/date_time_utils.dart';
import 'calendar_utils.dart';

class DayCell extends StatelessWidget {
  const DayCell({
    super.key,
    required this.display,
    required this.selected,
    required this.maxDate,
    required this.current,
  });

  final DateTime display;
  final DateTime selected;
  final DateTime maxDate;
  final DateTime current;

  //显示的日期文本
  String get dayText {
    return DateTimeUtils.formatDate(display, format: 'dd');
  }

  @override
  Widget build(BuildContext context) {
    return AnimatedContainer(
      duration: const Duration(milliseconds: 300),
      width: 40,
      height: 40,
      decoration: BoxDecoration(
        color: isPastDate(current, display) || isAfterMaxDate(maxDate, display)
            ? context.appColors.disEnableGray.withOpacity(0.6) // 小于今天的日期背景颜色和大于Max日期变灰并禁用
            : _isSelected(display)
                ? _isToday(display)
                    ? context.appColors.btnBgDefault // 今天的日期背景颜色
                    : context.appColors.btnBgDefault // 选中的日期背景颜色
                : context.appColors.grayBgE9, // 默认的背景颜色
        shape: BoxShape.rectangle,
        borderRadius: BorderRadius.circular(2.5), // 圆角
      ),
      alignment: Alignment.center,
      child: Text(
        dayText,
        style: TextStyle(
          fontSize: 17,
          fontWeight: FontWeight.w500,
          color: _dayTextColor(context, display),
        ),
      ),
    );
  }

  Color _dayTextColor(BuildContext context, DateTime date) {
    if (_isSelected(date)) {
      return Colors.white;
    }

    if (_isToday(date)) {
      return context.appColors.textBlack;
    }

    if (_isWeekend(date)) {
      return context.appColors.textBlack;
    }

    return context.appColors.textBlack;
  }

  //是否是周末
  bool _isWeekend(DateTime date) {
    return date.weekday == DateTime.saturday || date.weekday == DateTime.sunday;
  }

  //是否是选中的
  bool _isSelected(DateTime date) {
    return DateTimeUtils.formatDate(date, format: 'yyyyMMdd') == DateTimeUtils.formatDate(selected, format: 'yyyyMMdd');
  }

  //是否是今天
  bool _isToday(DateTime date) {
    return DateTimeUtils.formatDate(date, format: 'yyyyMMdd') == DateTimeUtils.formatDate(current, format: 'yyyyMMdd');
  }
}

DayCell 控件就是每一个 Day 的控件,内部封装了选中过期和超期等状态的背景和文本颜色之类的封装。

到此我们把封装好的 DayTableView 控件填充进自定义控件入口中,做好事件的回调即可。

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisSize: MainAxisSize.min,
      children: [
        //顶部是固定的星期布局
        customDayOfWeek(),

        const SizedBox(height: 12),

        //底部是当前二周的数据
        DayTableView(
          weekdays: currentDateList,
          onSelect: (date) {
            setState(() {
              selectedDate = date;
            });
            //主动点击之后回调
            widget.onChangedSelectedDate?.call(date);
          },
          selectedDate: selectedDate ?? now,
          maxDate: widget.maxDate,
          currentDate: now,
        )
      ],
    );
  }

这里的数据是只能最多选择3天内的日期,控件使用:

       //二周的日期选择 (自定义控件)
          WeeklyCalendar(
            selectedDate: state.selectedDate,
            maxDate: DateTime.now().add(Duration(days: state.bookAdvanceDays)),
            onChangedSelectedDate: (dateTime) {
                //调用接口
              viewModel.changeSelectedDate(dateTime);
            },
          ),

效果为:

20250523_193444.gif

二、日历选择控件

日历的选择其实也就是和星期的控件类似,只是星期需要我们遍历7天,日历需要遍历当前月份。

image.png

我们还是分为上面的星期与下面的 Day 组。

//顶部星期的文本数据
  Widget daysOfWeek(double width, String? locale) {
    List daysNames = [];
    for (var day = 12; day <= 18; day++) {
      daysNames.add(DateFormat.E(locale.toString()).format(DateTime.parse('1970-01-$day')));
    }

    return Row(
      mainAxisAlignment: MainAxisAlignment.spaceBetween,
      children: [
        dayName(width / 7, daysNames[0]),
        dayName(width / 7, daysNames[1]),
        dayName(width / 7, daysNames[2]),
        dayName(width / 7, daysNames[3]),
        dayName(width / 7, daysNames[4]),
        dayName(width / 7, daysNames[5]),
        dayName(width / 7, daysNames[6]),
      ],
    );
  }

  //顶部星期的文本控件展示
  Widget dayName(double width, String text) {
    return Container(
      width: width,
      alignment: Alignment.center,
      child: Text(
        text,
        style: const TextStyle(
          fontSize: 13.0,
          fontWeight: FontWeight.w500,
        ),
        overflow: TextOverflow.ellipsis,
      ),
    );
  }

把每一天的星期文本,组合7份为一个星期的控件,这里为了控制间距没用 TabRow 了,是自己算的宽度。

每一天的布局,和一个月的数据编译而成的当前月份的控件如下:

 //当前月份,每一天的布局
  Widget dateInCalendar(DateTime date, bool outOfRange, double width, bool event) {
    bool isSelectedDate = date.toString().split(" ").first == widget.selectedDate.toString().split(" ").first;
    return GestureDetector(
      onTap: () => outOfRange ? null : widget.onDateChange(date),
      child: Container(
        width: width / 7,
        height: width / 7,
        decoration: BoxDecoration(
          shape: BoxShape.circle,
          color: isSelectedDate ? widget.dateSelectedBg : Colors.transparent,
        ),
        alignment: Alignment.center,
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            const SizedBox(
              height: 5.0,
            ),
            Padding(
              padding: const EdgeInsets.symmetric(vertical: 4.0),
              child: Text(
                DateFormat("dd").format(date),
                style: TextStyle(
                    color: outOfRange
                        ? isSelectedDate
                            ? widget.dateSelectedColor!.withOpacity(0.9)
                            : widget.dateColor!.withOpacity(0.4)
                        : isSelectedDate
                            ? widget.dateSelectedColor
                            : widget.dateColor,
                    fontWeight: FontWeight.w500,
                    fontSize: 13),
              ),
            ),
            event
                ? Icon(
                    Icons.bookmark,
                    size: 8,
                    color: isSelectedDate ? widget.dateSelectedColor : widget.dateSelectedBg,
                  )
                : const SizedBox(height: 5.0),
          ],
        ),
      ),
    );
  }

  //单独一个月的Page布局
  Widget month(List dates, double width, String? locale) {
    DateTime first = dates.first;

    // 获取这个月的第一天
    DateTime firstDayOfMonth = DateTime(first.year, first.month, 1);

    // 找到这个月的第一天是星期几
    int firstWeekday = firstDayOfMonth.weekday;

    // 计算需要的前导空格数量
    int leadingDaysCount = (firstWeekday - DateTime.monday + 7) % 7;

    // 只保留当前月份的日期
    List<DateTime?> fullDates = List.from(dates);

    // 在视图中添加用于填充的空日期(如果需要,这里就不填充上个月尾的日期了)
    for (int i = 0; i < leadingDaysCount; i++) {
      fullDates.insert(0, null); // 用null填充前导位置
    }

    return Column(
      mainAxisAlignment: MainAxisAlignment.start,
      children: [
        Text(
          DateFormat.yMMMM(Locale(locale!).toString()).format(first),
          style: TextStyle(fontSize: 18.0, color: widget.dateColor, fontWeight: FontWeight.w500),
        ),

        // 周一到周天的星期文本
        Padding(
          padding: const EdgeInsets.only(top: 30.0),
          child: daysOfWeek(width, widget.locale),
        ),

        // 底部的每月的每一天
        Container(
          padding: const EdgeInsets.only(top: 10.0),
          height: (fullDates.length > 28) ? (fullDates.length > 35 ? 6.2 * width / 7 : 5.2 * width / 7) : 4 * width / 7,
          width: MediaQuery.of(context).size.width - 2 * widget.padding!,
          child: GridView.builder(
            itemCount: fullDates.length,
            physics: const NeverScrollableScrollPhysics(),
            gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 7),
            itemBuilder: (context, index) {
              DateTime? date = fullDates[index]; // 使用 DateTime? 类型以支持 null

              // 如果 date 为 null,表示该位置为空,返回一个透明的容器
              if (date == null) {
                return Container(
                  width: width / 7,
                  height: width / 7,
                  color: Colors.transparent, // 透明或其他样式
                );
              }

              bool outOfRange = date.isBefore(startDate) || date.isAfter(endDate);

              return dateInCalendar(
                date,
                outOfRange,
                width,
                _events!.contains(date.toString().split(" ").first) && !outOfRange,
              );
            },
          ),
        )
      ],
    );
  }

这样组合而成上下结构,就是我们的效果图了。但是我们的月份是可能跨月或者跨年的,所以我们还需要用 pageView 包装一层让月份布局可以滑动。

所以我们的总体控件如下:

Container(
        height: monthHeight, // 使用固定的高度
        padding: const EdgeInsets.fromLTRB(25, 10.0, 25, 20.0),
        //只是PageView
        child: Stack(
          children: [
            //主题
            PageView.builder(
              physics: const BouncingScrollPhysics(),
              controller: _horizontalScroll,
              reverse: true,
              scrollDirection: Axis.horizontal,
              itemCount: months.length,
              itemBuilder: (context, index) {
                DateTime? date = months[index];
                List<DateTime?> daysOfMonth = [];
                for (var item in dates) {
                  if (date!.month == item!.month && date.year == item.year) {
                    daysOfMonth.add(item);
                  }
                }

                bool isLast = index == 0;

                return Container(
                  padding: EdgeInsets.only(bottom: isLast ? 0.0 : 10.0),
                  child: month(daysOfMonth, width, widget.locale),
                );
              },
            ),

            //返回按钮
            Positioned(
              top: -11,
              width: MediaQuery.of(context).size.width * 0.88,
              child: Row(
                mainAxisAlignment: MainAxisAlignment.spaceBetween,
                children: [
                  MyAssetImage(
                    Assets.baseLibCalendarLeftIcon,
                    width: 44,
                    height: 44,
                    color: DarkThemeUtil.multiColors(context, AppColorsTheme.colorPrimary, darkColor: Colors.white),
                  ).onTap(() {
                    _horizontalScroll.nextPage(
                      duration: const Duration(milliseconds: 300),
                      curve: Curves.ease,
                    );
                  }),
                  MyAssetImage(
                    Assets.baseLibCalendarRightIcon,
                    width: 44,
                    height: 44,
                    color: DarkThemeUtil.multiColors(context, AppColorsTheme.colorPrimary, darkColor: Colors.white),
                  ).onTap(() {
                    _horizontalScroll.previousPage(
                      duration: const Duration(milliseconds: 300),
                      curve: Curves.ease,
                    );
                  }),
                ],
              ),
            )
          ],
        ),
      );

加上了前进和后退的按钮,并且可以滑动,然后我们在把参数填充进去,选中的日期,最大日期,最小日期和对应的颜色背景等资源

代码如下:

//日历的具体控件展示
class FullCalendar extends StatefulWidget {
  final DateTime startDate;
  final DateTime? endDate;
  final DateTime? selectedDate;
  final Color? dateColor;
  final Color? dateSelectedColor;
  final Color? dateSelectedBg;
  final double? padding;
  final String? locale;
  final Widget? calendarBackground;
  final List<String>? events;
  final Function onDateChange;

  const FullCalendar({
    Key? key,
    this.endDate,
    required this.startDate,
    required this.padding,
    required this.onDateChange,
    this.calendarBackground,
    this.events,
    this.dateColor,
    this.dateSelectedColor,
    this.dateSelectedBg,
    this.locale,
    this.selectedDate,
  }) : super(key: key);

  @override
  State<FullCalendar> createState() => _FullCalendarState();
}

class _FullCalendarState extends State<FullCalendar> {
  late DateTime endDate;
  late DateTime startDate;
  late int _initialPage;

  List<String>? _events = [];

  late PageController _horizontalScroll;

  @override
  void initState() {
    setState(() {
      startDate = DateTime.parse("${widget.startDate.toString().split(" ").first} 00:00:00.000");
      endDate = DateTime.parse("${widget.endDate.toString().split(" ").first} 23:00:00.000");

      _events = widget.events;
    });

    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    List<String> partsStart = startDate.toString().split(" ").first.split("-");

    DateTime firstDate = DateTime.parse("${partsStart.first}-${partsStart[1].padLeft(2, '0')}-01 00:00:00.000");

    List<String> partsEnd = endDate.toString().split(" ").first.split("-");

    DateTime lastDate =
        DateTime.parse("${partsEnd.first}-${(int.parse(partsEnd[1]) + 1).toString().padLeft(2, '0')}-01 23:00:00.000").subtract(const Duration(days: 1));

    double width = MediaQuery.of(context).size.width - (2 * widget.padding!);

    List<DateTime?> dates = [];

    DateTime referenceDate = firstDate;

    while (referenceDate.isBefore(lastDate)) {
      List<String> referenceParts = referenceDate.toString().split(" ");
      DateTime newDate = DateTime.parse("${referenceParts.first} 12:00:00.000");
      dates.add(newDate);

      referenceDate = newDate.add(const Duration(days: 1));
    }

    if (firstDate.year == lastDate.year && firstDate.month == lastDate.month) {
      return Padding(
        padding: EdgeInsets.fromLTRB(widget.padding!, 40.0, widget.padding!, 0.0),
        child: month(dates, width, widget.locale),
      );
    } else {
      List<DateTime?> months = [];
      for (int i = 0; i < dates.length; i++) {
        if (i == 0 || (dates[i]!.month != dates[i - 1]!.month)) {
          months.add(dates[i]);
        }
      }

      months.sort((b, a) => a!.compareTo(b!));

      final index = months.indexWhere((element) => element!.month == widget.selectedDate!.month && element.year == widget.selectedDate!.year);

      _initialPage = index;
      _horizontalScroll = PageController(initialPage: _initialPage);

      double monthHeight = 6 * (width / 7) + 16 + 10 + 10 + 80; // 固定高度,6行的高度加上80额外空间

      return Container(
        height: monthHeight, // 使用固定的高度
        padding: const EdgeInsets.fromLTRB(25, 10.0, 25, 20.0),
        //只是PageView
        child: Stack(
          children: [
            //主题
            PageView.builder(
              physics: const BouncingScrollPhysics(),
              controller: _horizontalScroll,
              reverse: true,
              scrollDirection: Axis.horizontal,
              itemCount: months.length,
              itemBuilder: (context, index) {
                DateTime? date = months[index];
                List<DateTime?> daysOfMonth = [];
                for (var item in dates) {
                  if (date!.month == item!.month && date.year == item.year) {
                    daysOfMonth.add(item);
                  }
                }

                bool isLast = index == 0;

                return Container(
                  padding: EdgeInsets.only(bottom: isLast ? 0.0 : 10.0),
                  child: month(daysOfMonth, width, widget.locale),
                );
              },
            ),

            //返回按钮
            Positioned(
              top: -11,
              width: MediaQuery.of(context).size.width * 0.88,
              child: Row(
                mainAxisAlignment: MainAxisAlignment.spaceBetween,
                children: [
                  MyAssetImage(
                    Assets.baseLibCalendarLeftIcon,
                    width: 44,
                    height: 44,
                    color: DarkThemeUtil.multiColors(context, AppColorsTheme.colorPrimary, darkColor: Colors.white),
                  ).onTap(() {
                    _horizontalScroll.nextPage(
                      duration: const Duration(milliseconds: 300),
                      curve: Curves.ease,
                    );
                  }),
                  MyAssetImage(
                    Assets.baseLibCalendarRightIcon,
                    width: 44,
                    height: 44,
                    color: DarkThemeUtil.multiColors(context, AppColorsTheme.colorPrimary, darkColor: Colors.white),
                  ).onTap(() {
                    _horizontalScroll.previousPage(
                      duration: const Duration(milliseconds: 300),
                      curve: Curves.ease,
                    );
                  }),
                ],
              ),
            )
          ],
        ),
      );
    }
  }

由于是一个弹窗的日历选择,所以我们还需要用背景边框把日历控件装饰一下:

class CustomCalendarBottomSheet extends StatelessWidget {
  final DateTime firstDate;
  final DateTime? lastDate;
  final String locale;
  final DateTime selectedDate;
  final Function(DateTime) onDateChange;

  const CustomCalendarBottomSheet({
    Key? key,
    required this.firstDate,
    this.lastDate,
    required this.selectedDate,
    required this.locale,
    required this.onDateChange,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {

    return Column(
      mainAxisSize: MainAxisSize.min,
      children: [
        const SizedBox(height: 20),
        Container(
          width: 60,
          height: 6,
          decoration: BoxDecoration(borderRadius: BorderRadius.circular(3.0), color: const Color(0xFFE0E0E0)),
        ),
        const SizedBox(height: 13.0),
        FullCalendar(
          startDate: firstDate,
          endDate: lastDate,
          selectedDate: selectedDate,
          padding: 25,
          locale: locale,
          dateColor: context.appColors.textBlack,
          dateSelectedBg: context.appColors.btnBgDefault,
          dateSelectedColor: Colors.white,
          events: [],
          onDateChange: onDateChange,
        ),
      ],
    );
  }
}

在使用的时候用BottomSheet的方式弹起:

 //日期日历的选择器,底部弹窗选择
  void _datePickerBottomSheet(BuildContext context, WidgetRef ref) {
    final viewModel = ref.watch(facilityBookingViewModelProvider.notifier);
    final state = ref.watch(facilityBookingViewModelProvider);

    showModalBottomSheet<void>(
      context: context,
      isScrollControlled: true,
      shape: const RoundedRectangleBorder(
        borderRadius: BorderRadius.only(topLeft: Radius.circular(30.0), topRight: Radius.circular(30.0)),
      ),
      builder: (BuildContext context) {
        return CustomCalendarBottomSheet(
          firstDate: DateTime.now(),
          lastDate: DateTime.now().add(Duration(days: state.bookAdvanceDays)),
          selectedDate: state.selectedDate,
          locale: "en",
          onDateChange: (dateTime) {
            Navigator.pop(context);
            viewModel.changeSelectedDate(dateTime);
          },
        );
      },
    );
  }

效果:

20250523_194947.gif

总结

在本文中,我们探讨了 Flutter 中的组合控件,具体实现了自定义的星期组合控件和日历选择控件。通过这两个控件的设计与实现,展示了如何利用 Flutter 的组合特性来构建复杂的用户界面,以便更好地满足实际业务需求。

星期组合控件:

通过将多个基础控件(如 Text 和 GestureDetector)组合在一起,我们成功实现了一个可以展示双星期、支持选中回调的控件。此控件不仅提供了美观的用户体验,还具备了良好的交互性。 该控件的设计逻辑清晰,结构分明,使得开发者可以轻松扩展和维护。同时,通过封装日期与星期的逻辑,增强了代码的复用性。

日历选择控件:

我们实现了一个能够展示当前月份并支持跨月、跨年的日历选择控件。通过结合 PageView 和自定义的组件,我们实现了流畅的月份切换体验,使得用户在选择日期时更加直观和便捷。 该控件还支持通过 BottomSheet 弹出,提供了良好的视觉效果和交互逻辑,用户可以方便地选择所需的日期范围。

除此之外还有一些日期时间处理的工具类没有贴出来,这里我放出一个 Demo 给大家参考,内部的资源都是使用的黑白模式的,文本可能用到国际化,大家可以自行替换即可。【传送门】

先在项目根目录运行 melos bootstrap,然后运行 run app 的命令即可,如果没有 melos 环境那么就需要你一个模块一个模块的进去运行 flutter pub get 之后在 app 模块执行命令 flutter run 也能运行。

如果不想运行就在里面全局搜索你们的文件也可以的哦。

如果我的文章有错别字,不通顺的,或者代码、注释、有错漏的地方,理解不正确的地方,同学们都可以指出修正。

今天的分享就到这里啦,当然如果你有其他的更多的更好的实现方式推荐,也希望大家能评论区交流一起学习进步。

如果感觉本文对你有一点的启发和帮助,还望你能点赞支持一下,你的支持对我真的很重要。

Ok,这一期就此完结。