iOS 自定义日历(日期选择)控件
[TOC]
前言
作为一个程序员,当你开发的app越来越多的时候,或者当你浏览一些app的时候,你会发现很多模块实现的功能是一样的。而作为开发者而言,就更加注意这些功能一样的东西了,因为你会发现这个项目中的某个模块完全可以使用以前做项目时封装的一些功能模块,这样你会无比的开心。然后去寻找以前封装的东西,简单的导入和引用就解决了一个功能模块。
日期选择器可以说是一个经常用到的控件了,只是形式各不相同而已。所以为了满足项目的需求我决定自己研究一下日历控件的实现方法。
实现 (工程代码见文末链接)
老规矩,先上图

工程目录结构

EngineeringDocuments:工程头文件,pch,类目,base文件等。
controller:控制器(YZXSelectDateViewController),日报,月报,年报,自定义等视图,都是添加到该控制器的view上。
Model:用于缓存和处理数据。
- YZXDateModel:记录年份信息,通过设置的
开始日期和结束日期计算两日期之间所有的年份和月份数组。
- YZXMonthModel:记录月份信息,主要用于
YZXDateModel中。
- YZXCalendarModel:记录
月份的具体信息。(其实应该放在YZXMonthModel中,可能当时脑子抽筋了…)
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...),然后遍历布局,将周末字体设置为红色。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| - (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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
|
- (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
私有属性部分:
1 2 3 4 5 6 7 8 9 10
| @property (nonatomic, strong) UICollectionView *collectionView;
@property (nonatomic, copy) NSArray <YZXCalendarModel *> *collectionViewData;
@property (nonatomic, strong) YZXCalendarHelper *calendarHelper;
@property (nonatomic, strong) YZXCalendarModel *model;
@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数组。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
| - (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]; NSRange daysOfMonth = [YZXCalendarHelper.helper.calendar rangeOfUnit:NSCalendarUnitDay inUnit:NSCalendarUnitMonth forDate:headerDate]; NSUInteger numberOfDaysInMonth = daysOfMonth.length; 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设置为今天,超过今天的日期设置为不可选。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42
| 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**
```Objc - (YZXDateWithTodayType)determineWhetherForTodayWithIndexPaht:(NSIndexPath *)indexPath model:(YZXCalendarModel *)model { NSDateFormatter *formatter = self.yearMonthAndDayFormatter; 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:将选择的日期返回。
1 2 3 4 5 6 7 8 9 10 11
| [self.selectedArray removeAllObjects];
[self.selectedArray addObject:indexPath];
[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回调数据。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77
| switch (self.selectedArray.count) { case 0: { [self p_changeTheSelectedCellStyleWithIndexPath:indexPath]; [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]; if (self.maxChooseNumber) { if (labs(components.day) > self.maxChooseNumber - 1) { if (_delegate && [_delegate respondsToSelector:@selector(clickCalendarWithStartDate:andEndDate:)]) { [_delegate clickCalendarWithStartDate:startDate andEndDate:@"error"]; } return; } } [self.selectedArray addObject:[NSIndexPath indexPathForRow:indexPath.item inSection:indexPath.section]]; [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: { [self.selectedArray removeAllObjects]; [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刷新。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40
| - (void)setStartDate:(NSString *)startDate { _startDate = startDate; if (!_startDate) { return; } [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; } [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)