今是昨非

今是昨非

日出江花红胜火,春来江水绿如蓝

iOS 工作日——過濾法定節假日日曆提醒的實現

iOS 工作日 —— 過濾法定節假日日曆提醒的實現#

背景#

筆者五一之前補班的時候,鬧鐘沒響,早上差點遲到了。筆者鬧鐘設定的是週一到週五,iPhone 沒有法定節假日的設定,也沒有補休的設定。。。。筆者就想要解決這個痛點,夢想著,要是做出來了,發布到商店,從此走上人生巔峰,贏取白。。。。

YY 過後,回過頭來,接著調研,法定節假日鬧鐘的實現,筆者查找了很多資料,發現不用做夢了。首先 iOS 程序添加鬧鐘到時鐘 APP 是不允許的。。。其次,iOS 也沒有法定節假日的判斷。。。。所以不用 YY 了。但是筆者還真找到了iOS 自定義鬧鐘 —— 中國法定節假日 (升級版)這個,通過快捷指令自定義鬧鐘,可以實現過濾法定節假日。原理是:設定鬧鐘,然後通過快捷指令的自動執行,每天在鬧鐘時間前,通過訂閱的別人維護的日曆或者自己本地維護日曆,判斷當天是否是節假日,然後決定當天的鬧鐘是否打開、關閉。筆者不得不讚一個,真的優秀。

雖然筆者的發財夢夭折了。。。但筆者想到了另一個,雖然 iOS 程序不能直接添加鬧鐘,但是 iOS 程序可以直接添加日曆提醒啊,比如預約直播或者預約搶購的,其實都是添加事件到日曆中,然後在指定的時間,彈出來日曆提醒去做什麼,也不是不可以用。那是否能用日曆提醒來實現,法定工作日的提醒呢。。。比如每個工作日提醒打卡。或者只針對節假日補班提醒,每個補班前天晚上提醒設定鬧鐘。

實現#

iPhone 添加日曆提醒的實現很簡單,難的地方還是在於國內法定節假日的判斷,怎麼能過濾掉法定節假日,實現真正純工作日的時候提醒?

第一步,先創建週一到週五的重複事件#

筆者又調研了一番,發現日曆提醒中有一個EKRecurrenceRule的規則選項,是否能用這個來實現呢?

EKRecurrenceRule是什麼?

官方解釋:

A class that describes the recurrence pattern for a recurring event.

筆者理解:
重複事件的重複規則。簡單的說,就是定義一個重複規則,比如每週重複、每天重複、每隔幾天重複類似的,然後按照這個規則添加事件。

看到這個,筆者的心涼了半截,重複的規則,對於國內法定節假日來說。。。。除了五一、國慶、元旦之外,農曆的節日重複的規則找不到。。。怎麼辦?筆者尋思著都到這一步了,就先做個週一到週五的,也算是需求完成了半個,工作日的那部分完成了,剩下的那部分過濾法定節假日和補休,慢慢看,又不是不用😂

先來看設定每週一到週五的循環日曆事件

添加日曆事件
添加日曆事件的步驟如下:

  1. 獲取讀寫日曆權限
  2. 創建單獨的日曆
  3. 生成週一到週五的規則
  4. 根據標題、地址、規則和時間生成日曆事件
  5. 添加事件到日曆 判斷生成的事件是否已經添加,已添加則不操作,沒添加則添加

