Flutter-自定义日历的实现

红衣佳人白衣友,朝与同歌暮同酒。
世人皆谓慕长安,然吾只恋长安某。

前言

最近我们的UI小姐姐给了一份这样的日历设计图 ┭┮﹏┭┮,
可以上下滑动,支持多日选择,再次进入日历页面可以选中上次选中的日期,
开始想冒着侥幸的心里去找找网上的开源库,
无奈找了许久找不到可以上下滑动的日历,
故花了三天时间终于写完了初版

UI设计的样子

默认状态下是这个样子:

选中状态下是这个样子:

TimeUtil的实现

TimeUtil提供时间的计算功能类

     /*
      * 每个月对应的天数
      * */
      static const List _daysInMonth = [
        31,
        -1,
        31,
        30,
        31,
        30,
        31,
        31,
        30,
        31,
        30,
        31
      ];
    /*
      * 根据年月获取月的天数
      * */
      static int getDaysInMonth(int year, int month) {
        if (month == DateTime.february) {
          final bool isLeapYear =
              (year % 4 == 0) && (year % 100 != 0) || (year % 400 == 0);
          if (isLeapYear) return 29;
          return 28;
        }
        return _daysInMonth[month - 1];
      }
    /*
      * 得到这个月的第一天是星期几(0 是 星期日 1 是 星期一...)
      * */
      static int computeFirstDayOffset(
          int year, int month, MaterialLocalizations localizations) {
        // 0-based day of week, with 0 representing Monday.
        final int weekdayFromMonday = DateTime(year, month).weekday - 1;
        // 0-based day of week, with 0 representing Sunday.
        final int firstDayOfWeekFromSunday = localizations.firstDayOfWeekIndex;
        // firstDayOfWeekFromSunday recomputed to be Monday-based
        final int firstDayOfWeekFromMonday = (firstDayOfWeekFromSunday - 1) % 7;
        // Number of days between the first day of week appearing on the calendar,
        // and the day corresponding to the 1-st of the month.
        return (weekdayFromMonday - firstDayOfWeekFromMonday) % 7;
      }
    /*
      * 每个月前面空出来的天数
      * */
      static int numberOfHeadPlaceholderForMonth(
          int year, int month, MaterialLocalizations localizations) {
        return computeFirstDayOffset(year, month, localizations);
      }
    /*
      * 根据当前年月份计算当前月份显示几行
      * */
      static int getRowsForMonthYear(int year, int month, MaterialLocalizations localizations){
        int currentMonthDays = getDaysInMonth(year, month);
        // 每个月前面空出来的天数
        int placeholderDays = numberOfHeadPlaceholderForMonth(year, month, localizations);
        int rows = (currentMonthDays + placeholderDays)~/7; // 向下取整
        int remainder = (currentMonthDays + placeholderDays)%7; // 取余(最后一行的天数)
        if (remainder > 0) {
          rows += 1;
        }
        return rows;
      }
    /*
      * 根据当前年月份计算每个月后面空出来的天数
      * */
      static int getLastRowDaysForMonthYear(int year, int month, MaterialLocalizations localizations){
        int count = 0;
        // 当前月份的天数
        int currentMonthDays = getDaysInMonth(year, month);
        // 每个月前面空出来的天数
        int placeholderDays = numberOfHeadPlaceholderForMonth(year, month, localizations);
        int rows = (currentMonthDays + placeholderDays)~/7; // 向下取整
        int remainder = (currentMonthDays + placeholderDays)%7; // 取余(最后一行的天数)
        if (remainder > 0) {
          count = 7-remainder;
        }
        return count;
      }

CalendarViewModel的实现

