上节课,我们已经实现了一个本地通知。为什么我要求你们要先按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
好了,你已经确定它在工作了,你需要从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。
最终我们的故事模版是这个样子的:
看起来很壮观吧。
你得到了应得的回报,当你准备好开始下一课前,好好休息一下吧,我也休息一下,有两段结束语不翻译了。