红衣佳人白衣友,朝与同歌暮同酒。
世人皆谓慕长安,然吾只恋长安某。
前言
最近我们的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)
源码地址
链接描述