iOS 工作日 —— 過濾法定節假日日曆提醒的實現#
背景#
筆者五一之前補班的時候,鬧鐘沒響,早上差點遲到了。筆者鬧鐘設定的是週一到週五,iPhone 沒有法定節假日的設定,也沒有補休的設定。。。。筆者就想要解決這個痛點,夢想著,要是做出來了,發布到商店,從此走上人生巔峰,贏取白。。。。
YY 過後,回過頭來,接著調研,法定節假日鬧鐘的實現,筆者查找了很多資料,發現不用做夢了。首先 iOS 程序添加鬧鐘到時鐘 APP 是不允許的。。。其次,iOS 也沒有法定節假日的判斷。。。。所以不用 YY 了。但是筆者還真找到了iOS 自定義鬧鐘 —— 中國法定節假日 (升級版)這個,通過快捷指令自定義鬧鐘,可以實現過濾法定節假日。原理是:設定鬧鐘,然後通過快捷指令的自動執行,每天在鬧鐘時間前,通過訂閱的別人維護的日曆或者自己本地維護日曆,判斷當天是否是節假日,然後決定當天的鬧鐘是否打開、關閉。筆者不得不讚一個,真的優秀。
雖然筆者的發財夢夭折了。。。但筆者想到了另一個,雖然 iOS 程序不能直接添加鬧鐘,但是 iOS 程序可以直接添加日曆提醒啊,比如預約直播或者預約搶購的,其實都是添加事件到日曆中,然後在指定的時間,彈出來日曆提醒去做什麼,也不是不可以用。那是否能用日曆提醒來實現,法定工作日的提醒呢。。。比如每個工作日提醒打卡。或者只針對節假日補班提醒,每個補班前天晚上提醒設定鬧鐘。
實現#
iPhone 添加日曆提醒的實現很簡單,難的地方還是在於國內法定節假日的判斷,怎麼能過濾掉法定節假日,實現真正純工作日的時候提醒?
第一步,先創建週一到週五的重複事件#
筆者又調研了一番,發現日曆提醒中有一個EKRecurrenceRule
的規則選項,是否能用這個來實現呢?
EKRecurrenceRule是什麼?
官方解釋:
A class that describes the recurrence pattern for a recurring event.
筆者理解:
重複事件的重複規則。簡單的說,就是定義一個重複規則,比如每週重複、每天重複、每隔幾天重複類似的,然後按照這個規則添加事件。
看到這個,筆者的心涼了半截,重複的規則,對於國內法定節假日來說。。。。除了五一、國慶、元旦之外,農曆的節日重複的規則找不到。。。怎麼辦?筆者尋思著都到這一步了,就先做個週一到週五的,也算是需求完成了半個,工作日的那部分完成了,剩下的那部分過濾法定節假日和補休,慢慢看,又不是不用😂
先來看設定每週一到週五的循環日曆事件
添加日曆事件
添加日曆事件的步驟如下:
- 獲取讀寫日曆權限
- 創建單獨的日曆
- 生成週一到週五的規則
- 根據標題、地址、規則和時間生成日曆事件
- 添加事件到日曆 判斷生成的事件是否已經添加,已添加則不操作,沒添加則添加
下面一步步來看:
-
獲取讀寫日曆權限
首先需要在 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) } }
-
創建單獨的日曆
用於保證不和其它日曆衝突,而且不顯示或者移除時方便,建議每個自定義日曆事件的都單獨定義一個日曆。
聽起來有些繞,打開 iPhone,打開日曆,然後點擊底部中間的日曆按鈕,就能看到自己的所有日曆。看圖如下,"自定義的事項日曆" 即是筆者自定義的日曆,筆者所添加的日曆事件都會在這個日曆中,如果不想要看到這些事件,可以直接把前面的勾選去除,日曆中就不會顯示自定義的日曆事件了。或者想要刪除這個日曆中的所有事件時,只需要把這個日曆刪掉即可,不需要一條條事件刪除,點擊右邊的提示按鈕,然後滑動到最下方就有刪除日曆的按鈕。
Ps:默默的吐槽,不知道為啥預約搶購和預約直播提醒的,不單獨建一個日曆。。。。筆者預約了之後感覺煩,每次都得手動去刪除事件
創建日曆的代碼如下,注意 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) } }
-
生成週一到週五的規則
使用
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 }
-
根據標題、地址、備註、規則和時間生成日曆事件
生成日曆事件時,要注意事件的持續時間,以及是否添加鬧鐘提示。這個鬧鐘提示不是通常意義的鬧鐘,是日程提醒,比如設定了事件的鬧鐘提示,在達到鬧鐘提醒時間後,會提醒響鈴,且在通知欄彈出。
// 生成日曆事件 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 }
-
添加事件到日曆
添加時,需要判斷生成的事件是否已經添加,已添加則不操作,沒添加則添加。添加成功後,把事件 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: "自定義標題")
會先彈出授權訪問日曆的提示框,點擊允許後,成功添加到日曆,然後去日曆中可以看到,日曆中從當天開始的,每週一至週五都有事件存在

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

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

至此,筆者已經成功添加了週一到週五重複提醒的事件,已經算是完成了一半,勉強能用,就是遇到節假日時,補班、調休的時候會錯誤提醒。所以還有後面的一般,怎麼把節假日的邏輯加入到事件中?
第二步,添加法定節假日邏輯#
筆者一直想的是添加法定節假日的邏輯,一開始其實就陷入了誤區,一直想的是,是否有一個規則,按照這個規則,能自動過濾掉節假日和添加補班,然後生成重複日曆事件。然而並沒有這樣的規則存在。
參考快捷指令節假日鬧鐘的實現,筆者就想到了另一種方式,如果沒有直接節假日的規則,那能否分兩步走?第一步先創建週一到週五的固定重複邏輯;第二步,從某個地方獲取到節假日和補班信息,然後根據信息,在第一步的基礎上,“多退少補”,即屬於節假日的週一至週五的事件移除,屬於補班的沒有日曆事件的則添加事件。
那這種方案是否可行呢?實踐出真知!
步驟如下:
-
獲取節假日和補班信息
從哪裡能獲取到節假日和補班信息呢?筆者去網上查找了一番,最終看到了有兩個合適的訂閱來源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() }
-
“多退少補”
即屬於節假日的週一至週五的事件移除,屬於補班的沒有日曆事件的則添加事件。這裡需要判斷,某天日期是否有當前的事件。
// 判斷某天,是否有指定的事件 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)
最終結果如下:

可以看到中秋節和國慶節週一到週五的邏輯好了,之前有事件的現在已經移除了。但是應該補班的,比如 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,完美

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