yinxing29

iOS 自定义日历(日期选择)控件

2018/05/14 Share

iOS 自定义日历(日期选择)控件

[TOC]

前言

作为一个程序员,当你开发的app越来越多的时候,或者当你浏览一些app的时候,你会发现很多模块实现的功能是一样的。而作为开发者而言,就更加注意这些功能一样的东西了,因为你会发现这个项目中的某个模块完全可以使用以前做项目时封装的一些功能模块,这样你会无比的开心。然后去寻找以前封装的东西,简单的导入和引用就解决了一个功能模块。

日期选择器可以说是一个经常用到的控件了,只是形式各不相同而已。所以为了满足项目的需求我决定自己研究一下日历控件的实现方法。

实现 (工程代码见文末链接)

老规矩,先上图

日历控件-w453

工程目录结构

日历控件-目录结构-w230

  • EngineeringDocuments:工程头文件pch类目base文件等。
  • controller:控制器(YZXSelectDateViewController),日报月报年报自定义视图,都是添加到该控制器的view上。
  • Model:用于缓存处理数据。
    • YZXDateModel:记录年份信息,通过设置的开始日期结束日期计算两日期之间所有的年份月份数组。
    • YZXMonthModel:记录月份信息,主要用于YZXDateModel中。
    • YZXCalendarModel:记录月份的具体信息。(其实应该放在YZXMonthModel中,可能当时脑子抽筋了…)
  • Views:各种view,用于初始化完整的日历控件
    • YZXCalendarHelper:整个工程的manager(应该放到EngineeringDocuments目录下的😅,Demo中已修改),可以设置一些基本信息,如:日历开始时间结束时间,一些常用的NSDateFormatter等。
    • YZXWeekMenuView:日报UICollectionView-Section展示星期
    • YZXDaysMenuView:日报中展示具体的日期
    • YZXCalendarView:YZXWeekMenuViewYZXDaysMenuView,组成完整的日历
    • YZXCalendarDelegate:选择日期后的回调`代理`。
    • DateSelection:月报年报及其对应的其他视图
    • collectionView:日历控件主要用UICollectionView来实现界面的搭建的,所以该文件夹中都是一些cellheader等。

下面将详细介绍一下主要文件的作用

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
/**
自定义初始化

@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

私有属性部分:

1
2
3
4
5
6
7
8
9
10
//使用的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
通过传入的startDateendDate,计算日期间隔之间所有的年份月份天数等信息。

  1. 使用NSCalendar的实例方法components:fromDate:toDate:options:得到一个NSDateCompoments实例,根据设置的components可以获取到对应的年差值月差值日差值等。
  2. 根据获取到的dateComponents.month`for循环,调用NSCalendar的实例方法dateByAddingComponents:toDate:options:获取每个月的date`。
  3. 根据NSCalendarrangeOfUnit:inUnit:forDate方法,得到该月的天数numberOfDaysInMonth(得到的是一个NSRange.length获取天数)。
  4. 根据NSCalendarcomponents:fromDate方法,获取到一个关于weekdayNSDateComponents实例,再通过NSDateComponents实例的weekday方法得到该月的第一天firstDayInMonth第一个星期第几天(当前日历的每个星期第一天星期日)。
  5. 通过numberOfDaysInMonthfirstDayInMonth计算collectionView对应的月份需要多少行item(一行是一个星期)。
  6. 将对应信息缓存到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];

//获取此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.itemfirstDayInMonth,从而将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
//从每月的第一天开始设置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**

```Objc
- (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:将选择的日期返回。
1
2
3
4
5
6
7
8
9
10
11
//移除已选中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.selectedArraycount判断是选择第几个时间。
      1. self.selectedArray.count == 0,表示选择的第一个日期,改变选中cell样式,并将cell.indexPath添加到self.selectedArray中。最后调用delegate返回数据。
      2. self.selectedArray.count == 1,表示选择的第二个日期,通过self.selectedArray中的indexPath.sectonindexPath.item判断第二次选择和第一次选择是否相同,如果相同改变cell未选中样式,移除self.selectedArray中的数据,并调用delegate告知父视图取消选择,最后return。如果不相同,将两次的选择转换为日期,通过NSCalendarcomponents:fromDate:toDate:options:计算两个日期相差多少天,如果设置了maxChooseNumber最大选择范围,当超过范围直接return,如果未设置或者未超过,则将点击的NSIndexPath加入self.selectedArray,对数组进行一个排序,然后重新转换为日期,通过delegate回传数据。
      3. 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://选择第一个时间
{
//设置点击的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,记录其NSIndexPathreloadData刷新。

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;
}
//传入一个时间时,查找其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

YZXWeekMenuViewYZXDaysMenuView组合在一起就组成了一个日历控件(日期选择),这里就不多介绍了。

到这里,日报自定义日期的功能基本完成了。

月报年报

YZXMonthlyReportView(月报)
YZXAnnualReportView(年报)

月报的布局这里采用的是两个UITableView,一个展示年份,一个展示月份年报直接一个UITableView就展示完成了)。对于月报年报的实现对数据源的处理等和日报就一样了,在这里就不啰嗦了,具体的可以去下载Demo看看。

最后

其实日历控件的样式有很多方式,就看你想怎样的了。但是内容的展示都逃不过NSCalendar及其相关的API了,只要了解了NSCalendar,再动一下脑子,计算一下具体日期就差不多了。Demo下载(已适配iPhone X)

原文作者: yinxing29

原文链接: http://yinxing29.github.io/2018/05/14/iOS-自定义日历(日期选择)控件/

发表日期: May 14th 2018, 7:40:17

版权声明: 未经同意,禁止转载

CATALOG
  1. 1. iOS 自定义日历(日期选择)控件
    1. 1.1. 前言
    2. 1.2. 实现 (工程代码见文末链接)
      1. 1.2.1. Manager
      2. 1.2.2. 日报和自定义日期
      3. 1.2.3. 月报与年报
    3. 1.3. 最后