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。

最终我们的故事模版是这个样子的:

看起来很壮观吧。

你得到了应得的回报,当你准备好开始下一课前,好好休息一下吧,我也休息一下,有两段结束语不翻译了。

推荐阅读更多精彩内容