这是***【总结回顾】iOS Apprentice Tutorial 2:Checklists ***系列的第七篇文章,也是最后一篇文章了,前几篇文章请见(一) 、(二)、(三)、(四)、(五)、(六)。
本篇文章总结本书的第章( Improving the user experience、Extra feature: local notifications)中的重点内容,主要是讲述如何实现一些提升用户体验的功能,例如记录每个清单里未完成项目的数量和清单中项目总数,每次添加新的清单后能够给清单自动排序,可给清单增加小图标,让界面更好看,以及适配所有的机型。从205页到269页(最后一页)。
67. 优化用户体验之显示未完成数量
func countUncheckedItems() -> Int {
var count = 0
for item in items where !item.checked {
count += 1
}
return count
}
此方法返回值就是此清单中没有完成的数量。当然此方法也可以有另外一种写法:
func countUncheckedItems() -> Int {
var count = 0
for item in items {
if !item.checked {
count += 1
}
}
return count
}
然后在AllListsViewController.swift中加入下列方法,保持显示数据的同步:
override func viewWillAppear(animated: Bool) {
super.viewWillAppear(animated)
tableView.reloadData()
}
当然接下来还可以实现一些小功能,比如全部完成的时候文案显示全部完成,清单里还没有添加项目的时候显示没有项目等等。
68. 优化用户体验之自动排序
func sortChecklists() {
lists.sortInPlace({ checklist1, checklist2 in
return checklist1.name.localizedStandardCompare(checklist2.name) == .OrderedAscending
})
}
这个方法的好处是,如果用户是用中文的,排序按照拼音的顺序排序,如果是英文,则按照英文从a到z的顺序来。用户使用的语言不对,相应的顺序也是不一样的。
真正的排序公式是:
checklist1.name.localizedStandardCompare(checklist2.name) == .OrderedAscending
如果你想按其他方式排序,只需要改动这一行代码即可。
当然,还要在下载读取plist文件(也就是在loadChecklists()
方法里)的时候,调用这一方法
69. 优化用户体验之增加选择图片
允许用户给每个list选择一个图标,实际效果如下:
当然了,创建编辑list的时候,也需要增加图标这个选项:
将设计好的图片放入Xcode,在 Checklist.swift 文件里增加iconName变量,声明,初始化,NSCdoing 的两个协议方法,都要加上。然后到AllListsViewController、ListDetailViewController里把对应的图片显示出来(一个是所有清单列表的界面,一个是增加或者编辑清单的界面)。在增加或编辑清单界面中点击第二行cell,跳转到之后选择图标界面,Segue的Identifier是“PickIcon”,这个界面(IconPickerViewController.swift)需要先创建。
当用户没有选择图标时,实际上显示的是一张完全透明的图片,这样的效果就是下图中右侧的情况:
新建选择图片的swift文件:IconPickerViewController.swift,storyboard中拖入一个tableview Controller,关联,代码如下。
import UIKit
protocol IconPickerViewControllerDelegate: class {
func iconPicker(picker: IconPickerViewController, didPickIcon iconName: String)
}
class IconPickerViewController: UITableViewController {
weak var delegate: IconPickerViewControllerDelegate?
let icons = [
"No Icon",
"Appointments",
"Birthdays",
"Chores",
"Drinks",
"Folder",
"Groceries",
"Inbox",
"Photos",
"Trips" ]
override func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return icons.count
}
override func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCellWithIdentifier("IconCell", forIndexPath: indexPath)
let iconName = icons[indexPath.row]
cell.textLabel!.text = iconName
cell.imageView!.image = UIImage(named: iconName)
return cell
}
override func tableView(tableView: UITableView, didSelectRowAtIndexPath indexPath: NSIndexPath) {
if let delegate = delegate {
let iconName = icons[indexPath.row]
delegate.iconPicker(self, didPickIcon: iconName)
}
}
}
然后回到增加或者编辑清单界面的view controller里补上IconPickerViewController.swift里创建的delegate的有关的代码,别忘了在类的开头写上:IconPickerViewControllerDelegate
。
下面代码都是因为增加了图标而做出了相应修改的代码:
override func viewDidLoad() {
super.viewDidLoad()
if let checklist = checklistToEdit {
title = "Edit Checklist"
textField.text = checklist.name
doneBarButton.enabled = true
iconName = checklist.iconName
}
iconImageView.image = UIImage(named: iconName)
}
@IBAction func done() {
if let checklist = checklistToEdit {
checklist.name = textField.text!
checklist.iconName = iconName
delegate?.listDetailViewController(self, didFinishEditingChecklist: checklist)
} else {
let checklist = Checklist(name: textField.text!, iconName: iconName)
delegate?.listDetailViewController(self, didFinishAddingChecklist: checklist)
}
}
override func tableView(tableView: UITableView, willSelectRowAtIndexPath indexPath: NSIndexPath) -> NSIndexPath? {
if indexPath.section == 1 {
return indexPath
} else {
return nil
}
}
override func prepareForSegue(segue: UIStoryboardSegue, sender: AnyObject?) {
if segue.identifier == "PickIcon" {
let controller = segue.destinationViewController as! IconPickerViewController
controller.delegate = self
}
}
func iconPicker(picker: IconPickerViewController, didPickIcon iconName: String) {
self.iconName = iconName
iconImageView.image = UIImage(named: iconName)
navigationController?.popViewControllerAnimated(true)
}
}
上面的代码有好多行,貌似是一项巨大工程,其实主要是设计的方法有些多,所以容易落下某个代码没有改。总结起来,就三件事情:
- 增加一个新的 view controller 对象。
- 在Storyboard中设计界面(还需要做一些自动布局约束的工作)
- 然后用 segue 和 delegate 将这个新创建的 view controller 对象连接到 增加编辑清单 界面。
70. 优化用户体验之优化界面+适配机型
作者在优化外表的时候走了一个快捷方式,使用 tint color。tint color 能改变哪些地方呢?见下图:
而且更改 tint color 的方法非常简单,无需在Storyboard中一个一个界面的改,只要改掉 Global Tint 即可。如下图:
这样全局的 tint color 都是你想要的颜色啦多简单,一步到位
还有一个改进的地方:启动的时候,显示App的一部分,这样给用户一种错觉,App很快就启动起来了。其实现国外的App这样的设计比较多,国内的App都不这样做了,都会弄个广告啊,或者图片啊什么的。不过,也了解一下如何实现吧:
至于书中说的适配所有机型,其实只修改了 TextField 控件,只要这个控件在不同的机型下显示有些问题。
71. 函数式编程
近年来,函数式编程日趋流行。使用函数式编程有好处,可以缩短代码量。不过对于新手来说,可能阅读代码的时候会有些不习惯,不过慢慢习惯了就好了。
比如:
func countUncheckedItems() -> Int {
var count = 0
for item in items where !item.checked {
count += 1
}
return count
}
用函数式编程写出来就是
func countUncheckedItems() -> Int {
return items.reduce(0) { cnt, item in cnt + (item.checked ? 0 : 1) }
}
reduce()
这个方法,每次看到一个item就执行一遍{}里的代码。cnt变量一开始的值是0,每次根据item的情况来加1或0.
72. Convenience Initializer
convenience init(name: String) {
self.init(name: name, iconName: "No Icon")
}
init(name: String, iconName: String) {
self.name = name
self.iconName = iconName
super.init()
}
好吧,这个地方我实际上没太看懂,先复制一下原文,等哪天理解了,再总结一下。
Instead of
super.init()
it now callsself.init(name, iconName)
. Because it farms out its work to another init method,init(name)
is now known as a convenience initializer. It does the same thing asinit(name, iconName)
but saves you from having to type iconName: "No Icon" whenever you want to use it.
init(name, iconName)
has become the so-called designated initializer for Checklist. It is the primary way to create new Checklist objects, whileinit(name)
exists only for the convenience of lazy developers... such as you and me. :-)
73. 本地提醒功能(local notifications)
首先要了解的一点是,这是 local notifications,不是开发时常常用到的 push notifications,push notifications可以让你的 App 接收外部的事件,比如新闻推送某只球队进了世界杯。
local notifications 更有点像是闹钟,用户在使用 App 的时候,设置了一个时间点,到点就会提醒。提醒的前提是,应用已经在后台运行,或者应用没有启动,如果应用正在使用,本地通知是不会显示的。这时候需要用其他方法另作处理。
在 iOS8 之后,只有在获得用户的允许后才能发送 local notifications,如果用户拒绝,到点也不会出现提醒信息。获取用户许可的方法在后面会说。
(1)扩展 data model
增加了提醒意味着数据也要增加相对应的属性,因为每个清单里的一个item里有提醒,所以要做ChecklistItem文件里增加属性。不过 UILocalNotification对象是无法存入plist文件中的,需要找替代方案,比如数字标识符:numeric primary。
var dueDate = NSDate()
var shouldRemind = false
var itemID: Int
接着在 NSCoding 的2个协议方法增加相应的代码。在Objective-C语言中,Int,Float和Bool是原始类型,所以会看到shouldRemind
、checked
和itemID
的方法有别与非原始类型的对象。
required init?(coder aDecoder: NSCoder) {
text = aDecoder.decodeObjectForKey("Text") as! String
checked = aDecoder.decodeBoolForKey("Checked")
dueDate = aDecoder.decodeObjectForKey("DueDate") as! NSDate
shouldRemind = aDecoder.decodeBoolForKey("ShouldRemind")
itemID = aDecoder.decodeIntegerForKey("ItemID")
super.init()
}
func encodeWithCoder(aCoder: NSCoder) {
aCoder.encodeObject(text, forKey: "Text")
aCoder.encodeBool(checked, forKey: "Checked")
aCoder.encodeObject(dueDate, forKey: "DueDate")
aCoder.encodeBool(shouldRemind, forKey: "ShouldRemind")
aCoder.encodeInteger(itemID, forKey: "ItemID")
}
不要忘了更新初始化方法,注意代码中DataModel是大写开头的。
override init() {
//注意这里是大写开头的DataModel
itemID = DataModel.nextChecklistItemID()
super.init()
}
在初始化方法里写这么一行代码是什么意思呢?不管什么时候只要App创建了一个新的 ChecklistItem 对象后,都让 DataModel 对象生成一个新的item ID。
之所以可以大写开头,是因为我们要在DataModel类中创建一个类方法(方法前面有个class,详细内容见#74):
class func nextChecklistItemID() -> Int {
let userDefaults = NSUserDefaults.standardUserDefaults()
//读取出来值
let itemID = userDefaults.integerForKey("ChecklistItemID")
//写入新值
userDefaults.setInteger(itemID + 1, forKey: "ChecklistItemID")
//保存同步
userDefaults.synchronize()
return itemID
}
NSUserDefaults里面没有ChecklistItemID这个键啊,是的,没有,所以需要加入:
func registerDefaults() {
let dictionary = [ "ChecklistIndex": -1,
"FirstTime": true,
"ChecklistItemID": 0 ]
NSUserDefaults.standardUserDefaults().registerDefaults(dictionary)
}
(2)搭建界面
先上效果图:
这个直接去控件库里拖动相对应的控件即可,没啥可说的,别忘了自动布局的约束。而下图这个就有些问题了,作者提供的方法非常新颖,我使用之后发现有个不好的地方,就是扩展性不够好,没法扩展到多个时间选择器上,比如这里只要提醒时间,要是我再加入开始时间和结束时间,不好实现同样的效果,扩展性不够。
步骤如下:
步骤一,把cell拖到下图中箭头所在的位置:
拖拽之后会出现这样的效果:
cell高度设为217,拖入一个DatePicker控件,效果如下:
(3)对应的Controller里编写代码:
还要创建Outlet连接:
@IBOutlet weak var shouldRemindSwitch: UISwitch!
@IBOutlet weak var dueDateLabel: UILabel!
@IBOutlet weak var datePickerCell: UITableViewCell!
@IBOutlet weak var datePicker: UIDatePicker!
声明新的变量:
var dueDate = NSDate()
var datePickerVisible = false
更新viewDidLoad()
中的方法:编辑状态下2个属性要加入+更新提醒日期的Label内容(updateDueLabel()
):
override func viewDidLoad() {
super.viewDidLoad()
if let item = itemToEdit {
title = "Edit Item"
textField.text = item.text
doneBarButton.enabled = true
//新增加的两行代码:
shouldRemindSwitch.on = item.shouldRemind
dueDate = item.dueDate
}
//需要创建的方法:
updateDueDateLabel()
}
updateDueLabel()
方法代码:
func updateDueDateLabel() {
let formatter = NSDateFormatter()
formatter.dateStyle = .MediumStyle
formatter.timeStyle = .ShortStyle
dueDateLabel.text = formatter.stringFromDate(dueDate)
}
NSDateFormatter可以改变时间显示的格式。
@IBAction func done()
方法里也要增加新增的2个属性。
下面的代码都是有关如何显示时间选择器cell的:
看完这些方法,你就会理解我为什么说作者提供的这个方法非常麻烦了。
- cellForRowAtIndexPath
override func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
if indexPath.section == 1 && indexPath.row == 2 {
return datePickerCell
} else {
return super.tableView(tableView, cellForRowAtIndexPath: indexPath)
}
}
2)numberOfRowsInSection
override func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
if section == 1 && datePickerVisible {
return 3
} else {
return super.tableView(tableView, numberOfRowsInSection: section)
}
}
3)heightForRowAtIndexPath
override func tableView(tableView: UITableView, heightForRowAtIndexPath indexPath: NSIndexPath) -> CGFloat {
if indexPath.section == 1 && indexPath.row == 2 {
return 217
} else {
return super.tableView(tableView, heightForRowAtIndexPath: indexPath)
}
}
4)didSelectRowAtIndexPath
override func tableView(tableView: UITableView, didSelectRowAtIndexPath indexPath: NSIndexPath) {
tableView.deselectRowAtIndexPath(indexPath, animated: true)
textField.resignFirstResponder()
if indexPath.section == 1 && indexPath.row == 1 {
if !datePickerVisible {
showDatePicker()
} else {
hideDatePicker()
}
}
}
5) willSelectRowAtIndexPath
override func tableView(tableView: UITableView, willSelectRowAtIndexPath indexPath: NSIndexPath) -> NSIndexPath? {
if indexPath.section == 1 && indexPath.row == 1 {
return indexPath
} else {
return nil
}
}
6)indentationLevelForRowAtIndexPath
override func tableView(tableView: UITableView, var indentationLevelForRowAtIndexPath indexPath: NSIndexPath) -> Int {
if indexPath.section == 1 && indexPath.row == 2 {
indexPath = NSIndexPath(forRow: 0, inSection: indexPath.section)
}
return super.tableView(tableView, indentationLevelForRowAtIndexPath: indexPath)
}
到此结束了。但这样只能让cell一直出现,时间选择器会一直在这里出现的,这可不是我们想要的效果。我们需要点击一下显示,再点击一下隐藏,那么好吧,继续写代码吧~
下面这些代码有关显示隐藏时间选择器
1)显示时间选择器
func showDatePicker() {
datePickerVisible = true
let indexPathDateRow = NSIndexPath(forRow: 1, inSection: 1)
let indexPathDatePicker = NSIndexPath(forRow: 2, inSection: 1)
if let dateCell = tableView.cellForRowAtIndexPath(indexPathDateRow) {
dateCell.detailTextLabel!.textColor = dateCell.detailTextLabel!.tintColor
}
tableView.beginUpdates()
tableView.insertRowsAtIndexPaths([indexPathDatePicker], withRowAnimation: .Fade)
tableView.reloadRowsAtIndexPaths([indexPathDateRow], withRowAnimation: .None)
tableView.endUpdates()
datePicker.setDate(dueDate, animated: false)
}
2)隐藏时间选择器
func hideDatePicker() {
if datePickerVisible {
datePickerVisible = false
let indexPathDateRow = NSIndexPath(forRow: 1, inSection: 1)
let indexPathDatePicker = NSIndexPath(forRow: 2, inSection: 1)
if let cell = tableView.cellForRowAtIndexPath(indexPathDateRow) {
cell.detailTextLabel!.textColor = UIColor(white: 0, alpha: 0.5)
}
tableView.beginUpdates()
tableView.reloadRowsAtIndexPaths([indexPathDateRow], withRowAnimation: .None)
tableView.deleteRowsAtIndexPaths([indexPathDatePicker], withRowAnimation: .Fade)
tableView.endUpdates()
}
}
优化用户体验
启动键盘后要隐藏日期选择器:
func textFieldDidBeginEditing(textField: UITextField) {
hideDatePicker()
}
日期选择器中的时间每次发生改变后都更新Label内容:
@IBAction func dateChanged(datePicker: UIDatePicker) {
dueDate = datePicker.date
updateDueDateLabel()
}
(4)如何以及什么时候安排通知(schedule the notifications)
好吧,终于开始写有关通知的代码了,上面那么多东西,都是铺垫铺垫铺垫。。。
先创建一个方法,用来创建通知:
func scheduleNotification() {
}
这个方法写入ChecklistItem.swift这个文件里。
关于通知的时间,要注意,过去的时间点是无法提醒的,但是用户设置了过去的时间怎么办呢?
dueDate.compare(NSDate()) = .OrderedAscending
.OrderedAscending
表示 dueDate 的时间在前,现在时间NSDate()在后,也就是说,提醒时间发生在过去。只有将时间设置到将来才可以提醒,所以设置提醒的条件为:!=,当然了,还要用户开启提醒功能:
if shouldRemind && dueDate.compare(NSDate()) != .OrderedAscending {
}
补充一下,NSComparisonResult 的结果,也就是 A.compare(B) 结果有三种:
- .OrderedAscending,也是我们正在使用的,A发生在B之前(A在过去,B在将来)。
- .OrderedSame,表示两个时间一致,A和B时间是同一个时间点。
- .OrderedDescending,表示A发生在B之后,也就是先经过B时间点,然后才是A时间点。
当然了,每创建一个新的 Item 或者编辑一个 Item 的时候,都要检查一下是否要创建通知,所以:
@IBAction func done() {
if let item = itemToEdit {
item.text = textField.text!
item.shouldRemind = shouldRemindSwitch.on
item.dueDate = dueDate
item.scheduleNotification()
delegate?.itemDetailViewController(self, didFinishEditingItem: item)
} else {
let item = ChecklistItem()
item.text = textField.text!
item.checked = false
item.shouldRemind = shouldRemindSwitch.on
item.dueDate = dueDate
item.scheduleNotification()
delegate?.itemDetailViewController(self, didFinishAddingItem: item)
}
}
现在可以创建通知了,创建本地通知有7行非常关键的代码:
- 创建本地通知
- 创建提醒时间
- 创建时间提醒的时区
- 创建通知显示的文案或内容
- 创建通知使用的声音
- 通知提醒的是哪个内容(哪个item)
- 将创建好的本地通知排入时间表
用代码表示也就是:
let localNotification = UILocalNotification()
localNotification.fireDate = dueDate
localNotification.timeZone = NSTimeZone.defaultTimeZone()
localNotification.alertBody = text
localNotification.soundName = UILocalNotificationDefaultSoundName
localNotification.userInfo = ["ItemID": itemID]
UIApplication.sharedApplication().scheduleLocalNotification(localNotification)
注意第一行代码我们实际上创建的是一个UILocalNotification对象,要使用这个对象,需要先 import UIKit。
当然我们要获取用户的许可才能设置通知,处理方法就是,当用户将switch按钮调整到on的时候,向用户获取许可,Ctrl拖拽创建Action连接:
@IBAction func shouldRemindToggled(sender: UISwitch) {
//隐藏键盘
textField.resignFirstResponder()
if sender.on {
let notificationSettings = UIUserNotificationSettings(forTypes: [.Alert , .Sound], categories: nil)
UIApplication.sharedApplication().registerUserNotificationSettings(notificationSettings)
}
}
当我们设置时间的时,会记录到秒,比如提醒时间是10:16:54,实际上我们在设置时间的时候,时间选择器里只显示到了分,但是提醒的时候,却会连秒也考虑到里面。如果想提高用户体验,去掉秒,直接在0秒的时候就提醒,那么,这是另外一个话题了。如何解决呢?作者也没说。
通知可能发生变化的五种情形:
- 当用户新建一个 ChecklistItem 对象后,将 ShouldRemind 转换按钮调整到了开上,需要安排一个新的通知(相当于新建通知)。
- 当用户改变提醒日期后,旧的通知需要取消,然后更换上新的通知日期。
- 当用户将 ShouldRemind 转换按钮调整到关的状态后,当前的通知需要取消掉。
- 当用户删除当前的 ChecklistItem 后,该Item下的通知需要被取消。
- 当用户删除一整个 Checklist 后,里面所有的Item下的通知都需要被取消。
我们上面的各种步骤已经完成了新建通知,那么编辑和删除如何进行呢?
先说编辑,当我们编辑一个item的时候,先看是否存在一个通知,如果存在,取消通知即可,然后新建。每次编辑,都相当于删除旧的,重新创建新的通知,如果用户在编辑的时候没有修改通知,顶多就是新建了一个和之前一模一样的通知而已。
func notificationForThisItem() -> UILocalNotification? {
let allNotifications = UIApplication.sharedApplication().scheduledLocalNotifications!
for notification in allNotifications {
if let number = notification.userInfo?["ItemID"] as? Int where number == itemID {
return notification
}
}
return nil
}
判断此item是否有通知。然后放入创建通知的方法里:
func scheduleNotification() {
let existingNotification = notificationForThisItem()
if let notification = existingNotification {
UIApplication.sharedApplication().cancelLocalNotification(notification)
}
if shouldRemind && dueDate.compare(NSDate()) != .OrderedAscending {
let localNotification = UILocalNotification()
localNotification.fireDate = dueDate
localNotification.timeZone = NSTimeZone.defaultTimeZone()
localNotification.alertBody = text
localNotification.soundName = UILocalNotificationDefaultSoundName
localNotification.userInfo = ["ItemID": itemID]
UIApplication.sharedApplication().scheduleLocalNotification(localNotification)
}
}
最后剩下的就是删除了,即通知可能发生变化的五种情形的最后两种情绪,用一个方法即可解决:
deinit {
if let notification = notificationForThisItem() {
UIApplication.sharedApplication().cancelLocalNotification(notification)
}
}
当年删除单个的ChecklistItem或者一个整个Checklist时,都会调用上面方法。
74. Class method vs. instance method(类方法和实例方法)
class func nextChecklistItemID()
class 这个关键词意味着你可以直接调用该方法,不用创建一个 DataMode 的对象的引用。
之前用的都是 instance method,只使用于类中某些实例。那什么时候用类方法什么时候用引用方法呢?哪个方法用的代码更少就用哪个方法啦,毕竟在这个App里,就这么一个地方用到了DataModel,如果以后你需要更新App,增加更多功能,很有可能就需要使用创建引用类型,然后使用引用方法。
更多详细的介绍会在下一本书中讲解,所以留着点疑问去看下一本书吧~
结束了
终于总结完毕了,终于看完了,本来以为一个周就能搞定,结果来来回回拖了这么久才完成,我也是小瞧了这本书,本来以为是100米短跑,跑完5000米之后才发现,前面还有一个马拉松。。。这就是我总结这本书的感受,再也不敢随意估算时间了,以后自己估计时间了之后再乘以3,如果完全没做过,乘以7,就是实际时间了。
照着书敲三遍代码,和总结一遍知识点,完全不是一个量级上的工作,虽然中间数次想放弃,好歹也走到了今天。有了这本书的知识,我完全可以开发一个无网络交互的本地App了,最起码数据持久化可以搞定了。要是哪天App上线了,我要在这里更新,多说几句~~
能看完的都是,我很佩服,总之,看到错别字或者错误的知识点,还请指正~~