CalendarViewModel提供日历要显示的数据模型

    class YearMonthModel {
      int year;
      int month;
    
      YearMonthModel(this.year, this.month);
    }
    // 每天对应的数据模型
    class DayModel {
      int year;
      int month;
      int dayNum; // 数字类型的几号
      String day; // 字符类型的几号
      bool isSelect; // 是否选中
      bool isOverdue; // 是否过期
      DayModel(this.year, this.month, this.dayNum, this.day, this.isSelect, this.isOverdue);
    }
    // 每个月对应的数据模型
    class CalendarItemViewModel {
      final List list;
      final int year;
      final int month;
      DayModel firstSelectModel;
      DayModel lastSelectModel;
      CalendarItemViewModel({this.list, this.year, this.month, this.firstSelectModel, this.lastSelectModel});
    }
    
    class CalendarViewModel {
    
      List yearMonthList = CalendarViewModel.getYearMonthList();
    
      List getItemList() {
        List _list = [];
        yearMonthList.forEach((model){
          List dayModelList = getDayModelList(model.year, model.month);
          _list.add(CalendarItemViewModel(list:dayModelList,year: model.year, month:model.month));
        });
        return _list;
      }
      // 根据年月得到 月的每天显示需要的日期
      static List getDayModelList(int year, int month) {
        List _listModel = [];
        // 今天几号
        int  _currentDay = DateTime.now().day;
        // 今天在几月
        int _currentMonth = DateTime.now().month;
        // 当前月的天数
        int _days = TimeUtil.getDaysInMonth(year, month);
    
        String _day = '';
        bool _isSelect = false;
        bool isOverdue = false;
        int _dayNum = 0;
        for (int i = 1; i <= _days; i++) {
          _dayNum = i;
          if (_currentMonth == month) {
            //在当前月
            if (i < _currentDay) {
              isOverdue = true;
              _day = '$i';
            } else if (i == _currentDay) {
              _day = '今';
              isOverdue = false;
            } else {
              _day = '$i';
              isOverdue = false;
            }
          } else {
            _day = '$i';
            isOverdue = false;
          }
          DayModel dayModel = DayModel(year, month, _dayNum, _day, _isSelect, isOverdue);
          _listModel.add(dayModel);
        }
        return _listModel;
      }
    
      /*
      * 根据当前年月份计算下面6个月的年月,根据需要可以实现更多个月的
      * */
      static List getYearMonthList() {
        int _month = DateTime.now().month;
        int _year = DateTime.now().year;
    
        List _yearMonthList = [];
        for(int i=0; i<6; i++) {
          YearMonthModel model = YearMonthModel(_year, _month);
          _yearMonthList.add(model);
          if(_month == 12) {
            _month = 1;
            _year ++;
    
          } else {
            _month ++;
          }
        }
        return _yearMonthList;
      }

CalendarItem的实现

CalendarItem对应的是每个月的widget

    typedef void OnTapDayItem(int year, int month, int checkInTime);
    
    class CalendarItem extends StatefulWidget {
      final CalendarItemViewModel itemModel;
      final OnTapDayItem dayItemOnTap;
    
      CalendarItem(this.dayItemOnTap, this.itemModel);
    
      @override
      _CalendarItemState createState() => _CalendarItemState();
    }
    
    class _CalendarItemState extends State {
      // 日历显示几行
      int _rows = 0;
      List _listModel = [];
    
      @override
      void initState() {
        // TODO: implement initState
        super.initState();
        _listModel = widget.itemModel.list;
      }
    
      @override
      Widget build(BuildContext context) {
        double screenWith = MediaQuery.of(context).size.width;
        // 显示几行
        _rows = TimeUtil.getRowsForMonthYear(widget.itemModel.year,
            widget.itemModel.month, MaterialLocalizations.of(context));
    
        return Container(
          width: screenWith,
          height: 25.0 + 24.0 + 17.0 + _rows * 52.0 + 32.0 + 13,
          child: Column(
            children: [
              SizedBox(
                height: 32,
              ),
              _yearMonthItem(widget.itemModel.year, widget.itemModel.month),
              SizedBox(
                height: 24,
              ),
              _weekItem(screenWith),
              SizedBox(
                height: 13,
              ),
              _monthAllDays(widget.itemModel.year, widget.itemModel.month, context),
            ],
          ),
        );
      }
    
      /*
      * 显示年月的组件,需要传入年月日期
      * */
      _yearMonthItem(int year, int month) {
        return Container(
          alignment: Alignment.center,
          height: 25,
          child: Text(
            '$year.$month',
            style: TextStyle(
              color: ColorUtil.color('212121'),
              fontSize: 18,
              fontFamily: 'Avenir-Heavy',
            ),
          ),
        );
      }
    
      /*
      * 显示周的组件,使用了 _weekTitleItem
      * */
      _weekItem(double screenW) {
        List _listS = [
          '日',
          '一',
          '二',
          '三',
          '四',
          '五',
          '六',
        ];
        List _listW = [];
        _listS.forEach((title) {
          _listW.add(_weekTitleItem(title, (screenW - 40) / 7));
        });
        return Container(
          width: screenW - 40,
          height: 17,
          child: Row(
            mainAxisAlignment: MainAxisAlignment.spaceBetween,
            children: _listW,
          ),
        );
      }
    
      /*
      * 周内对应的每天的组件
      * */
      _weekTitleItem(String title, double width) {
        return Container(
          alignment: Alignment.center,
          width: width,
          child: Text(
            title,
            style: TextStyle(
              color: ColorUtil.color('757575'),
              fontSize: 12,
              fontFamily: 'PingFangSC-Semibold',
            ),
          ),
        );
      }
    
      _monthAllDays(int year, int month, BuildContext context) {
        double screenWith = MediaQuery.of(context).size.width;
    
        // 当前月前面空的天数
        int emptyDays = TimeUtil.numberOfHeadPlaceholderForMonth(
            year, month, MaterialLocalizations.of(context));
    
        List _list = [];
    
        for (int i = 1; i <= emptyDays; i++) {
          _list.add(_dayEmptyTitleItem(context));
        }
    
        for (int i = 1; i <= _listModel.length; i++) {
          _list.add(_dayTitleItem(_listModel[i - 1], context));
        }
    
        List _rowList = [
          Row(
            children: _list.sublist(0, 7),
          ),
          Row(
            children: _list.sublist(7, 14),
          ),
          Row(
            children: _list.sublist(14, 21),
          ),
        ];
    
        if (_rows == 4) {
          _rowList.add(
            Row(
              children: _list.sublist(21, _list.length),
            ),
          );
        } else if (_rows == 5) {
          _rowList.add(
            Row(
              children: _list.sublist(21, 28),
            ),
          );
          _rowList.add(
            Row(
              children: _list.sublist(28, _list.length),
            ),
          );
        } else if (_rows == 6) {
          _rowList.add(
            Row(
              children: _list.sublist(21, 28),
            ),
          );
          _rowList.add(
            Row(
              children: _list.sublist(28, 25),
            ),
          );
          _rowList.add(
            Row(
              children: _list.sublist(35, _list.length),
            ),
          );
        }
        return Container(
          width: screenWith - 40,
          color: Colors.white,
          height: 52.0 * _rows,
          child: Column(
            children: _rowList,
          ),
        );
      }
    
      /*
      * number 月的几号
      * isOverdue 是否过期
      * */
      _dayTitleItem(DayModel model, BuildContext context) {
        double screenWith = MediaQuery.of(context).size.width;
        double singleW = (screenWith - 40) / 7;
        String dayTitle = model.day;
        if (widget.itemModel.firstSelectModel != null &&
            model.isSelect &&
            model.dayNum == widget.itemModel.firstSelectModel.dayNum) {
          dayTitle = '入住';
        }
        if (widget.itemModel.lastSelectModel != null &&
            model.isSelect &&
            model.dayNum == widget.itemModel.lastSelectModel.dayNum) {
          dayTitle = '离开';
        }
        return GestureDetector(
          onTap: () {
            if(model.isOverdue) return;
            _dayTitleItemTap(model);
          },
          child: Stack(
            children: [
              Container(
                width: singleW,
                height: 52,
                alignment: Alignment.center,
                child: Text(
                  dayTitle,
                  style: TextStyle(
                    color: model.isOverdue
                        ? ColorUtil.color('BDBDBD')
                        : ColorUtil.color('212121'),
                    fontSize: 15,
                    fontFamily: 'Avenir-Medium',
                  ),
                ),
              ),
              Positioned(
                left: 0,
                right: 0,
                bottom: 0,
                child: Visibility(
                    visible: model.isOverdue ? false : model.isSelect,
                    child: Container(
                      height: 4,
                      width: singleW,
                      color: ColorUtil.color('FED836'),
                    )),
              ),
            ],
          ),
        );
      }
    
      _dayEmptyTitleItem(BuildContext context) {
        double screenWith = MediaQuery.of(context).size.width;
        double singleW = (screenWith - 40) / 7;
        return Container(
          width: singleW,
          height: 52,
        );
      }
    
      _dayTitleItemTap(DayModel model) {
        widget.dayItemOnTap(
            widget.itemModel.year, widget.itemModel.month, model.dayNum);
        setState(() {});
      }
    }    

CalendarPage的实现

CalendarPage是日历页面的Widget

    /*
    * Location 标记当前选中日期和之前的日期相比,
    * left: 是在之前日期之前
    * mid:  和之前日期相等
    * right:在之前日期之后
    * */
    enum Location{left,mid,right}
    
    typedef void SelectDateOnTap(DayModel checkInTimeModel, DayModel leaveTimeModel);
    
    class CalendarPage extends StatefulWidget {
      final DayModel startTimeModel;// 外部传入的之前选中的入住日期
      final DayModel endTimeModel;// 外部传入的之前选中的离开日期
      final SelectDateOnTap selectDateOnTap;// 确定按钮的callback 给外部传值
      CalendarPage({this.startTimeModel,this.endTimeModel,this.selectDateOnTap});
    
      @override
      _CalendarPageState createState() => _CalendarPageState();
    }
    
    class _CalendarPageState extends State {
    
      String _selectCheckInTime = '选择入住时间';
      String _selectLeaveTime = '选择离开时间';
      bool _isSelectCheckInTime = false; // 是否选择入住日期
      bool _isSelectLeaveTime = false; // 是否选择离开日期
      int _checkInDays = 0; // 入住天数
    
      // 保存当前选中的入住日期和离开日期
      DayModel _selectCheckInTimeModel = null;
      DayModel _isSelectLeaveTimeModel = null;
    
      List _list = [];
      @override
      void initState() {
        super.initState();
        // 加载日历数据源
        _list = CalendarViewModel().getItemList();
        // 处理外部传入的选中日期
        if(widget.startTimeModel!=null && widget.endTimeModel!=null) {
          for(int i=0; i<_list.length; i++) {
            CalendarItemViewModel model = _list[i];
            if(model.month == widget.startTimeModel.month) {
              _updateDataSource(widget.startTimeModel.year, widget.startTimeModel.month, widget.startTimeModel.dayNum);
            }
            if (model.month == widget.endTimeModel.month) {
              _updateDataSource(widget.endTimeModel.year, widget.endTimeModel.month, widget.endTimeModel.dayNum);
            }
          }
        }
      }
      @override
      Widget build(BuildContext context) {
        final data = MediaQuery.of(context);
        // 屏幕宽高
        final screenHeight = data.size.height;
        final screenWidth = data.size.width;
        return Container(
          color: Colors.white,
          width: double.maxFinite,
          height: screenHeight - 64,
          child: Stack(
            children: [
              Column(
                children: [
                  SizedBox(
                    height: 86,
                  ),
                  Row(
                    children: [
                      SizedBox(
                        width: 20,
                      ),
                      // 择入住时间的视图
                      _selectTimeItem(context, _selectCheckInTime,
                          Alignment.centerLeft, _isSelectCheckInTime),
                      // 入住天数的视图
                      _daysItem(_checkInDays),
                      // 选择离开时间的视图
                      _selectTimeItem(context, _selectLeaveTime,
                          Alignment.centerRight, _isSelectLeaveTime),
                      SizedBox(
                        width: 20,
                      ),
                    ],
                  ),
    
                  // 月日期的视图
                  Container(
                    height: screenHeight - 64 - 80 - 83 - 30,
                    child: ListView.builder(
                      itemBuilder: (BuildContext context, int index) {
                        CalendarItemViewModel itemModel = _list[index];
                        return CalendarItem(
                          (year, month, checkInTime) {
                            _updateCheckInLeaveTime(
                                year, month, checkInTime);
                          },
                          itemModel,
                        );
                      },
                      itemCount: _list.length,
                    ),
                  ),
                ],
              ),
              Positioned(
                left: 0,
                right: 0,
                bottom: 0,
                height: MediaQuery.of(context).padding.bottom,
                child: Container(),
              ),
              _bottonSureButton(screenWidth),
            ],
          ),
        );
      }
    
      /*
      * content 显示的日期
      * alignment 用来控制文本的对齐方式
      * isSelectTime 是否选择了日期
      * */
      _selectTimeItem(BuildContext context, String content, Alignment alignment,
          bool isSelectTime) {
        final screenWidth = MediaQuery.of(context).size.width;
        return Container(
          width: (screenWidth - 40 - 30) / 2,
          height: 30,
          alignment: alignment,
          child: Text(
            content,
            style: TextStyle(
              fontFamily: isSelectTime ? 'Avenir-Heavy' : 'PingFangSC-Regular',
              fontSize: isSelectTime ? 22 : 18,
              color: isSelectTime
                  ? ColorUtil.color('212121')
                  : ColorUtil.color('BDBDBD'),
            ),
          ),
        );
      }
    
      /*
      * day 入住天数,默认不选择为0
      * */
      _daysItem(int day) {
        return Container(
          width: 30,
          height: 18,
          alignment: Alignment.center,
          decoration: BoxDecoration(
            color: Colors.white,
            border: Border.all(width: 0.5, color: ColorUtil.color('BDBDBD')),
            borderRadius: BorderRadius.all(Radius.circular(2)),
          ),
          child: Text(
            '$day晚',
            style: TextStyle(
              color: ColorUtil.color('BDBDBD'),
              fontSize: 12,
            ),
          ),
        );
      }
    
      /*
      * 底部确定按钮
      * */
      _bottonSureButton(double screenWidth) {
        return Positioned(
          left: 0,
          right: 0,
          bottom: MediaQuery.of(context).padding.bottom,
          height: 80,
          child: Container(
            height: 80,
            width: double.maxFinite,
            color: Colors.white,
            alignment: Alignment.center,
            child: GestureDetector(
              onTap: _sureButtonTap,
              child: Container(
                height: 48,
                width: screenWidth - 30,
                decoration: BoxDecoration(
                  color: ColorUtil.color('FED836'),
                  borderRadius: BorderRadius.all(Radius.circular(24.0)),
                ),
                child: Center(
                  child: Text(
                    '确定',
                    style: TextStyle(
                      fontSize: 16,
                      color: Colors.black,
                      fontFamily: 'PingFangSC-Light',
                    ),
                  ),
                ),
              ),
            ),
          ),
        );
      }
    
      /*
      * 比较后面的日期是比model日期小(left) 还是相等(mid) 还是大 (right)
      * */
      _comparerTime(DayModel model, int year, int month, int day){
        if(year > model.year) {
          return Location.right;
        } else if(year == model.year) {
          if(model.month < month) {
            return Location.right;
          } else if(model.month == month){
            if(model.dayNum < day){
              return Location.right;
            } else if(model.dayNum == day){
              return Location.mid;
            } else {
              return Location.left;
            }
          } else {
            return Location.right;
          }
        } else {
          return Location.left;
        }
      }
    
      /*
      * 更新日历的数据源
      * */
      _updateDataSource(int year, int month, int checkInTime) {
        // 左右指针 用来记录选择的入住日期和离开日期
        DayModel firstModel = null ;
        DayModel lastModel = null;
    
        for(int i=0; i<_list.length; i++) {
          CalendarItemViewModel model = _list[i];
          if(model.firstSelectModel != null){
            firstModel = model.firstSelectModel;
          }
          if (model.lastSelectModel != null) {
            lastModel = model.lastSelectModel;
          }
        }
    
        if (firstModel != null && lastModel != null) {
          for(int i=0; i<_list.length; i++) {
            CalendarItemViewModel model = _list[i];
            model.firstSelectModel = null;
            model.lastSelectModel = null;
            firstModel = null;
            lastModel = null;
            for(int i=0; i firstModel.dayNum && dayModel.dayNum firstModel.dayNum){
                      dayModel.isSelect = true;
                      _calculaterDays++;
                    }
                  }
                } else if(model.month>firstModel.month && model.month

状态设计模式

日历除了UI视图外,最麻烦的就是选择日期的各种逻辑
这里我们把它抽象成几种状态之间的转换:

对应的代码逻辑就是:CalendarPage页面中更新日历的数据源的方法(_updateDataSource)

源码地址

链接描述

你可能感兴趣的:(dart,flutter)