下面一步步來看:

  1. 獲取讀寫日曆權限

    首先需要在 plist 中添加Privacy - Calendars Usage Description權限,然後使用下面代碼申請權限

    
    lazy fileprivate var store = EKEventStore()
    
    // MARK: utils
    // 申請日曆權限
    func requestEventAuth(_ callback:((Bool) -> Void)?) {
        store.requestAccess(to: EKEntityType.event) { granted, error in
            callback?(granted)
        }
    }
    
    
  2. 創建單獨的日曆

    用於保證不和其它日曆衝突,而且不顯示或者移除時方便,建議每個自定義日曆事件的都單獨定義一個日曆。

    聽起來有些繞,打開 iPhone,打開日曆,然後點擊底部中間的日曆按鈕,就能看到自己的所有日曆。看圖如下,"自定義的事項日曆" 即是筆者自定義的日曆,筆者所添加的日曆事件都會在這個日曆中,如果不想要看到這些事件,可以直接把前面的勾選去除,日曆中就不會顯示自定義的日曆事件了。或者想要刪除這個日曆中的所有事件時,只需要把這個日曆刪掉即可,不需要一條條事件刪除,點擊右邊的提示按鈕,然後滑動到最下方就有刪除日曆的按鈕。

    Ps:默默的吐槽,不知道為啥預約搶購和預約直播提醒的,不單獨建一個日曆。。。。筆者預約了之後感覺煩,每次都得手動去刪除事件

    image

    創建日曆的代碼如下,注意 calendar 的 source 的設定,source 設定為什麼,最後添加的日曆會顯示在哪個地方

    
     // 創建新的日曆
     func createNewCalendar() {
         guard let calendarId = MKCalendarReminderUtil.userDefaultsSaveStr(kCustomCalendarId),
               let _ = store.calendar(withIdentifier: calendarId) else { // 说明本地已经创建了当前日历
             return
         }
         
         let calendar = EKCalendar(for: EKEntityType.event, eventStore: store)
         for item in store.sources {
             if item.title == "iCloud" || item.title == "Default" {
                 calendar.source = item
                 break
             }
         }
         
         calendar.title = "自定義的事項日曆" // 自定義日曆標題
         calendar.cgColor = UIColor.systemPurple.cgColor // 自定義日曆顏色
         
         do {
             try store.saveCalendar(calendar, commit: true)
             MKCalendarReminderUtil.saveToUserDefaults(kCustomCalendarId, valueStr: calendar.calendarIdentifier)
         }
         catch {
             print(error)
         }
     }
    
    
  3. 生成週一到週五的規則

    使用EKRecurrenceRule生成每週一到週五重複的規則。EKRecurrenceRule的使用如下,其中EKRecurrenceRule(recurrenceWith:interval:daysOfTheWeek:daysOfTheMonth:monthsOfTheYear:weeksOfTheYear:daysOfTheYear:setPositions:end:)初始化方法各參數意義如下:

    • recurrenceWith: EKRecurrenceFrequency, 代表重複頻率,可設定:按天、週、月、年的重複頻率
    • interval: Int, 代表重複間隔,每個多久重複,不能為 0
    • daysOfTheWeek: [EKRecurrenceDayOfWeek], 每週哪幾天重複,設定之後,除了按天的重複頻率外,都可以生效
    • daysOfTheMonth: [number], number 取值 1-31,也可以為負數,負數說明是從月底開始,比如 - 1 是該月最後一天。只有在設定了按月重複頻率下生效
    • monthsOfTheYear: [number], number 取值 1-12,只有在設定了按年重複頻率下生效
    • weeksOfTheYear: [number], number 取值 1-53,也可以為負數,負數說明是從年底開始。只有在設定了按年重複頻率下生效
    • daysOfTheYear: [number], number 取值 1-366,也可以為負數,負數說明是從年底開始。只有在設定了按年重複頻率下生效
    • setPositions: [number], number 取值 1-366,也可以為負數,負值表示反向計算,過濾其它規則的過濾器,在設定了 daysOfTheWeek, daysOfTheMonth, monthsOfTheYear, weeksOfTheYear, daysOfTheYear 之後有效
    • end: EKRecurrenceEnd, 重複截止日期
    
     // 生成重複的規則
     func generateEKRecurrenceRule() -> EKRecurrenceRule {
         let monday = EKRecurrenceDayOfWeek(EKWeekday.monday)
         let tuesday = EKRecurrenceDayOfWeek(EKWeekday.tuesday)
         let wednesday = EKRecurrenceDayOfWeek(EKWeekday.wednesday)
         let thursday = EKRecurrenceDayOfWeek(EKWeekday.thursday)
         let friday = EKRecurrenceDayOfWeek(EKWeekday.friday)
         
         // 設定按重複頻率為按週重複,重複間隔為每週都重複,一週中的週一、週二、週三、週四、週五重複
         let rule = EKRecurrenceRule(recurrenceWith: EKRecurrenceFrequency.weekly,
                                     interval: 1,
                                     daysOfTheWeek: [monday, tuesday, wednesday, thursday, friday],
                                     daysOfTheMonth: nil,
                                     monthsOfTheYear: nil,
                                     weeksOfTheYear: nil,
                                     daysOfTheYear: nil,
                                     setPositions: nil,
                                     end: nil)
         return rule
     }
    
    
  4. 根據標題、地址、備註、規則和時間生成日曆事件

    生成日曆事件時,要注意事件的持續時間,以及是否添加鬧鐘提示。這個鬧鐘提示不是通常意義的鬧鐘,是日程提醒,比如設定了事件的鬧鐘提示,在達到鬧鐘提醒時間後,會提醒響鈴,且在通知欄彈出。

    
    // 生成日曆事件
    func generateEvent(_ title: String?, location: String?, notes: String?, timeStr: String?) -> EKEvent {
        let event = EKEvent(eventStore: store)
        event.title = title
        event.location = location
        event.notes = notes
        
        // 事件的時間
        if let date = Date.date(from: timeStr, formatterStr: "yyyy-MM-dd HH:mm:ss") {
            // 開始
            let startDate = Date(timeInterval: 0, since: date)
            // 結束
            let endDate = Date(timeInterval: 60, since: date)
            
            // 日曆提醒持續時間
            event.startDate = startDate
            event.endDate = endDate
            event.isAllDay = false
        }
        else {
            // 全天提醒
            event.isAllDay = true
        }
    
        // 添加重複規則
        let recurrenceRule = generateEKRecurrenceRule()
        event.addRecurrenceRule(recurrenceRule)
    
        // 添加鬧鐘結合(開始前多少秒)若為正則是開始後多少秒。
        let alarm = EKAlarm(relativeOffset: 0)
        event.addAlarm(alarm)
        
        if let calendarId = MKCalendarReminderUtil.userDefaultsSaveStr(kCustomCalendarId),
           let calendar = store.calendar(withIdentifier: calendarId) {
            event.calendar = calendar
        }
        
        return event
    }
    
    
  5. 添加事件到日曆

    添加時,需要判斷生成的事件是否已經添加,已添加則不操作,沒添加則添加。添加成功後,把事件 ID 存儲起來,避免重複添加同一個事件

    
        // 添加事件到日曆
    func addEvent(_ title: String?, location: String?, notes: String?, timeStr: String, eventKey: String) {
        requestEventAuth { [weak self] granted in
            if granted {
                // 先創建日曆
                self?.createNewCalendar()
                
                // 判斷事件是否存在
                if let eventId = MKCalendarReminderUtil.userDefaultsSaveStr(eventKey),
                   let _ = self?.store.event(withIdentifier: eventId) {
                    // 事件已添加
                    return
                }
                else {
                    if let event = self?.generateEvent(title, location: location, notes: notes, timeStr: timeStr) {
                        do {
                            try self?.store.save(event, span: EKSpan.thisEvent, commit: true)
                            //添加成功後需要保存日曆關鍵字
                            // 保存在沙盒,避免重複添加等其他判斷
                            MKCalendarReminderUtil.saveToUserDefaults(eventKey, valueStr: event.eventIdentifier)
                        }
                        catch {
                            print(error)
                        }
                    }
                }
            }
        }
    }
    
    

