iOS Apprentice中文版-從0開始學iOS開發-第二十七課

上節課,我們已經實現了一個本地通知。為什么我要求你們要先按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。

最終我們的故事模版是這個樣子的:

看起來很壯觀吧。

你得到了應得的回報,當你準備好開始下一課前,好好休息一下吧,我也休息一下,有兩段結束語不翻譯了。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容