来个简短的插曲—关于函数编程
Swift首先是一种面向对象编程语言,但是实际上存在一种其他风格的编程方式并且在最近几年也很流行,那就是函数编程。
“函数”这个术语的意思就是,可以纯粹的通过数学函数来传送并且改变数据。
不同于Swift中的函数或者方法,这些数据函数不允许出现所谓的“侧面效应”,对于任何给定的输入,这个函数总是会产生一个同样的输出。方法则没这么严格。
虽然Swift不是一种函数语言,它还是可以让你在app使用一些函数编程的技术。它们会使你的代码变的非常简洁。
作为例子,我们再来看一看countUncheckedItems():
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()是一个函数,作用为对每一个条目执行花括号内的代码。最初变量cnt包含的值为0,但是每次执行后它都会增加0或者1,这取决于条目是否checked。
当reduce()结束后,它返回为被打勾的数量的总和。
你不用记住或者理解这些,但是了解一下Swift可以允许你执行这种简洁的算法,是非常棒的。
对列表进行排序
对列表常见的另一个操作就是以某种规则排序。
让我们来以名称为顺序来对列表进行排序。目前当你添加一个Checklist的时候,新增的条目总是出现在最后一条,无论它的首写字母在字母表中的顺序如何。
在我们了解如何对数组进行排序前,让我们想想什么时候需要执行排序:
1、新增的时候
2、重命名的时候
没有必要在删除掉时候进行重新排序,因为删除不会引起序列的变化。
目前这两种操作都是由AllListsViewController中的 “didFinishAdding” 和“didFinishEditing”来执行。
将这两个方改动一下:
func listDetailViewController(_ controller: ListDetailViewController, didFinishAdding checklist: Checklist) {
dataModel.lists.append(checklist)
dataModel.sortChecklists()
tableView.reloadData()
dismiss(animated: true, completion: nil)
}
func listDetailViewController(_ controller: ListDetailViewController, didFinishEditing checklist: Checklist) {
dataModel.sortChecklists()
tableView.reloadData()
dismiss(animated: true, completion: nil)
}
在这两个方法中你删掉了不少东西,因为现在你总是在table view上使用reloadData()。
它再也不用手动插入一个新行了,或者更新cell的textLabel。而是简单的通过调用tableView.reloadData()来刷新整个列表的内容。
你再一次使用了全量更新,因为我们这个app中不太可能会出现大量数据。如果我们的列表中会出现上千行,那么用更加科学的方法才会显得比较有必要。(对于大量数据,你可以定位新增或者重命名的是哪一行,然后仅仅对这一行进行更新)
DataModel中的sortChecklists()是一个新的方法,你还没有添加它。在这之前,我们来讨论下排序是如何实现的。
当你对一个列表中的条目进行排序时,app会逐条比较来找出每一条合适的位置,但是比较两个Checklist对象是什么意思呢?
在我们的app中,很显然,我们想要根据名称进行排序,但是我们需要一些方法来告诉app我们的意图。
在DataModel.swift中添加以下方法:
func sortChecklists() {
lists.sort(by: {
checklist1, checklist2 in
return checklist1.name.localizedStandardCompare(checklist2.name) == .orderedAscending
})
}
这里你告诉lists数组Checklists的内容要以某种准则排序。
这个准则由闭包提供。花括号内就是排序的代码;它们以闭包的形式存在:
lists.sort(by: { /* 这里就是排序代码 */ })
在Bull's Eye这个课程中,我们简单的介绍过一次闭包。它们包裹一段代码到一个匿名的,内联的方法中。
这个闭包的作用是判断一个Checklist对象是否应该排在另一个之前,基于你提供的排序规则。
这个排序算法会通过闭包中的准则重复的两两比较列表中的Checklist对象,直到数组被排序完毕。
如果你想以其他标准来进行排序,那么你就要修改闭包中的代码。
实际的排序准则是这个:
checklist1.name.localizedStandardCompare(checklist2.name) ==
.orderedAscending
比较两个Checklist对象,你只需要知道它们的名字。
localizedStandardCompare() 方法会结合区域中的规则比较两个字符串,比较时会忽略大小写(它会认为A和a是一样的)。
区域是一个对象,它知道一个国家中的语法细节,比如Sorting在德语中的意思可能和英语中不太一样。
这就是对一个数组进行排序的全部工作:调用sort()并且给它一个闭包,闭包中包含着比较两个Checklist对象的逻辑。
为了确保以存在的条目也被同样的规则排序,你需要在plist文件被加载时也调用sortChecklists()方法:
func loadChecklists() {
...
sortChecklists()
}
}
运行app并且新增几个待办事项分类。改变它们的名称,观察下是否已经按照名称排序了。
给待办分类添加图标
因为真正的iOS开发者不能无限制的添加视图控制器和委托,所以我们要通过给Checklist对象添加一个新属性的方法来增加图标。我们要把这些概念固化在你的脑袋里。
当你完成后,编辑和新增待办事项分类的界面会看起来是这个样子:
当你新增或者编辑待办事项分类的时候,可以通过当前界面打开一个新的界面来选择图标。这个图标选择器是一个新的视图控制器。这一次你不会使用modally转场,而是会将它推入导航器的栈堆。
可以在课程附带的Resources文件里找到相关的图片文件,文件夹的名字是Checklist Icons(小伙伴们可以通过购买正版图书得到这个附件,也可以去网上下载一些自己喜欢的素材图标来练习)
把图标从这个文件夹添加到Asset Catalog里。在工程导航起上选择Assets.xcassets,点击底部的加号按钮,并且选择Import
在文件选择时打开Checklist Icons目录,全选其中的文件。
注意:一定要选择文件,而不是目录。
然后点击Open,导入就完成了。
每一个图标都有对应Retina设备的2x版本和对应Retina HD的3x版本。
就像我在之前的课程中说过的一样,你不需要1x的低分辨率图标,所有的iPhone,ipad或者iPod,只要能运行iOS 10,就一定是2x或者3x分辨率。
打开Checklist.swift,添加一条属性:
var iconName: String
变量iconName存储图标的文件名。
扩展一下init?(coder)和encode(with),让它们可以分别在Checklist.plist文件中读取和存储这个图标名称。
required init?(coder aDecoder: NSCoder) {
name = aDecoder.decodeObject(forKey: "Name") as! String
items = aDecoder.decodeObject(forKey: "Items") as! [ChecklistItem]
iconName = aDecoder.decodeObject(forKey: "IconName") as! String
super.init()
}
func encode(with aCoder: NSCoder) {
aCoder.encode(name, forKey: "Name")
aCoder.encode(items, forKey: "Items")
aCoder.encode(iconName, forKey: "IconName")
}
假如你以后想要自己给这个app扩展什么功能的话,记住这一点,你要把所有涉及到的新的属性都添加到Checklist中,否则app就不会从plist文件中读取或者存储它们。
Xcode现在仍然有所不满,它在抱怨init(name)方法中没有这个新的属性,报错信息为:Property self.iconName is not initialized at super.init call
这个意思是,如果Checklist对象用init(name)初始化,而不是用init?(coder)初始化时,iconName没有值。
更新一下init(name)方法:
init(name: String) {
self.name = name
iconName = "Appointments"
super.init()
}
这样会给所有新的checklist一个“ Appointments”图标。
我猜你现在一定很想知道如何在table view上展示这个图标。不过在这之后,你还有更多的问题要操心,比如如何使用户选择图标。
打开AllListsViewController.swift,把图标放入cell中:
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
...
cell.imageView!.image = UIImage(named: checklist.iconName)
return cell
}
使用标准cell的.subtitle风格时,会自带一个内建的UIImageView在左边。你可以简单的将图片给它,然后它会自动将图片展现出来,非常简单。
在重新运行app前,删除掉Checklist.plist文件,然后重置模拟器,因为我们的文件结构又发生了变化,如果不这样做,app就随时会死给你看。
运行app,现在每个待办分类都有了一个闹钟样子的图标:
改造非常成功,你现在可以改变一下Checklist的init(name)方法,给每个Checklist对象一个“No Icon”图标作为默认。
打开Checklist.swift,在init(name)内,稍微改动一下:
iconName = "No Icon"
"No Icon"是个完全透明的PNG图片,它和其他图片的大小一模一样。使用透明图片的必要性在于,这样可以使所有的行格式一样,即使用户有时不想要一个图片。
如果我们仅仅是把iconName设置为空的话,那么图片位置就不会显示出来,标签会位于最左边,看起来非常难看,大家可以比较一下没有图片和透明图片的区别:
让我们来创建图片选择界面:
添加一个Swift文件到工程中,取名为IconPickerViewController。
将该文件中的预置内容全部删掉,替换为以下代码:
protocol IconPickerViewControllerDelegate: class {
func iconPicker(_ picker: IconPickerViewController,didPick iconName: String)
}
class IconPickerViewController: UITableViewController {
weak var delegate: IconPickerViewControllerDelegate?
}
这里定义了IconPickerViewController对象,它是一个table view controller,并且有有一个用来和这个app中其他对象通信的协议。
在class内部,新增一个常量数组来保存各种图片的名称:
let icons = ["No Icon",
"Appointments",
"Birthdays",
"Chores",
"Drinks",
"Folder",
"Groceries",
"Inbox",
"Photos",
"Trips"]
这是一个包含图片名称的常量数组。这些字符串同时也是asset catalog中PNG文件的名称。
这个数组就是这个视图控制器的数据模型。注意一下,这个数组不会发生改变,因为它是用let而不是var定义的,因为用户不能增加或者删除图片。
这个新的视图控制器是一个UITableViewController,所以你需要执行table view的数据源方法。
在文件中添加以下方法:
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return icons.count
}
这里简单的返回了数组中对象的数量
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "IconCell", for: indexPath)
let iconName = icons[indexPath.row]
cell.textLabel!.text = iconName
cell.imageView!.image = UIImage(named: iconName)
return cell
}
这里你得到了一个table view cell并且给它了一个文本和图片。稍候你会在故事模版里设计这个cell。
这是一个标准cell,风格为“Default”(在界面建造器中对应的名字是“Basic”)。这个风格的cell自带一个文本标签,一个图片视图,非常的便利。
打开故事模版。拖拽一个Table View Controller到画布上,把它放在List Detail View Controller的旁边(就是名字叫做“Add Checklist”)的那个。
打开这个新视图的身份检查器,将在class中填入IconPickerViewController。
选定cell,在属性检查器中设置Style(风格)为Basic,Identifier为IconCell。
这样,图片选择器的cell就设置好了。现在你需要在某些地方调用它。为了实现这个目的,你需要在Add/Edit Checklist界面添加一个新的行。
选择List Detail View Controller,并且在table view中添加一个新的分节。你可以在table view的属性检查器中将Sections设置为2。这样就有两个分节了。
删除掉新cell上的Text Field,你并不需要它。
新增一个标签到这个cell上,并且命名为Icon。
设置这个cell的Accessory为Disclosure Indicator,这样你会得到一个灰色的大于号的按钮。
添加一个Image View到这个cell的右边,大小为36*36(可以使用尺寸检查器来设置)
⚠️:如果你的Mac OS版本为Sierra,Xcode中的Image View也许会展现为一个白色的矩形,非常难以观察。我认为这是Xcode的一个bug,在EI Capitan版本的Mac OS上,Image View就展现为一个蓝色的矩形。
打开辅助编辑器为这个image view添加一个outlet,命名为iconImageView。(辅助编辑器中的文件应该是在ListDetailViewController.swift)
这时,两个界面的设计都完成了,你现在可以使用转场把它们连接起来了。
按住ctrl拖拽这个新的table view cell到Icon Picker View Controller并且将转场的类型设置为Show。(确保你拖拽的是table view cell,而不是其中的图片或者标签)
将这个新的转场的identifier设置为PickIcon。
托转场的福,这个新的视图控制器有了一个导航栏。双击导航栏,将其标题命名为Choose Icon。
⚠️:如果双击无法修改导航栏标题,你可以先拖拽一个Navigation Item到这个新的视图控制器上。然后就可以修改标题了,Xcode也是存在一些bug的。
现在你的故事模版中的这一部分,看起来应该是这个样子:
打开ListDetailViewController.swift,将table view的willSelectRowAt委托方法修改一下:
override func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? {
if indexPath.section == 1 {
return indexPath
} else {
return nil
}
}
如果不这样做的话,点击“Icon”cell,就不会触发转场。
之前这个方法总是返回nil,就是说不可能对这一行进行点击。现在你必须允许用户点击“Icon”cell所在对行,所以当点击这一行时,需要返回它的indexPath。
因为“Icon”cell是第二个分节中的一行,所以你要查看一下indexPath.section,这里并不需要检查行号,因为第二个分节只有一行。用户仍然不能选择第一个分节中的行。
运行app,确认一下,在添加和编辑Checklist的界面有一行是用于选择图标的,并且点击这一行后会展示图标的列表。
你可以点击back按钮来回到上一步,但是选择图标功能还不会生效,你点击一个图标,仅仅是可以看到这一行变灰了,表示已经被选中。
为了完成剩下的部分,你需要使用icon picker的委托协议把它和Checklist连接起来。
首先,在ListDetailViewController.swift中添加一个实例变量:
var iconName = "Folder"
你使用这个变量来跟踪被选择的图标名称。
因为即使Checklist对象已经有了一个iconName属性,你也不能保证百分百的追踪到被选择图标的名称,原因很简单,因为有可能此时Checklist对象根本不存在,比如正在新建一个Checklist的时候。
所以你要把图标的名称存储到一个临时变量里,并且在合适的时候把这个临时变量的值拷贝到Checklist的iconName属性中。
你要在合理的对iconName变量进行初始化。仅对新建Checklist对象时需要这样做,我们需要在新建Checklist对象时,给它一个默认图标,就用文件夹图标好了。
改动一下viewDidLoad()方法:
override func viewDidLoad() {
super.viewDidLoad()
if let checklist = checklistToEdit {
title = "Edit Checklist"
textField.text = checklist.name
doneBarButton.isEnabled = true
iconName = checklist.iconName //添加这一行
}
iconImageView.image = UIImage(named: iconName) //添加这一行
}
这里新增了两行,如果checklistToEdit不为nil,那么你将Checklist对象的iconName拷贝到新建的iconName变量中,你同时还读取图标的图片文件到一个新的UIImage对象中,并且将它设置到iconImageView中,这样图标就可以展示出来了。
之前你创建的转场名称叫做“PickIcon”,你还需要执行prepare(for:sender:)来告诉IconPickerViewController这个界面是它的委托。
打开ListDetailViewController.swift,添加下面的方法:
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
if segue.identifier == "PickIcon" {
let controller = segue.destination as! IconPickerViewController
controller.delegate = self
}
}
这对你应该很轻车熟路了。
当然,Xcode已经发现了有些事情不对,并且给出了一个大大的警告:在“controller.delegate = self”这一行。
Xcode的意思是:“Cannot assign a value of type 'ListDetailViewController' to a value of type
'IconPickerViewControllerDelegate?'”(不能把类型ListDetailViewController的值分配给IconPickerViewControllerDelegate)
练习:我们忘记了什么?
答案:你还没有让这个视图控制器遵循委托协议,所以Swift不会使ListDetailViewController成为icon picker的委托。
在class这一行添加一点东西进去:
class ListDetailViewController: UITableViewController,UITextFieldDelegate,IconPickerViewControllerDelegate
并且执行委托协议中的方法,随便放在什么地方都可以,但是要在ListDetailViewController class的里面:
func iconPicker(_ picker: IconPickerViewController, didPick iconName: String) {
self.iconName = iconName
iconImageView.image = UIImage(named: iconName)
let _ = navigationController?.popViewController(animated: true)
}
这里将被选择的图标名称存放到iconName变量中,并且用新的图片更新了image view。
这里不能调用dismiss()而是调用popViewController(animated),因为Icon Picker位于导航栈堆中。当创建这个转场的时候你使用了Show而不是present modally,所以这里要使用“pop”,dismiss()仅用于present modally。
回忆一下,navigationController是这个视图控制器的一个可选型的属性,所以你使用问号或者感叹号来解包得到实际的UINavigationController对象。通过let _ =你告诉了Xcode不需要关心popViewController()的返回结果。不这样做的话,Xcode又会给出一个警告。这里的下划线符号叫做通配符,你可以用具体的变量名称来替换它。
⚠️:你已经知道了self用于引用对象自己。这里你写的:
self.iconName = iconName
理由是这里iconName可以指代两个不同的东西:1、委托方法的参数。2、实例变量。
为了消除歧义,你用带有self前缀的来表示实例变量,这样编译器就很清楚你想要用的是这两个iconName中的那一个。
改变一下done()方法,以便于将被选择的icon name放入Checklist对象。
@IBAction func done() {
if let checklist = checklistToEdit {
checklist.name = textField.text!
checklist.iconName = iconName //添加这一行
delegate?.listDetailViewController(self, didFinishEditing: checklist)
} else {
let checklist = Checklist(name: textField.text!)
checklist.iconName = iconName //添加这一行
delegate?.listDetailViewController(self, didFinishAdding: checklist)
}
}
最后,你必须改变IconPickerViewController,使它在某一行被点击时,实际的调用委托方法。
打开IconPickerViewController.swift,在底部添加一个方法:
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
if let delegate = delegate {
let iconName = icons[indexPath.row]
delegate.iconPicker(self, didPick: iconName)
}
}
现在你就完成了对Checklist对象图标设置的工作。
回顾一下,你做了以下事情:
1、添加一个新的视图控制器
2、在故事模版中设计界面
3、使用转场和委托将这个视图控制和添加及编辑Checklist界面连接起来。
当你每新建一个视图控制的时候,都需要完成以上基本步骤。
运行app,试试效果如何: