iOS 自定義日曆(日期選擇)控制元件

yinxing29發表於2018-03-23

前言

作為一個程式設計師,當你開發的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:YZXWeekMenuViewYZXDaysMenuView,組成完整的日曆
    • YZXCalendarDelegate:選擇日期後的回撥``代理
    • DateSelection:月報年報及其對應的其他檢視
    • collectionView:日曆控制元件主要用UICollectionView來實現介面的搭建的,所以該資料夾中都是一些cellheader等。

下面將詳細介紹一下主要檔案的作用

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 通過傳入的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陣列
- (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設定為今天,超過今天的日期設定為不可選

//從每月的第一天開始設定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.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回撥資料。
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重新整理。

- (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)**

相關文章