從外部使用下面代碼調用


let date = Date.beijingDate()
let timeStr = Date.string(from: date, formatterStr: "yyyy-MM-dd HH:mm:ss")
MKCalendarReminderUtil.util.addEvent("自定義標題", location: "上海東方明珠", notes: "記得拍照打卡", timeStr: timeStr!, eventKey: "自定義標題")

會先彈出授權訪問日曆的提示框,點擊允許後,成功添加到日曆,然後去日曆中可以看到,日曆中從當天開始的,每週一至週五都有事件存在

image

點開具體的日期,可以看到當天日期的所有事件,點擊添加的事件

image

可以看到事件的標題、地址、持續時間、重複頻率、所屬日曆以及備註

image

至此,筆者已經成功添加了週一到週五重複提醒的事件,已經算是完成了一半,勉強能用,就是遇到節假日時,補班、調休的時候會錯誤提醒。所以還有後面的一般,怎麼把節假日的邏輯加入到事件中?

第二步,添加法定節假日邏輯#

筆者一直想的是添加法定節假日的邏輯,一開始其實就陷入了誤區,一直想的是,是否有一個規則,按照這個規則,能自動過濾掉節假日和添加補班,然後生成重複日曆事件。然而並沒有這樣的規則存在。

參考快捷指令節假日鬧鐘的實現,筆者就想到了另一種方式,如果沒有直接節假日的規則,那能否分兩步走?第一步先創建週一到週五的固定重複邏輯;第二步,從某個地方獲取到節假日和補班信息,然後根據信息,在第一步的基礎上,“多退少補”,即屬於節假日的週一至週五的事件移除,屬於補班的沒有日曆事件的則添加事件。

