iOS 自定义日历(日期选择)控件
iOS 自定义日历(日期选择)控件
[TOC]
前言
作为一个程序员
,当你开发的app
越来越多的时候,或者当你浏览一些app
的时候,你会发现很多模块实现的功能是一样的。而作为开发者
而言,就更加注意这些功能一样的东西了,因为你会发现这个项目中的某个模块完全可以使用以前做项目时封装的一些功能模块,这样你会无比的开心。然后去寻找以前封装的东西,简单的导入和引用就解决了一个功能模块。
日期选择器
可以说是一个经常用到的控件了,只是形式各不相同而已。所以为了满足项目的需求我决定自己研究一下日历控件
的实现方法。
实现 (工程代码见文末链接)
老规矩,先上图
[图片上传失败...(image-337b88-1536583028680)]
工程目录结构
[图片上传失败...(image-52292a-1536583028681)]
-
EngineeringDocuments
:工程头文件
,pch
,类目
,base文件
等。 -
controller
:控制器(YZXSelectDateViewController),日报
,月报
,年报
,自定义
等视图
,都是添加到该控制器的view
上。 -
Model
:用于缓存
和处理
数据。- YZXDateModel:记录年份信息,通过设置的
开始日期
和结束日期
计算两日期之间所有的年份
和月份
数组。 - YZXMonthModel:记录月份信息,主要用于
YZXDateModel
中。 - YZXCalendarModel:记录
月份
的具体信息。(其实应该放在YZXMonthModel
中,可能当时脑子抽筋了...)
- YZXDateModel:记录年份信息,通过设置的
-
Views
:各种view
,用于初始化完整的日历控件
。-
YZXCalendarHelper
:整个工程的manager
(应该放到EngineeringDocuments
目录下的😅,Demo中已修改),可以设置一些基本信息,如:日历
的开始时间
和结束时间
,一些常用的NSDateFormatter
等。 -
YZXWeekMenuView
:日报
中UICollectionView-Section
展示星期
。 -
YZXDaysMenuView
:日报
中展示具体的日期
。 -
YZXCalendarView
:YZXWeekMenuView
和YZXDaysMenuView
,组成完整的日历
。 -
YZXCalendarDelegate
:选择日期后的回调``代理
。 -
DateSelection
:月报
,年报
及其对应的其他视图
。 -
collectionView
:日历控件
主要用UICollectionView
来实现界面的搭建的,所以该文件夹
中都是一些cell
,header
等。
-
下面将详细介绍一下主要
文件
的作用
Manager
YZXCalendarHelper(manager)
YZXCalendarHelper
中主要提供了一下日历控件
相关的设置,比如开始日期
和结束日期
,一些枚举
,还有一些常用的NSDateFormatter
和日期
的比较方法等,方便设置日历控件
,并减少重复代码。具体的实现方法,将在使用到的时候介绍。
日报
和自定义日期
YZXWeekMenuView
初始化一个NSDateFormatter
,使时区
和区域语言
和NSCalendar
相同,然后通过NSDateFormatter
的实例方法veryShortWeekdaySymbols
获取到周符号(S,M,T,W...)
,然后遍历布局,将周末字体设置为红色。
- (NSDateFormatter *)createDateFormatter
{
NSDateFormatter *dateFormatter = [NSDateFormatter new];
dateFormatter.timeZone = self.calendarHelper.calendar.timeZone;
dateFormatter.locale = self.calendarHelper.calendar.locale;
return dateFormatter;
}
NSDateFormatter *formatter = [self createDateFormatter];
NSMutableArray *days = [[formatter veryShortWeekdaySymbols] mutableCopy];
[days enumerateObjectsUsingBlock:^(id _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
UILabel *weekdayLabel = [[UILabel alloc] initWithFrame:CGRectMake(self.bounds.size.width / 7.f * idx, 0, self.bounds.size.width / 7.f, self.bounds.size.height - lineView_height)];
weekdayLabel.text = obj;
weekdayLabel.font = [UIFont systemFontOfSize:10.0];
weekdayLabel.textAlignment = NSTextAlignmentCenter;
weekdayLabel.textColor = CustomBlackColor;
if (idx == 0 || idx == 6) {
weekdayLabel.textColor = CustomRedColor;
}
[self addSubview:weekdayLabel];
}];
YZXDaysMenuView
YZXDaysMenuView.h
/**
自定义初始化
@param frame frame
@param startDateString 日历的开始时间(日期格式:yyyy年MM月dd日)
@param endDateString 日历的结束时间(日期格式:yyyy年MM月dd日)
@return self
*/
- (instancetype)initWithFrame:(CGRect)frame
withStartDateString:(NSString *)startDateString
endDateString:(NSString *)endDateString;
//点击回调代理
@property (nonatomic, weak) id<YZXCalendarDelegate> delegate;
//日历单选
@property (nonatomic, copy) NSString *startDate;
//判断是否为自定义选择(选择日期段)
@property (nonatomic, assign) BOOL customSelect;
//自定义日历(可选择两个时间的范围)
@property (nonatomic, copy) NSArray *dateArray;
//自定义日历,控制可选择的日期的最大跨度
@property (nonatomic, assign) NSInteger maxChooseNumber;
-
initWithFrame:withStartDateString:endDateString:
:根据开始时间
和结束时间
,初始化界面。 -
delegate
:日期选择结束回调。 -
startDate
:日报
单选时,用于记录上次所选日期。 -
customSelect
:判断是否为自定义日历
选择(选择日期段)。 -
dateArray
:自定义日历
时,记录上次选择的日期段。 -
maxChooseNumber
:自定义日历
,设置可选择日期段的最大跨度。
YZXDaysMenuView.m
私有属性部分:
//使用的collectionView实现的界面
@property (nonatomic, strong) UICollectionView *collectionView;
//collectionView数据
@property (nonatomic, copy) NSArray <YZXCalendarModel *> *collectionViewData;
//manager
@property (nonatomic, strong) YZXCalendarHelper *calendarHelper;
//数据
@property (nonatomic, strong) YZXCalendarModel *model;
//用于记录点击的cell
@property (nonatomic, strong) NSMutableArray <NSIndexPath *> *selectedArray;
关键代码实现部分:
获取数据源:YZXCalendarModel
通过传入的startDate
和endDate
,计算日期间隔之间所有的年份
,月份
,天数
等信息。
- 使用
NSCalendar
的实例方法components:fromDate:toDate:options:
得到一个NSDateCompoments
实例,根据设置的components
可以获取到对应的年差值
,月差值
,日差值
等。 - 根据获取到的
dateComponents.month``for循环
,调用NSCalendar
的实例方法dateByAddingComponents:toDate:options:
获取每个月的date
。 - 根据
NSCalendar
的rangeOfUnit:inUnit:forDate
方法,得到该月的天数numberOfDaysInMonth
(得到的是一个NSRange
,.length
获取天数)。 - 根据
NSCalendar
的components:fromDate
方法,获取到一个关于weekday
的NSDateComponents
实例,再通过NSDateComponents
实例的weekday
方法得到该月的第一天firstDayInMonth
是第一个星期
的第几天
(当前日历的每个星期
的第一天
是星期日
)。 - 通过
numberOfDaysInMonth
和firstDayInMonth
计算collectionView
对应的月份
需要多少行item
(一行是一个星期
)。 - 将对应信息缓存到
model
中,然后返回一个model数组
。
- (NSArray<YZXCalendarModel *> *)achieveCalendarModelWithData:(NSDate *)startDate toDate:(NSDate *)endDate
{
NSMutableArray *modelArray = [NSMutableArray array];
NSDateFormatter *formatter = [YZXCalendarHelper helper].yearAndMonthFormatter;
//判断所给年月距离当前年月有多少个月
NSDateComponents *components = [YZXCalendarHelper.helper.calendar components:NSCalendarUnitMonth fromDate:startDate toDate:endDate options:NSCalendarWrapComponents];
//循环遍历得到从给定年月一直到当前年月的所有年月信息
for (NSInteger i = 0; i<=components.month; i++) {
NSDateComponents *monthComponents = [[NSDateComponents alloc] init];
monthComponents.month = i;
NSDate *headerDate = [YZXCalendarHelper.helper.calendar dateByAddingComponents:monthComponents toDate:startDate options:0];
NSString *headerTitle = [formatter stringFromDate:headerDate];
//获取此section所表示月份的天数
NSRange daysOfMonth = [YZXCalendarHelper.helper.calendar rangeOfUnit:NSCalendarUnitDay inUnit:NSCalendarUnitMonth forDate:headerDate];
NSUInteger numberOfDaysInMonth = daysOfMonth.length;
//获取此section所表示月份的第一天是第一个星期的第几天(当前日历的每个星期的第一天是星期日)
NSDateComponents *comps = [YZXCalendarHelper.helper.calendar components:NSCalendarUnitWeekday fromDate:headerDate];
NSInteger firstDayInMonth = [comps weekday];
NSInteger sectionRow = ((numberOfDaysInMonth + firstDayInMonth - 1) % 7 == 0) ? ((numberOfDaysInMonth + firstDayInMonth - 1) / 7) : ((numberOfDaysInMonth + firstDayInMonth - 1) / 7 + 1);
YZXCalendarModel *model = [[YZXCalendarModel alloc] init];
model.numberOfDaysOfTheMonth = numberOfDaysInMonth;
model.firstDayOfTheMonth = firstDayInMonth;
model.headerTitle = headerTitle;
model.sectionRow = sectionRow;
[modelArray addObject:model];
}
return [modelArray copy];
}
UI界面布局:
布局我是通过collectionView
,设置section
表示月
,item
表示日
,item
的个数为之前获取的当月
行数sectionRow
*7,并且你需要比较indexPath.item
与firstDayInMonth
,从而将item
上的text
设置为对应的日期
,并判断今天
的日期,将text
设置为今天
,超过今天
的日期设置为不可选
。
//从每月的第一天开始设置cell.day的值
if (indexPath.item >= firstDayInMonth - 1 && indexPath.item <= firstDayInMonth + model.numberOfDaysOfTheMonth - 2) {
self.day.text = [NSString stringWithFormat:@"%ld",indexPath.item - (firstDayInMonth - 2)];
self.userInteractionEnabled = YES;
}else {
self.day.text = @"";
self.userInteractionEnabled = NO;
}
//今天
if ([YZXCalendarHelper.helper determineWhetherForTodayWithIndexPaht:indexPath model:model] == YZXDateEqualToToday) {
self.day.text = @"今天";
self.day.textColor = CustomRedColor;
}else if ([YZXCalendarHelper.helper determineWhetherForTodayWithIndexPaht:indexPath model:model] == YZXDateLaterThanToday) {//判断日期是否超过今天
self.day.textColor = [UIColor grayColor];
self.userInteractionEnabled = NO;
}
判断item
对应的日期
和今天
的关系:YZXCalendarHelper
- (YZXDateWithTodayType)determineWhetherForTodayWithIndexPaht:(NSIndexPath *)indexPath
model:(YZXCalendarModel *)model
{
//今天
NSDateFormatter *formatter = self.yearMonthAndDayFormatter;
//获取当前cell上表示的天数
NSString *dayString = [NSString stringWithFormat:@"%@%ld日",model.headerTitle,indexPath.item - (model.firstDayOfTheMonth - 2)];
NSDate *dayDate = [formatter dateFromString:dayString];
if (dayDate) {
if ([YZXCalendarHelper.helper date:[NSDate date] isTheSameDateThan:dayDate]) {
return YZXDateEqualToToday;
}else if ([dayDate compare:[NSDate date]] == NSOrderedDescending) {
return YZXDateLaterThanToday;
}else {
return YZXDateEarlierThanToday;
}
}
return NO;
}
点击选择事件:
- 日报(单选,非自定义)
移除默认选中cell
(上次选中cell
),再添加新的选择,并设置cell
样式,最后调用_delegate
方法clickCalendarDate:
将选择的日期
返回。
//移除已选中cell
[self.selectedArray removeAllObjects];
//记录当前点击的按钮
[self.selectedArray addObject:indexPath];
//设置点击的cell的样式
[self p_changeTheSelectedCellStyleWithIndexPath:indexPath];
if (_delegate && [_delegate respondsToSelector:@selector(clickCalendarDate:)]) {
NSString *dateString = [NSString stringWithFormat:@"%@%02d日",self.collectionViewData[indexPath.section].headerTitle,indexPath.item - (self.collectionViewData[indexPath.section].firstDayOfTheMonth - 2)];
[_delegate clickCalendarDate:dateString];
}
- 自定义选择(多选)
- 根据
self.selectedArray
的count
判断是选择第几个时间。- 当
self.selectedArray.count == 0
,表示选择的第一个日期,改变选中cell
样式,并将cell.indexPath
添加到self.selectedArray
中。最后调用delegate
返回数据。 - 当
self.selectedArray.count == 1
,表示选择的第二个日期,通过self.selectedArray
中的indexPath.secton
和indexPath.item
判断第二次选择和第一次选择是否相同,如果相同改变cell
为未选中
样式,移除self.selectedArray
中的数据,并调用delegate
告知父视图
取消选择,最后return
。如果不相同,将两次的选择转换为日期
,通过NSCalendar
的components:fromDate:toDate:options:
计算两个日期
相差多少天,如果设置了maxChooseNumber
最大选择范围,当超过
范围直接return
,如果未设置
或者未超过
,则将点击的NSIndexPath
加入self.selectedArray
,对数组进行一个排序,然后重新转换为日期
,通过delegate
回传数据。 - 当
self.selectedArray.count == 2
,表示重新选择,移除self.selectedArray
中所有的内容,添加此次点击内容,reloadData
更新视图,调用delegate
回调数据。
- 当
- 根据
switch (self.selectedArray.count) {
case 0://选择第一个时间
{
//设置点击的cell的样式
[self p_changeTheSelectedCellStyleWithIndexPath:indexPath];
//记录当前点击的cell
[self.selectedArray addObject:[NSIndexPath indexPathForRow:indexPath.row inSection:indexPath.section]];
if (_delegate && [_delegate respondsToSelector:@selector(clickCalendarWithStartDate:andEndDate:)]) {
NSString *startString = [NSString stringWithFormat:@"%@%02ld日",self.collectionViewData[indexPath.section].headerTitle,indexPath.item - (self.collectionViewData[indexPath.section].firstDayOfTheMonth - 2)];
[_delegate clickCalendarWithStartDate:startString andEndDate:nil];
}
}
break;
case 1://选择第二个时间
{
//如果第二次的选择和第一次的选择一样,则表示取消选择
if (self.selectedArray.firstObject.section == indexPath.section && self.selectedArray.firstObject.item == indexPath.item) {
[self p_recoveryIsNotSelectedWithIndexPath:self.selectedArray.firstObject];
[self.selectedArray removeAllObjects];
if (_delegate && [_delegate respondsToSelector:@selector(clickCalendarWithStartDate:andEndDate:)]) {
[_delegate clickCalendarWithStartDate:nil andEndDate:nil];
}
return;
}
NSString *startDate = [NSString stringWithFormat:@"%@%02d日",self.collectionViewData[self.selectedArray.firstObject.section].headerTitle,self.selectedArray.firstObject.item - (self.collectionViewData[self.selectedArray.firstObject.section].firstDayOfTheMonth - 2)];
NSString *endDate = [NSString stringWithFormat:@"%@%02d日",self.collectionViewData[indexPath.section].headerTitle,indexPath.item - (self.collectionViewData[indexPath.section].firstDayOfTheMonth - 2)];
YZXCalendarHelper *helper = [YZXCalendarHelper helper];
NSDateComponents *components = [helper.calendar components:NSCalendarUnitDay fromDate:[helper.yearMonthAndDayFormatter dateFromString:startDate] toDate:[helper.yearMonthAndDayFormatter dateFromString:endDate] options:0];
//当设置了maxChooseNumber时判断选择的时间段是否超出范围
if (self.maxChooseNumber) {
if (labs(components.day) > self.maxChooseNumber - 1) {
if (_delegate && [_delegate respondsToSelector:@selector(clickCalendarWithStartDate:andEndDate:)]) {
[_delegate clickCalendarWithStartDate:startDate andEndDate:@"error"];
}
return;
}
}
//记录当前点击的cell
[self.selectedArray addObject:[NSIndexPath indexPathForRow:indexPath.item inSection:indexPath.section]];
//对selectedArray进行排序,小的在前,大的在后
[self p_sortingTheSelectedArray];
//排序之后重新确定开始和结束时间
startDate = [NSString stringWithFormat:@"%@%02ld日",self.collectionViewData[self.selectedArray.firstObject.section].headerTitle,self.selectedArray.firstObject.item - (self.collectionViewData[self.selectedArray.firstObject.section].firstDayOfTheMonth - 2)];
endDate = [NSString stringWithFormat:@"%@%02ld日",self.collectionViewData[self.selectedArray.lastObject.section].headerTitle,self.selectedArray.lastObject.item - (self.collectionViewData[self.selectedArray.lastObject.section].firstDayOfTheMonth - 2)];
//时间选择完毕,刷新界面
[self.collectionView reloadData];
//代理返回数据
if (_delegate && [_delegate respondsToSelector:@selector(clickCalendarWithStartDate:andEndDate:)]) {
[_delegate clickCalendarWithStartDate:startDate andEndDate:endDate];
}
}
break;
case 2://重新选择
{
//重新选择时,将之前点击的cell恢复成为点击状态,并移除数组中所有对象
[self.selectedArray removeAllObjects];
//记录当前点击的cell
[self.selectedArray addObject:indexPath];
[self.collectionView reloadData];
//
if (_delegate && [_delegate respondsToSelector:@selector(clickCalendarWithStartDate:andEndDate:)]) {
NSString *startString = [NSString stringWithFormat:@"%@%02ld日",self.collectionViewData[indexPath.section].headerTitle,indexPath.item - (self.collectionViewData[indexPath.section].firstDayOfTheMonth - 2)];
[_delegate clickCalendarWithStartDate:startString andEndDate:nil];
}
}
break;
default:
break;
}
设置界面事件:
通过传入的日期
,遍历数据源
,当headerTitle
和传入日期相同时,获取section
,再通过firstDayOfTheMonth
计算对应的item
,获取到对应的NSIndexPath
,记录其NSIndexPath
,reloadData
刷新。
- (void)setStartDate:(NSString *)startDate
{
_startDate = startDate;
if (!_startDate) {
return;
}
//传入一个时间时,查找其indexPath信息,用在collectionView上展现
[self.collectionViewData enumerateObjectsUsingBlock:^(YZXCalendarModel * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
if ([obj.headerTitle isEqualToString:[_startDate substringWithRange:NSMakeRange(0, 8)]]) {
NSInteger day = [_startDate substringWithRange:NSMakeRange(8, 2)].integerValue;
[self.selectedArray addObject:[NSIndexPath indexPathForItem:(day + obj.firstDayOfTheMonth - 2) inSection:idx]];
[_collectionView selectItemAtIndexPath:[NSIndexPath indexPathForItem:(self.collectionViewData[idx].sectionRow * 7 - 1) inSection:idx] animated:YES scrollPosition:UICollectionViewScrollPositionBottom];
*stop = YES;
}
}];
[_collectionView reloadData];
}
- (void)setDateArray:(NSArray *)dateArray
{
_dateArray = dateArray;
if (!_dateArray) {
return;
}
//传入两个时间时,查找其indexPath信息,用在collectionView上展现
[self.collectionViewData enumerateObjectsUsingBlock:^(YZXCalendarModel * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
if ([obj.headerTitle isEqualToString:[_dateArray.firstObject substringWithRange:NSMakeRange(0, 8)]]) {
NSInteger day = [_dateArray.firstObject substringWithRange:NSMakeRange(8, 2)].integerValue;
[self.selectedArray addObject:[NSIndexPath indexPathForItem:(day + obj.firstDayOfTheMonth - 2) inSection:idx]];
}
if ([obj.headerTitle isEqualToString:[_dateArray.lastObject substringWithRange:NSMakeRange(0, 8)]]) {
NSInteger day = [_dateArray.lastObject substringWithRange:NSMakeRange(8, 2)].integerValue;
[self.selectedArray addObject:[NSIndexPath indexPathForItem:(day + obj.firstDayOfTheMonth - 2) inSection:idx]];
[_collectionView selectItemAtIndexPath:[NSIndexPath indexPathForItem:(self.collectionViewData[idx].sectionRow * 7 - 1) inSection:idx] animated:YES scrollPosition:UICollectionViewScrollPositionBottom];
}
}];
[_collectionView reloadData];
}
YZXCalendarView
将YZXWeekMenuView
和YZXDaysMenuView
组合在一起就组成了一个日历控件(日期选择)
,这里就不多介绍了。
到这里,日报
和自定义日期
的功能基本完成了。
月报
与年报
YZXMonthlyReportView(月报)
YZXAnnualReportView(年报)
月报
的布局这里采用的是两个UITableView
,一个展示年份
,一个展示月份
(年报
直接一个UITableView
就展示完成了)。对于月报
和年报
的实现对数据源
的处理等和日报
就一样了,在这里就不啰嗦了,具体的可以去下载Demo看看。
最后
其实日历控件
的样式有很多方式,就看你想怎样的了。但是内容的展示都逃不过NSCalendar
及其相关的API
了,只要了解了NSCalendar
,再动一下脑子,计算一下具体日期
就差不多了。Demo下载(已适配iPhone X)