红衣佳人白衣友,朝与同歌暮同酒。
世人皆谓慕长安,然吾只恋长安某。
前言
最近我们的 UI 小姐姐给了一份这样的日历设计图 ┭┮﹏┭┮,
可以上下滑动,支持多日选择,再次进入日历页面可以选中上次选中的日期,
开始想冒着侥幸的心里去找找网上的开源库,
无奈找了许久找不到可以上下滑动的日历,
故花了三天时间终于写完了初版
UI 设计的样子
默认状态下是这个样子:
选中状态下是这个样子:
TimeUtil 的实现
TimeUtil 提供时间的计算功能类
/*
* 每个月对应的天数
* */
static const List<int> _daysInMonth = <int>[
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<DayModel> 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<YearMonthModel> yearMonthList = CalendarViewModel.getYearMonthList();
List<CalendarItemViewModel> getItemList() {List<CalendarItemViewModel> _list = [];
yearMonthList.forEach((model){List<DayModel> dayModelList = getDayModelList(model.year, model.month);
_list.add(CalendarItemViewModel(list:dayModelList,year: model.year, month:model.month));
});
return _list;
}
// 根据年月得到 月的每天显示需要的日期
static List<DayModel> getDayModelList(int year, int month) {List<DayModel> _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<YearMonthModel> getYearMonthList() {int _month = DateTime.now().month;
int _year = DateTime.now().year;
List<YearMonthModel> _yearMonthList = <YearMonthModel>[];
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<CalendarItem> {
// 日历显示几行
int _rows = 0;
List<DayModel> _listModel = <DayModel>[];
@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: <Widget>[
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<String> _listS = <String>[
'日',
'一',
'二',
'三',
'四',
'五',
'六',
];
List<Widget> _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<Widget> _list = <Widget>[];
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<Row> _rowList = <Row>[
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: <Widget>[
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<CalendarPage> {
String _selectCheckInTime = '选择入住时间';
String _selectLeaveTime = '选择离开时间';
bool _isSelectCheckInTime = false; // 是否选择入住日期
bool _isSelectLeaveTime = false; // 是否选择离开日期
int _checkInDays = 0; // 入住天数
// 保存当前选中的入住日期和离开日期
DayModel _selectCheckInTimeModel = null;
DayModel _isSelectLeaveTimeModel = null;
List<CalendarItemViewModel> _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: <Widget>[
Column(
children: <Widget>[
SizedBox(height: 86,),
Row(
children: <Widget>[
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<model.list.length; i++) {DayModel dayModel = model.list[i];
dayModel.isSelect = false;
if(_comparerTime(dayModel, year, month, checkInTime) == Location.mid){
dayModel.isSelect = true;
model.firstSelectModel = dayModel;
_isSelectCheckInTime = true;
_isSelectLeaveTime = false;
_selectCheckInTime = '$year.$month.$checkInTime';
_selectCheckInTimeModel = dayModel;
}
}
}
_checkInDays = 0;
_isSelectLeaveTime = false;
_selectLeaveTime = '选择离开时间';
_isSelectLeaveTimeModel = null;
} else if(firstModel != null && lastModel == null) {if(_comparerTime(firstModel, year, month, checkInTime) == Location.left){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<model.list.length; i++) {DayModel dayModel = model.list[i];
dayModel.isSelect = false;
if(_comparerTime(dayModel, year, month, checkInTime) == Location.mid){
dayModel.isSelect = !dayModel.isSelect;
model.firstSelectModel = dayModel;
_isSelectCheckInTime = dayModel.isSelect ? true : false;
_selectCheckInTime = '$year.$month.$checkInTime';
_selectCheckInTimeModel = dayModel;
}
}
}
_checkInDays = 0;
_isSelectLeaveTime = false;
_selectLeaveTime = '选择离开时间';
_isSelectLeaveTimeModel = null;
} else if(_comparerTime(firstModel, year, month, checkInTime) == Location.mid){// 点击了自己
for(int i=0; i<_list.length; i++) {CalendarItemViewModel model = _list[i];
model.lastSelectModel = null;
if(model.month == month){for(int i=0; i<model.list.length; i++) {DayModel dayModel = model.list[i];
if(_comparerTime(dayModel, year, month, checkInTime) == Location.mid){
dayModel.isSelect = !dayModel.isSelect;
model.firstSelectModel = dayModel.isSelect ? dayModel : null;
_selectCheckInTimeModel = dayModel.isSelect ? dayModel : null;
_isSelectCheckInTime = dayModel.isSelect ? true : false;
_selectCheckInTime = dayModel.isSelect ? '$year.$month.$checkInTime' : '选择入住时间';
}
}
}
}
_checkInDays = 0;
_isSelectLeaveTime = false;
_selectLeaveTime = '选择离开时间';
_isSelectLeaveTimeModel = null;
} else if (_comparerTime(firstModel, year, month, checkInTime) == Location.right){if(month == firstModel.month){
// 统计入住天数
int _calculaterDays = 1;
for(int i=0; i<_list.length; i++) {CalendarItemViewModel model = _list[i];
if(model.month == month){for(int i=0; i<model.list.length; i++) {DayModel dayModel = model.list[i];
if(dayModel.dayNum == checkInTime) {
dayModel.isSelect = true;
model.lastSelectModel = dayModel;
_isSelectLeaveTimeModel = dayModel;
_isSelectLeaveTime = true;
_selectLeaveTime = '$year.$month.$checkInTime';
}else if(dayModel.dayNum > firstModel.dayNum && dayModel.dayNum<checkInTime){
dayModel.isSelect = true;
_calculaterDays++;
}
}
}
}
_checkInDays = _calculaterDays;
} else {
// 统计入住天数
int _calculaterDays = 1;
for(int i=0; i<_list.length; i++) {CalendarItemViewModel model = _list[i];
if(model.month == firstModel.month){for(int i=0; i<model.list.length; i++) {DayModel dayModel = model.list[i];
if (dayModel.dayNum > firstModel.dayNum){
dayModel.isSelect = true;
_calculaterDays++;
}
}
} else if(model.month>firstModel.month && model.month<month){for(int i=0; i<model.list.length; i++) {DayModel dayModel = model.list[i];
dayModel.isSelect = true;
_calculaterDays++;
}
} else if(month == model.month){for(int i=0; i<model.list.length; i++) {DayModel dayModel = model.list[i];
if(dayModel.dayNum < checkInTime){
dayModel.isSelect = true;
_calculaterDays++;
} else if (dayModel.dayNum == checkInTime) {
dayModel.isSelect = true;
model.lastSelectModel = dayModel;
_isSelectLeaveTimeModel = dayModel;
_isSelectLeaveTime = true;
_selectLeaveTime = '$year.$month.$checkInTime';
}
}
}
}
_checkInDays = _calculaterDays;
}
}
} else 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<model.list.length; i++) {DayModel dayModel = model.list[i];
dayModel.isSelect = false;
if(_comparerTime(dayModel, year, month, checkInTime) == Location.mid){
dayModel.isSelect = true;
model.firstSelectModel = dayModel;
_isSelectCheckInTime = true;
_selectCheckInTimeModel = dayModel;
_isSelectLeaveTime = false;
_selectCheckInTime = '$year.$month.$checkInTime';
}
}
}
}
}
/*
* 点击日期的回调事件
* */
_updateCheckInLeaveTime(int year, int month, int checkInTime) {
// 更新数据源
_updateDataSource(year, month, checkInTime);
// 刷新 UI
setState(() {});
}
/*
* 底部确定按钮的点击事件
* */
_sureButtonTap() {if(!_isSelectCheckInTime){ShowToast().showToast('请选择入住时间');
return;
} else if (!_isSelectLeaveTime){ShowToast().showToast('请选择离开时间');
return;
}
print('${_selectCheckInTimeModel.year},${_selectCheckInTimeModel.month},${_selectCheckInTimeModel.dayNum}');
print('${_isSelectLeaveTimeModel.year},${_isSelectLeaveTimeModel.month},${_isSelectLeaveTimeModel.dayNum}');
print('入住日期:$_selectCheckInTime, 离开时间:$_selectLeaveTime, 共 $_checkInDays 晚');
// 把日期回调给外部
widget.selectDateOnTap(_selectCheckInTimeModel,_isSelectLeaveTimeModel);
Navigator.pop(context);
}
}
状态设计模式
日历除了 UI 视图外,最麻烦的就是选择日期的各种逻辑
这里我们把它 抽象
成几种 状态
之间的转换:
对应的代码逻辑就是:CalendarPage 页面中更新日历的数据源的方法(_updateDataSource)