上節課,我們已經實現了一個本地通知。為什么我要求你們要先按Home鍵退回到主界面呢?那是因為iOS的消息通知,僅僅在app未使用時才會生效,如果你正在使用app,你當然不需要關于這個app的提醒。
點擊Stop按鈕中斷app,然后再次運行app,這次不要按Home鍵退回主界面,再進行一次漫長的等待,看吧,什么都不會發生,我只希望你不要等了太久。
消息通知的功能已經實現了,但是它和用戶的待辦事項是相互獨立的,兩者之間還不存在關系,為了解決這個問題,我們要以某種方式讓相關的事件注意到本地通知。怎么辦呢?當然是通過使用委托了。
在AppDelegate的class聲明的那一行上改動一下:
class AppDelegate: UIResponder, UIApplicationDelegate,UNUserNotificationCenterDelegate {
這樣就讓AppDelegate成為了UNUserNotificationCenter的委托。
同時在AppDelegate.swift中添加以下方法:
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
print("Received local notification \(notification)")
}
這個方法當app仍在運行時有本地通知發布時被調用。你不用在這里做任何操作,除了在調試區域打印一條消息。
當app在前臺運行時,它假定任何通知都會自己照顧自己。根據app的類型不同,通知也許會被展現給用戶,也許會自動刷新界面。
最后,告訴UNUserNotificationCenter現在AppDelegate是它的委托了。在application(didFinishLaunchingWithOptions)方法中添加一行代碼來完成這件事:
center.delegate = self
再次重新運行app,不要按Home鍵,等待10秒,10秒之后你會看到調試區域打印出了一條消息:
Received local notification <UNNotification: 0x60800003b160; date: 2017-07-23 13:38:50 +0000, request: <UNNotificationRequest: 0x6000004250c0; identifier: MyNotification, content: <UNNotificationContent: 0x600000107b30; title: Hello, subtitle: (null), body: I am a local notifcation, ...
好了,你已經確定它在工作了,你需要從AppDelegate.swift中移除掉所有代碼,因為你并不需要每次用戶啟動app時都安排新的通知。
從didFinishLaunchingWithOptions中把所有通知相關的代碼移除掉,僅保留以下兩行:
let center = UNUserNotificationCenter.current()
center.delegate = self
你可以userNotificationCenter(willPresent...)方法也留下,讓它繼續在調試區域打印消息。
擴展數據模型
讓我們來考慮一下,app應該如何處理這些消息。每條待辦事項都應該有一個處理時間的字段(一個Date型對象,可以指定具體的日期和時間)并且需要有一個Bool型對象來判斷用戶是否想要對這一條信息進行提示。
用戶也許不需要對每一條待辦事項都進行提醒,所以你不能對所有的待辦事項都安排一條通知。所以我們需要一個Bool型對象來進行判斷,名字就叫做shouldRemind好了。
你要在Add/Edit Item界面上增加關于它們的設置,完成后的樣子看起來會是這個樣子:
處理時間字段需要靠某種可以選擇時間的控制器實現。iOS自帶一個非常酷的日期選擇視圖,你可以直接把它添加到table view中。
首先,讓我們指出應該在什么時間以什么方式來安排通知。我考慮的情況如下:
1、當用戶添加一條新的待辦事項時,如果同時設置shouldRemind標示為true,則需要安排一條通知。
2、當用戶對已存在的待辦事項的處理時間進行編輯的時候,舊的通知安排需要被取消(如果之前有安排通知的話),并且安排一條新的通知(如果用戶沒有取消shouldRemind設置的話)
3、當用戶對shouldRemind狀態進行true到false切換的時候,存在的通知需要被取消,從false到true到時候需要安排一條通知。
4、當用戶刪除一條待辦事項的時候,需要取消通知(如果之前有的話)
5、當用戶刪除一個待辦分類的時候,其中所有已存在的通知都要被取消掉。
通過上面的分析,你要做的事情就一目了然了。
你同時需要注意一下,不能為哪些處理時間已經小于當前時間的待辦事項安排通知。雖然iOS會自動忽略這些通知,但是我們還是最好做到自己處理,養成考慮周全的習慣。
要把ChecklistItem對象和它們的通知聯系起來,就必須修改一下數據模型。
當你安排一條本地通知的時候,你就創建了一個UNNotificationRequest對象。你可能會覺得既然如此,那么將UNNotificationRequest對象作為一個實例變量放入ChecklistItem中就好了,但是,這并不是正確的方法。
取而代之的是,你要使用一個標識符,每當你創建一條本地通知時,你都給他一個標識符,這個標識符可以用一個字符串。字符串中的內容并不重要,只要它不發生重復就行。
當取消同時的時候,你并不需要對UNNotificationRequest進行操作,而是操作作為標識符的字符串就可以了。所以正確的做法是將這個標識符存放在ChecklistItem中。
即使用做通知標識符的是一個字符串,但是實際上我們我們給它的值將是數字。你還需要將這些數字保存至Checklist.plist文件中。每次你安排或者取消一條通知時,你就將數組轉換為字符串。這樣當有一個ChecklistItem對象時,你就可以簡單的找到對應的通知,或者當你有一個通知時就能簡單的找到ChecklistItem對象。
創建一個數字序列ID,是非常普遍的一種行為,就和關系型數據庫中的主鍵一樣。
首先在ChecklistItem.swift中添加以下代碼:
var dueDate = Date()
var shouldRemind = false
var itemID: Int
我們將它取名為itemID,而不是簡單的取名為id,那是因為id是OC中的一個特殊關鍵字,用id做變量名會使編譯器困惑。
其中的dueDate和shouldRemind都有初始值,而itemID則沒有。這就是為什么你要指定itemID的類型的原因,而其他兩個不需要指定類型。因為swfit有類型推斷,記得嗎?
你還需要拓展一下init?(coder) 和 encode(with),這樣就可以在保存和讀取ChecklistItem對象時,把它們也包含進去了。
在init?(coder)中添加以下代碼:
dueDate = aDecoder.decodeObject(forKey: "DueDate") as! Date
shouldRemind = aDecoder.decodeBool(forKey: "ShouldRemind")
itemID = aDecoder.decodeInteger(forKey: "ItemID")
在encode(with)中添加以下代碼:
aCoder.encode(itemID, forKey: "ItemID")
aCoder.encode(shouldRemind, forKey: "ShouldRemind")
aCoder.encode(dueDate, forKey: "DueDate")
我們對dueDate使用了decodeObject(forKey),shouldRemain使用了decodeBool(forKey),而對itemID使用了decodeInteger(forKey),這是非常必要的,因為NSCoder系統使用OC寫的,這種語言對類型的要求非常嚴謹。
對OC而言Int、Float、和Bool屬于原始類型。其他的東西比如String和Date屬于對象。這點和Swift不同,Swift對待所有東西都是按照對象處理。但是因為這里你要使用的是OC的框架,所以你必須遵守OC的規則。
非常棒,現在這些屬性也可以被存儲和讀取了。
現在Xcode中還存在一處報錯:init()需要itemID有一個值,因為每新建一個對象都需要一個值。所以你需要在init()中給itemID分配一個值。
在init()中添加以下代碼:
override init() {
itemID = DataModel.nextChecklistItemID()
super.init()
}
這個代碼的作用是無論app是否新創建一個ChecklistItem對象,你都向DataModel請求一個新的ID。
我們現在就來添加這個新的方法,這個方法和它的名字一樣每次都返回一個不同的ID。
打開DataModel.swift,添加這個新的方法:
class func nextChecklistItemID() -> Int {
let userDefaults = UserDefaults.standard
let itemID = userDefaults.integer(forKey: "ChecklistItemID")
userDefaults.set(itemID + 1, forKey: "ChecklistItemID")
userDefaults.synchronize()
return itemID
}
我們又見到了老朋友UserDefaults。
這個方法從UserDefaults中得到目前的“ ChecklistItemID”的值,然后將它加1,然后將之前沒有加1的值返回給調用者。
同時它用userDefaults.synchronize()強制UserDefaults實時的將變化寫入磁盤,這樣就算app突然中斷了,也不會丟失數據,從而保證不會出現重復的值。
在registerDefaults方法中為“ ChecklistItemID”的值添加初始值(注意一下,一定是要在FirstTime的后面添加):
func registerDefaults() {
let dictionary: [String: Any] = ["ChecklistIndex": -1,"FirstTime": true,"ChecklistItemID: 0"]
...
nextChecklistItemID第一次被調用后返回0,然后每次加1。就算你調用上億次都不會重復。
類方法和實例方法(Class methods & instance methods)
如果你對下面的語句感到好奇,為什么是:
class func nextChecklistItemID()
而不是:
func nextChecklistItemID()
那么我很高興你如此細心。
class關鍵字意味著你可以在不引用DataModel的前提下,調用這個方法。
記住,你使用:
itemID = DataModel.nextChecklistItemID()
來調用類方法,而不是:
itemID = dataModel.nextChecklistItemID()
這是因為ChecklistItem對象沒有一個用于引用DataModel的dataModel屬性。當然,你可以給它一個這樣的引用,但是我決定使用類方法,這樣簡單些。
聲明類方法使用關鍵字class func,這種類型的方法適用于整個類。
到目前為止你使用的方法都是實例方法,使用關鍵字func定義,只能用于類中一個特定的實例。
以前我們沒有討論過類方法和實例方法的區別,在以后的課程中我們會逐漸深化這個話題。就現在而言,僅僅記住用class func聲明的方法可以允許你在任何對象上調用它,甚至在不引用這個對象的前提下。
我不得不做出一個權衡:給每個ChecklistItem對象一個到DataModel的引用是否值得,或者簡單的使用一個類方法就好了?為了保持簡單,我選擇后者。如果你未來還在開發app的話,那么你很可能遇到這種需要做出權衡的情況。
為了快速的測試分配的ID是否正常工作,你可以把它放到ChecklistItem的標簽中展示出來看,下面的代碼僅僅是用做測試,因為這些內部的ID號沒有必要展示給用戶看。
打開ChecklistViewController.swift,改動一下configureText(for:with:)方法:
func configureText(for cell: UITableViewCell,with item: ChecklistItem) {
let lable = cell.viewWithTag(1000) as! UILabel
//lable.text = item.text
lable.text = "\(item.itemID):\(item.text)"
}
把原來的那一行注釋掉,不要刪掉,因為你一會還要改回來。
在重新運行app之前,一定要重置模擬器,并且把Checklist.plist文件刪掉,因為我們的數據模型已經變了,舊的文件結構會導致app崩潰掉。
運行app,并且添加幾條待辦事項,每一個都會得到一個唯一的ID,使用Home鍵回到iOS的主界面,然后中斷掉app,然后再次運行app。然后在新增幾條待辦事項,你會看到它們的編號和之前的是連續的。
OK,ID們工作的很好。現在我們來添加“due date”和“should remind”到Add/Edit Item界面。
先不要把configureText(for:with:)改回去,我們還要繼續用它做測試。
打開ItemDetailViewController.swift,添加兩個outlet:
@IBOutlet weak var shouldRemindSwitch: UISwitch!
@IBOutlet weak var dueDateLable: UILabel!
打開故事模版,選擇Item Detail View Controller中的table view(名字為Add Item的那個)
為這個table新增一個分節,這非常簡單,打開屬性檢查器然后將Section字段設置為2就可以了。這樣會復制一個已存在的cell過去。
刪除這個新的cell中的Text Field。拖拽一個新的Table View Cell到這個新的cell的下面,這個這個新增的分節就有兩個cell了。
最終我們完成設計時,界面會是這個樣子:
拖拽一個Lable到第一個cell的左邊,輸入文本Remind Me,設置字體為System 17。
在拖拽一個Switch到這個cell的右邊。將這個Switch和shouldRemindSwitch連接起來,然后在它的屬性檢查器中將Value設置為off,這樣它的初始狀態就是關閉的了,開關會由綠色變為灰色。
將這個Switch的頂部及右側固定起來(使用Pin菜單),這樣就保證了這個控件可以匹配所有的設備大小。
下面的一個cell應該具備兩個標簽:左邊的標簽負責獲取并且顯示用戶選擇的時間,右邊的負責選擇時間。實際上你不需要去拖拽兩個標簽上去,僅僅是將這個cell的風格修改為Right Detail,然后將標簽重命名為Due Date就可以了。
右邊的那個標簽應該和dueDateLabel outlet連接起來。(這個標簽比較難以選中,你需要多點幾次試試)
你還需要將Remind Me標簽以及Switch的位置移動一下,讓它倆和下面的兩個標簽保持左對齊,你選擇下面的標簽,打開尺寸檢查器,看看它們的x值是多少,然后把Remind Me標簽和Switch的x值設置為一致就可以了,用不著拖來拖去的微操。
下面進入代碼部分:
打開ItemDetailViewController.swift,添加一個新的實例變量dueDate:
var dueDate = Date()
對于每一個新的ChecklistItem,due date都應該默認當前時間。但是假如用戶選擇了時間,那么就要立刻把當前時間替換掉。
這里還有一些其他選擇,比如默認時間設置為明天或者10分鐘以后,但是實際上,用戶基本上都會立即選擇時間,所以對于默認時間不需要做太多考慮。
改動一下viewDidLoad():
override func viewDidLoad() {
super.viewDidLoad()
if let item = itemToEdit {
title = "Edit Item"
textField.text = item.text
doneBarButton.isEnabled = true
shouldRemindSwitch.isOn = item.shouldRemind //新增這一行
dueDate = item.dueDate //新增這一行
}
updateDueDateLabel() //新增這一行
}
對于已經存在的ChecklistItem對象,你設置switch的狀態需要使用這個對象的shouldRemind屬性,如果是新增的,那么初始狀態默認為off,我們在故事模版中做了設置。
你同時還從ChecklistItem中獲取了due date。
這個updateDueDateLabel()是個新的方法,我們現在把它添加上:
func updateDueDateLabel() {
let formtter = DateFormatter()
formtter.dateStyle = .medium
formtter.timeStyle = .short
dueDateLable.text = formtter.string(from: dueDate)
}
你使用DateFormatter來將日期轉換為文本。
它的工作原理非常明顯:你給它的date部分設置了一個風格,time部分設置了另外一個風格,并且從中獲得格式化好的Date對象。
你可以試試其他類型的風格,但是由于label的尺寸有點小,所以也看不出什么效果。
DateFormatter最酷的地方是它返回的是當地時間,不管你在地球上的那個地方,DateFormatter都是返回你所在地的當地時間。
最后一件事情就是修改done方法:
@IBAction func done() {
if let item = itemToEdit {
item.text = textField.text!
item.shouldRemind = shouldRemindSwitch.isOn //新增這一行
item.dueDate = dueDate //新增這一行
delegate?.itemDetailViewController(self, didFinishEditing: item)
} else {
let item = ChecklistItem()
item.text = textField.text!
item.checked = false
item.shouldRemind = shouldRemindSwitch.isOn //新增這一行
item.dueDate = dueDate //新增這一行
delegate?.itemDetailViewController(self, didFinishAdding: item)
}
}
當用戶點擊done按鈕的時候你將switch和due實例變量的值返回給ChecklistItem對象。
運行app,改變開關的狀態。app在中斷后也會記得開關的最終狀態(記得先退回主界面再中斷app)
due date還沒有生效,想要讓它工作,你必須先創建一個時間選擇器。
??:你也許想知道為什么你對dueDate使用了一個實例變量,而shouldRemind沒有。
因為并不需要這樣做,你可以輕易的從switch控件中得到它的狀態值,通過isON屬性,這個屬性返回值也是true和false。
然而,從dueDateLabel中將時間讀取出來就沒那么容易了,因為這個label存儲的文本是String型的,不是Date。所以我們用了一個實例變量來跟蹤日期的值。
時間選擇器
時間選擇器(date picker)對我們而言并不是什么新的視圖控制器。我們要實現的效果是,點擊Due Date這一行自動在table view中插入一個UIDatePicker組件,日歷型的app通常就具備這一功能。
打開ItemDetailViewController.swift,添加一個新的實例變量來跟蹤時間選擇器是否可見:
var datePickerVisible = false
并且添加showDatePicker()方法:
func showDatePicker() {
datePickerVisible = true
let indexPathDatePicker = IndexPath(row: 2, section: 1)
tableView.insertRows(at: [indexPathDatePicker], with: .fade)
}
這里將剛添加的實例變量設置為true,并且告訴table view插入一個新行到Due Date這一行下面。這個新插入到行將用來容納UIDatePicker。
問題是:用于date picker這一行的cell從哪來?你不能像靜態cell那樣直接把它放入table view。因為這樣就會使它總是可見。而你僅僅想要用戶點擊Due Date這一行后它才顯示。
Xcode有一個非常酷的功能可以使你添加附加視圖到場景中,而并不立即顯示它們。這是我們解決這個問題的不二之選。
打開故事模版找到Add Item界面。拖拽一個table view cell,不要把它拖拽到視圖控制器里面,而是拖拽到頂部的dock里,見下圖:
拖拽完畢后,故事模版看起來會是這個樣子:
這個新的table view cell對象屬于這個場景,但是它還不是這個場景的table view的一部分。
這個cell也有點小,不足以容納一個date picker,所以首先我們來把它弄大點。
選擇這個table view cell打開尺寸檢查器,設置Height為217,date picker的高是216,所以我們要設置高一個點位,在頂部留一點空隙,否則會非常難看。
然后打開屬性檢查器,設置Selection為None,這樣就使cell在你點擊它的時候不會變灰。
然后拖拽一個date picker到這個cell中,它應該剛好可以容納進去。
使用Pin菜單將date picker的四條邊都固定好。注意不要勾選Constrain to margins復選框。
當你完成后,新的cell看起來應該是這個樣子的:
那么你如何將這個cell放入table view中呢?首先,做兩個個新的outlets并且把它們分別和date picker與cell連接起來,這樣你就可以在代碼中引用這兩個視圖了。
打開ItemDetailViewController.swift,添加以下代碼:
@IBOutlet weak var datePickerCell: UITableViewCell!
@IBOutlet weak var datePicker: UIDatePicker!
回到故事模版,注意一下頂部的dock欄,上面有一個黃色圓圈的圖標,它就代表這個視圖控制器。
按住ctrl從這個黃色圓圈圖標拉線到灰色的那個代表table view cell的圖標,然后選擇datePickerCell outlet:
然后還是按住ctrl從這個黃色圓圈圖標到Date Picker上,之后選擇datePicker就完成了date picker的連接。
非常棒,現在你完成了cell和date picker的連接,你可以通過寫點代碼,把它們添加到table view上了。
通常你會執行tableView(cellForRowAt)方法,但是記住,這是用于靜態cell的情況。像我們這種情況下,不存在數據源,所以也就不存在cellForRowAt。
如果你觀察下ItemDetailViewController.swift,你不會看到有這個方法存在。通過一些列手段,你可以為靜態的table view重寫數據源,并且提供你自己寫的方法。
我們這樣在ItemDetailViewController.swift中添加cellForRowAt:
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
if indexPath.section == 1 && indexPath.row == 2 {
return datePickerCell
} else {
return super.tableView(tableView, cellForRowAt: indexPath)
}
}
注意:你不能對它進行太多的操作,當它由一個靜態table view使用時,因為它也許會影響這些靜態cell的內部工作方式。但是如果你足夠小心的話,你可以避免它。
這個if語句檢查cellForRowAt是否被date picker的indexPath調用。如果是,它返回你剛設計的datePickerCell。這樣操作是安全的,因為這個table view對row 2,section 1毫不知情,所以你不會影響到已存在的靜態cell。
對于其他任何不是date picker cell的行,這個方法會調用super.tableView(tableView, cellForRowAt: indexPath),通過這種手段來保證其他的靜態cell正常工作。
你還需要重寫tableView(numberOfRowsInSection):
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
if section == 1 && datePickerVisible {
return 3
} else {
return super.tableView(tableView, numberOfRowsInSection: section)
}
}
如果date picker可見,那么section 1就有三行,如果不可見,則僅返回原始的數據源。
同樣的,我們來重寫tableView(heightForRowAt)方法:
override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
if indexPath.section == 1 && indexPath.row == 2 {
return 217
} else {
return super.tableView(tableView, heightForRowAt: indexPath)
}
}
到目前為止你的table view中的cell都是同樣的高度,都是44,但是改變它并不難,你可以通過“heightForRowAt”來控制每個cell的高度。
如果是date picker所屬的cell的話,我們設置它的高為217。
date picker僅在用戶點擊due date這一行的cell時才顯示,我們來添加相關的代碼:
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
textField.resignFirstResponder()
if indexPath.section == 1 && indexPath.row == 1 {
showDatePicker()
}
}
當due date這一行被點擊后調用showDatePicker(),如果此時界面上有虛擬小鍵盤的話,也會被自動隱藏掉。
此時,你已經完成了大部分工作,但是due date這一行現在實際上并不能被點擊,這是因為ItemDetailViewController.swift中已經存在了一個“willSelectRowAt”方法,它總是返回nil,所以點擊會被忽視掉。
我們來改動一下tableView(willSelectRowAt):
override func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? {
if indexPath.section == 1 && indexPath.row == 1 {
return indexPath
} else {
return nil
}
}
現在due date這一行會被選中了,而其他行不會。
運行app,試試效果。添加一個新的待辦事項,并且點擊due date這一行。
不出意外的話你會發現app掛了,如果沒有掛的話,那就真的很意外了,通過一些調查我發現,當你為靜態table view重寫了數據源后,你還需要提供委托方法:tableView(indentationLevelForRowAt)
這不是你經常使用的一個方法,但是因為你動了用于靜態table view的數據源,所以你必須重寫它。我早就告訴過你(其實并沒有)。
添加tableView(indentationLevelForRowAt)方法:
override func tableView(_ tableView: UITableView, indentationLevelForRowAt indexPath: IndexPath) -> Int {
var newIndexPath = indexPath
if indexPath.section == 1 && indexPath.row == 2 {
newIndexPath = IndexPath(row: 0,section: indexPath.section)
}
return super.tableView(tableView, indentationLevelForRowAt: newIndexPath)
}
app會因為這個方法掛掉的原因是標準的數據源對section 1,row2的cell(就是date picker所屬的cell)毫不知情,甚至不知道它的存在,因為這個cell在設計時并不屬于這個table view。
所以在插入date picker所屬的cell后,數據源表示我沒見過它,所以app就躺槍了。為了克服這個問題,你需要在date picker顯示時欺騙數據源,使它確信這一行真的存在。這就是indentationLevelForRowAt這個方法的作用。
運行app,這一次點擊due date后,可以正常顯示出date picker了。
當你選擇date picker中的時間的時候,選擇的結果應該反饋在Due Date這一行中,但是現在并沒有起到這個效果。
我們需要監聽date picker的值的改變事件。無論何時,當date picker上的滾輪被轉動時都必須觸發這一事件。為了實現這個需求,你需要添加一個新的方法。
打開ItemDetailViewController.swift,添加這個方法:
@IBAction func dateChanged(_ datePicker: UIDatePicker) {
dueDate = datePicker.date
updateDueDateLabel()
}
這非常簡單。它使用date picker的時間來更新dueDate,然后更新Due Date這一行的標簽。
打開故事模版,按住ctrl拖拽Date Picker到視圖控制器,并且選擇dateChanged動作方法。現在所有的連接都完成了。
你一定要確認這個動作方法連接的是date picker的Value Changed事件。可以通過查看鏈接檢查器來確認。
運行app,試試效果。當你轉動date picker上的滾輪時,Due Date中的標簽也會隨著變化。
然而,當你編輯一條已存在的待辦事項的時候,data picker總是顯示當前時間。
在showDatePicker()方法的底部添加一行:
datePicker.setDate(dueDate, animated: false)
這樣就給了UIDatePicker組件一個合適的時間。
確認一下它是否按照我們的意圖工作,編輯一條已存在的待辦事項,最好用已經設置過due date的,確認一下date picker上的時間和due date標簽上的時間一致。
當date picker可見的時候如果Due Date上的標簽能夠高亮顯示,那么久太棒了。你可以使用tint color來實現這一目的(這也是日歷型app常見的功能)
再改一次showDatePicker:
func showDatePicker() {
datePickerVisible = true
let indexPathDateRow = IndexPath(row: 1, section: 1)
let indexPathDatePicker = IndexPath(row: 2, section: 1)
if let dateCell = tableView.cellForRow(at: indexPathDateRow) {
dateCell.detailTextLabel!.textColor = dateCell.detailTextLabel!.tintColor
}
tableView.beginUpdates()
tableView.insertRows(at: [indexPathDatePicker], with: .fade)
tableView.reloadRows(at: [indexPathDateRow], with: .none)
tableView.endUpdates()
datePicker.setDate(dueDate, animated: false)
}
這樣就將detailTextLabel的顏色設置為了tint color。它同時也告訴table view需要重新加載Due Date這一行。但是cell之間的間隔線沒有被更新。
因為你在同一時間對這個table view進行了兩種操作,插入一個新行并且重新加載另一個,你需要把它們放到叫做beginUpdates()和 endUpdates()的東西之間,這樣就可以同時更新所有東西了。
運行app,現在日期是淺藍色了。
當用戶再次點擊Due Date這一行時,date picker應該自動消失掉。如果你現在這樣做的話app就會掛掉,這樣肯定不會為你在app store中帶來太多好評。
添加一個新的方法:
func hideDatePicker() {
if datePickerVisible {
datePickerVisible = false
let indexPathDateRow = IndexPath(row: 1, section: 1)
let indexPathDatePicker = IndexPath(row: 2, section: 1)
if let cell = tableView.cellForRow(at: indexPathDateRow) {
cell.detailTextLabel!.textColor = UIColor(white: 0, alpha: 0.5)
}
tableView.beginUpdates()
tableView.reloadRows(at: [indexPathDateRow], with: .none)
tableView.deleteRows(at: [indexPathDatePicker], with: .fade)
tableView.endUpdates()
}
}
這個方法的作用和showDatePicker()。它從table view中刪除了date picker cell并且將date label的顏色恢復為灰色。
改變一下tableView(didSelectRowAt)來觸發顯示和隱藏狀態:
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
textField.resignFirstResponder()
if indexPath.section == 1 && indexPath.row == 1 {
if !datePickerVisible {
showDatePicker()
} else {
hideDatePicker()
}
}
}
還存在一種情況,需要我們把date picker隱藏起來:當用戶點擊text field的時候。
如果虛擬鍵盤和時間選擇器重疊在一起的話,會非常難看,所以你最好還是把時間選擇器隱藏起來。這個視圖控制器已經是text field的委托了,我們處理起來會非常簡單。
添加textFieldDidBeginEditing()方法:
func textFieldDidEndEditing(_ textField: UITextField) {
hideDatePicker()
}
這樣就非常完美了。
運行app并且確認是否一切工作正常。
安排本地通知
經過這么漫長的插曲,希望大家不要忘了,我們最終的目的是安排本地通知。
面向對象編程的一個原則是,對象可以盡可能的利用自己。因此,讓ChecklistItem對象來安排它自己的通知。
打開ChecklistItem.swift:
func scheduleNotification() {
if shouldRemind && dueDate > Date() {
print("We should schedule a notification")
}
}
這里我們對比了due date和當前時間。你可以通過使用Date對象來獲得當前時間。
語句dueDate > Date() 比較兩個時間后返回true和false。
如果返回false的話,則print不會執行。
注意一下這個“&&”符號,表示“與”,只有當Remind Me被設置為on,且due date大于Date()時,print才被執行。
當用戶新增或者編輯完一條待辦事項后,點擊Done按鈕時,你調用這個方法。
打開ItemDetailViewController.swift,在didFinishEditing和didFinishaAdding前面添加一行:
item.scheduleNotification()
運行app,試試效果。添加一條新的待辦事項,將開關狀態設置為on,不要改變due date。然后點擊Done。
這時在調試區域應該沒有打印出消息,因為due date小于當前時間(當你點擊Done按鈕的時候已經有幾秒過去了)
再添加一條待辦事項,將switch設置為on,并且選一個幾分鐘后的due date。
然后點擊Done按鈕,這時調試區域應該打印出一條消息“We should schedule a notification”
現在你可以確認這個方法確實被調用了,我們來實際的把本地消息添加進去。首先考慮新增待辦事項的情況。
打開ChecklistItem.swift,將scheduleNotification()修改為:
func scheduleNotification() {
if shouldRemind && dueDate > Date() {
//1
let content = UNMutableNotificationContent()
content.title = "Reminder"
content.body = text
content.sound = UNNotificationSound.default()
//2
let calender = Calendar(identifier: .gregorian)
let components = calender.dateComponents([.month,.day,.hour,.minute], from: dueDate)
//3
let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: false)
//4
let request = UNNotificationRequest(identifier: "\(itemID)", content: content, trigger: trigger)
//5
let center = UNUserNotificationCenter.current()
center.add(request)
print("We should schedule a notification")
}
}
你在第一次調試本地通知的時候應該見過這些代碼,但是這里有些不同。
1、將item的文本放入通知中
2、從dueDate中提取月、日、小時和分鐘。我們不關心年和秒。
3、之前你用UNTimeIntervalNotificationTrigger來測試本地消息,但是現在這里,你使用它來展示詳細的時間。
4、創建UNNotificationRequest對象。這里比較重要的是,我們把待辦事項的ID轉換為String型,并且使用它來確定通知。假如你之后需要取消這條消息的話,就可以用這個標示找到它。
5、添加新的通知到UNUserNotificationCenter。
唯一的問題就是,Xcode給出了一大堆報錯。
出什么事了呢?ChecklistItem還沒有導入本地消息的框架,現在它只有NSObject、NSCoder和Foundation框架。
導入框架非常簡單:
import UserNotifications
這樣就可以了。
這里還有另外一個小問題。如果你重置過模擬器,那么此時app就不再被允許發送本地通知。
你不能假定app總是被允許發送通知消息的。最初你測試的時候,是將請求許可的代碼放入了AppDelegate中,但是現在不行了,也不推薦這樣做。
因為你本人肯定討厭那些強制的消息,這種app一點都不受歡迎,我們讓自己的app變得美好一些。
打開ItemDetailViewController.swift,添加以下方法進去:
@IBAction func shouldRemindToggled(_ switchControl: UISwitch) {
textField.resignFirstResponder()
if switchControl.isOn {
let center = UNUserNotificationCenter.current()
center.requestAuthorization(options: [.alert,.sound], completionHandler: {
granted ,error in /*do nothing*/
})
}
}
當switch設置為on時,會自動提示用戶允許通知消息,一旦用戶給予了許可 ,app不會再次請求許可了就。
同時記得添加import UserNotifications。導入UserNotifications。
運行app,新增一個待辦事項,設置due date到幾分鐘后,點擊Done按鈕,并且會到iOS主界面。
你就可以看到本地通知已經生效了:
現在新增部分已經實現了,還剩下幾個情況,1、用戶編輯待辦事項時,2、用戶刪除待辦事項時。
我們先來做編輯部分,當用戶編輯待辦事項時,會發生以下情況:
1、Remind Me曾經是off,現在被設置為on。你需要安排一條通知
2、Remind Me曾經時on,現在被設置為off,你要取消掉已存在的通知
3、Remind Me保持為on,但是due date改變了,你需要取消舊的通知,安排新的通知。
4、沒有任何改變,你不需要做任何事。
5、Remind Me保持為off,也不用做任何事。
當然,上面所有情況中,都必須due date大于當前時間才安排通知消息。
好長的一個列表啊。在編程前,把所有的可能性列出來,是一個非常好的習慣。
看起來你要寫非常多的代碼了,但是實際上非常簡單。
首先,你觀察這里是否已經存在一條消息。如果有,你簡單的把它取消掉。然后判斷是否需要安排一條新的。
這樣就可以處理上面的所有情況了,甚至有時候僅僅把已經存在的通知保留下來就可以了。算法有點粗糙,但是很有效。
打開ChecklistItem.swift,添加以下方法:
func removeNotification() {
let center = UNUserNotificationCenter.current()
center.removePendingNotificationRequests(withIdentifiers: ["\(itemID)"])
}
這個方法的作用是移除已存在的某條待辦事項的通知安排,注意一下removePendingNotificationRequests()要求一個數組作為標示,所以你把(itemID)放入一對方括號中。
在scheduleNotification()的頂部調用這個方法:
func scheduleNotification() {
removeNotification()
...
運行app,添加一個待辦事項,并且將due date設置到兩分鐘后。一條新的通知就被安排上了。會到主界面等待它的出現。
編輯待辦事項并且改變due date,到三分鐘或者4分鐘后,這樣舊的消息就被取消了,然后根據新的時間安排了一條新的消息。
添加一條新的待辦事項,然后把switch設置為off,舊的消息會被取消,并且不會安排新的消息。
再次編輯上面哪條待辦事項,改變一下時間,不要動其他的,還是不會被安排消息。
我們還有最后一種情況要處理,就是刪除待辦事項,有兩種情況需要考慮:
1、用戶通過滑動的方式刪除某一條待辦事項
2、用戶刪除了整個待辦事項分類的目錄
當刪除發生時,有一個方法會被告知這件事。你可以簡單的執行這個方法,然后看看有沒有安排消息通知,有的話就取消掉。
打開ChecklistItem.swift,添加以下方法:
deinit {
removeNotification()
}
所有的工作都做完了。這個特殊的deinit方法會在刪除某一條待辦事項以及刪除整個目錄的時候被調用。
運行app,測試一下各種情況。如果一切正常的話,就把代碼里的print語句都刪掉。雖然不刪也沒什么關系,用戶是看不到它們的,但是我們所做的一切都是為了代碼的簡潔。
同時也把item ID從ChecklistViewController的label中移除,這僅僅是為了測試使用的。
好累啊
我們從設計草圖開始,一直到完整的完成了一個app。我們接觸了許多高級的課題,希望你能跟的上思路,明白我們是在做什么。你堅持到了現在,我非常為你感到驕傲。
如果你對其中的一些細節迷惑不解,那是正常的,沒有關系。睡一覺,然后重新在看一遍。編程需要你去思考,但是并不需要你通宵達旦的和它在一起。不要害怕重頭再來一遍。要記住,溫故而知新。
本課程聚焦于UIKit,以及其中的重要控件和模式。在下一節課,我們會先花點時間將將swift語言。當然,你還會再和我一起做一個更酷的app。
最終我們的故事模版是這個樣子的:
看起來很壯觀吧。
你得到了應得的回報,當你準備好開始下一課前,好好休息一下吧,我也休息一下,有兩段結束語不翻譯了。