那這種方案是否可行呢?實踐出真知!

步驟如下:

  1. 獲取節假日和補班信息
    從哪裡能獲取到節假日和補班信息呢?筆者去網上查找了一番,最終看到了有兩個合適的訂閱來源holiday-cn節假日 API

    • holiday-cn:自動每日抓取國務院公告,返回節假日和補班信息
    • 節假日 API:是由私人維護的 API,支持多種 API 接口訪問,傳入月份、傳入日期、傳入年份等等

    對於筆者來說,holiday-cn已滿足,故而筆者選用了holiday-cn。當然如果公司支持,也可以在公司服務端維護一份節假日信息,能保證各端統一。甚至也可以維護在客戶端一份本地 json,等下一年的節假日信息出來後,再更新客戶端本地的。

    返回節假日 JSON 格式如下,name是節假日名稱,date是節假日日期,isOffDay代表是否是休息,比如2021-09-18是中秋節的補班

    
    {
      "$schema":"https://raw.githubusercontent.com/NateScarlet/holiday-cn/master/schema.json",
      "$id":"https://raw.githubusercontent.com/NateScarlet/holiday-cn/master/2021.json",
      "year":2021,
      "papers":[
          "http://www.gov.cn/zhengce/content/2020-11/25/content_5564127.htm"
      ],
      "days":[
          {
              "name":"元旦",
              "date":"2021-01-01",
              "isOffDay":true
          },
          {
              "name":"元旦",
              "date":"2021-01-02",
              "isOffDay":true
          },
          {
              "name":"元旦",
              "date":"2021-01-03",
              "isOffDay":true
          },
          {
              "name":"春節",
              "date":"2021-02-07",
              "isOffDay":false
          },
          {
              "name":"春節",
              "date":"2021-02-11",
              "isOffDay":true
          },
          {
              "name":"春節",
              "date":"2021-02-12",
              "isOffDay":true
          },
          {
              "name":"春節",
              "date":"2021-02-13",
              "isOffDay":true
          },
          {
              "name":"春節",
              "date":"2021-02-14",
              "isOffDay":true
          },
          {
              "name":"春節",
              "date":"2021-02-15",
              "isOffDay":true
          },
          {
              "name":"春節",
              "date":"2021-02-16",
              "isOffDay":true
          },
          {
              "name":"春節",
              "date":"2021-02-17",
              "isOffDay":true
          },
          {
              "name":"春節",
              "date":"2021-02-20",
              "isOffDay":false
          },
          {
              "name":"清明節",
              "date":"2021-04-03",
              "isOffDay":true
          },
          {
              "name":"清明節",
              "date":"2021-04-04",
              "isOffDay":true
          },
          {
              "name":"清明節",
              "date":"2021-04-05",
              "isOffDay":true
          },
          {
              "name":"勞動節",
              "date":"2021-04-25",
              "isOffDay":false
          },
          {
              "name":"勞動節",
              "date":"2021-05-01",
              "isOffDay":true
          },
          {
              "name":"勞動節",
              "date":"2021-05-02",
              "isOffDay":true
          },
          {
              "name":"勞動節",
              "date":"2021-05-03",
              "isOffDay":true
          },
          {
              "name":"勞動節",
              "date":"2021-05-04",
              "isOffDay":true
          },
          {
              "name":"勞動節",
              "date":"2021-05-05",
              "isOffDay":true
          },
          {
              "name":"勞動節",
              "date":"2021-05-08",
              "isOffDay":false
          },
          {
              "name":"端午節",
              "date":"2021-06-12",
              "isOffDay":true
          },
          {
              "name":"端午節",
              "date":"2021-06-13",
              "isOffDay":true
          },
          {
              "name":"端午節",
              "date":"2021-06-14",
              "isOffDay":true
          },
          {
              "name":"中秋節",
              "date":"2021-09-18",
              "isOffDay":false
          },
          {
              "name":"中秋節",
              "date":"2021-09-19",
              "isOffDay":true
          },
          {
              "name":"中秋節",
              "date":"2021-09-20",
              "isOffDay":true
          },
          {
              "name":"中秋節",
              "date":"2021-09-21",
              "isOffDay":true
          },
          {
              "name":"國慶節",
              "date":"2021-09-26",
              "isOffDay":false
          },
          {
              "name":"國慶節",
              "date":"2021-10-01",
              "isOffDay":true
          },
          {
              "name":"國慶節",
              "date":"2021-10-02",
              "isOffDay":true
          },
          {
              "name":"國慶節",
              "date":"2021-10-03",
              "isOffDay":true
          },
          {
              "name":"國慶節",
              "date":"2021-10-04",
              "isOffDay":true
          },
          {
              "name":"國慶節",
              "date":"2021-10-05",
              "isOffDay":true
          },
          {
              "name":"國慶節",
              "date":"2021-10-06",
              "isOffDay":true
          },
          {
              "name":"國慶節",
              "date":"2021-10-07",
              "isOffDay":true
          },
          {
              "name":"國慶節",
              "date":"2021-10-09",
              "isOffDay":false
          }
        ]
     }
    
    

    代碼如下

    
    fileprivate func filterHolidayInfo(with title: String?, location: String?, notes: String?, timeStr: String, eventKey: String) {
        guard let url = URL(string: "https://natescarlet.coding.net/p/github/d/holiday-cn/git/raw/master/2021.json") else {
            return
        }
        let task = URLSession.shared.dataTask(with: url) { [weak self] (data, response, error) in
            guard let data = data else { return }
            
            do {
                if let jsonResult = try JSONSerialization.jsonObject(with: data, options: JSONSerialization.ReadingOptions.mutableContainers) as? NSDictionary,
                   let days = jsonResult["days"] as? [NSDictionary] {
                    // 過濾節假日
                    self?.handleHolidayInfo(with: days, title: title, location: location, notes: notes, timeStr: timeStr, eventKey: eventKey)
                }
            } catch {
                print(error)
            }
        }
    
        task.resume()
    }
    
    
  2. “多退少補”

    即屬於節假日的週一至週五的事件移除,屬於補班的沒有日曆事件的則添加事件。這裡需要判斷,某天日期是否有當前的事件。

    
    // 判斷某天,是否有指定的事件
    fileprivate func eventExist(on tdate: Date?, eventKey: String) -> EKEvent? {
        var resultEvent: EKEvent?
        
        guard let date = tdate else {
            return resultEvent
        }
        
        let endDate = date.addingTimeInterval(TimeInterval(24 * 60 * 60))
        guard let calendarId = MKCalendarReminderUtil.userDefaultsSaveStr(kCustomCalendarId),
              let eventId = MKCalendarReminderUtil.userDefaultsSaveStr(eventKey),
              let calendar = store.calendar(withIdentifier: calendarId) else {
            return resultEvent
        }
        
        let predicate = store.predicateForEvents(withStart: date, end: endDate, calendars: [calendar])
        let events = store.events(matching: predicate)
        for event in events {
            if event.eventIdentifier == eventId {
                resultEvent = event
                break
            }
        }
        return resultEvent
    }
    
    // "多退少補"
    fileprivate func handleHolidayInfo(with days: [NSDictionary], title: String?, location: String?, notes: String?, timeStr: String, eventKey: String) {
        for dayDic in days {
            if let dayStr = dayDic["date"] as? String,
               let date = Date.date(from: dayStr, formatterStr: "yyyy-MM-dd"), // 日期
               let isOffDay = dayDic["isOffDay"] as? Bool { // 是否上班
                let interval = date.timeIntervalSince(date)
                
                // 1. 判斷獲取到的日期小於當前日期,說明是以前的日期,不處理
                // 2. 判斷日期大於等於當前日期後,判斷是否休息,判斷日期那天是否有要添加的事件,
                // 3. 休息,有事件,則移除事件
                // 4. 未休息,無事件,則添加事件
                if interval < 0 {
                    continue
                }
                else {
                    if let targetEvent = eventExist(on: date, eventKey: eventKey) {
                        // 事件存在
                        if isOffDay { // 休息日
                            do {
                                try store.remove(targetEvent, span: EKSpan.thisEvent)
                            } catch {
                                print(error)
                            }
                        }
                    }
                    else {
                        // 事件不存在
                        if !isOffDay { // 非休息日,即要補班
                            let event = generateEvent(title, location: location, notes: notes, timeStr: timeStr)
                            do {
                                try store.save(event, span: EKSpan.thisEvent)
                            } catch {
                                print(error)
                            }
                        }
                    }
                }
            }
        }
    }
    
    

這個地方還有個問題需要注意,筆者在生成事件generateEvent的方法中,添加了重複規則,如果不修改的話,最後休息日補班調用生成事件方法時會有問題。所以這個地方要把事件重複規則的邏輯從通用的generateEvent方法中抽出來。放到addEvent方法的 save 之前。

最後運行調試,調用代碼如下:


let date = Date.beijingDate()
let timeStr = Date.string(from: date, formatterStr: "yyyy-MM-dd HH:mm:ss")
MKCalendarReminderUtil.util.addEvent("自定義標題2", location: "上海東方明珠2", notes: "記得拍照打卡2", timeStr: timeStr!, eventKey: "自定義標題2", filterHoliday: true)


最終結果如下:

image

可以看到中秋節和國慶節週一到週五的邏輯好了,之前有事件的現在已經移除了。但是應該補班的,比如 9 月 18 和 9 月 26,事件卻沒有加上?什麼鬼?難道是添加事件失敗?調試後發現並沒有,事件添加是成功的,但是日曆中補班的日期卻沒有事件,嗯哼?

再回過頭來看補班添加事件的那段代碼


// 事件不存在
if !isOffDay { // 非休息日,即要補班
    let event = generateEvent(title, location: location, notes: notes, timeStr: timeStr)
    do {
        try store.save(event, span: EKSpan.thisEvent)
    } catch {
        print(error)
    }
}

根據 title、location、notes、time 添加事件,噢... 時間錯了,這個地方應該添加的是補班的日期,而不是最開始的日期。。。所以看一下當天日期,應該能發現事件都添加到那天裡面了。

所以這個地方需要修改為,從傳入日期中獲取時分秒,然後拼接上補班的日期,作為要設定的日期,修改如下


// 事件不存在
if !isOffDay { // 非休息日,即要補班
    var targetDateStr = dayStr
    if let lastComponentStr = timeStr.components(separatedBy: " ").last {
        targetDateStr = String(format: "%@ %@", dayStr, lastComponentStr)
    }
    
    let event = generateEvent(title, location: location, notes: notes, timeStr: targetDateStr)
    do {
        try store.save(event, span: EKSpan.thisEvent)
    } catch {
        print(error)
    }
}

最後,調試運行,成敗在此一舉,哈哈哈,binggo,完美

image

代碼地址:MKReminderUtil

總結:#

WX20210514-171744.png

通過這種方式,生成的日曆提醒,還需要考慮一點,就是節假日數據有更新的時候,如何更新?筆者這裡感覺如果是在自己服務端維護一套節假日數據比較好,返回節假日數據時,也返回對應版本號。這樣請求了之後,根據 version 對比,如果節假日數據沒有更新,則無需做任何操作,如果有更新,則根據更新的數據默默的把明年的日曆也創建了即可。

參考#

載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。