编辑已有的待办事项名称
在app中添加新的待办项目到列表中对你是一个巨大的进步,但是通常伴随着新增还有其他两个操作需要实现,那就是:
1、删除待办项目(通过轻扫某一行删除,我们已经在前面实现了)
2、编辑已存在的待办项目
编辑已存在的项目是非常有用的,比如你想对待办项目重命名或者你打错字了需要修改,都需要用到编辑功能。
你也许可以做一个全新的界面来完成这个功能,但是你会发现这个功能和新增待办事项界面没有什么区别,唯一的不同就是一开始文本框中存在字符,而新增时一开始文本框是空的。
所以我们来重用新增待办事项界面,让它具备修改已存在项目的名称的能力。
当用户点击Done按钮后你做的并不是创建一个新的CheclistItem对象,而是简单的修改已存在的CheclistItem对象的文本。
你同时也要通知委托这里做出了修改,这样委托就会更新列表中相应的table view cell。
练习:为了实现这个功能,我们都需要做哪些改造,你能做个列表出来吗?
答案:1、编辑项目时界面需要被重命名为Edit Item(现在是Add Item)
2、你必须能读取到一条存在的ChecklistItem对象。
3、你必须把这个ChecklistItem对象的文本放入文本框。
4、当用户点击Done按钮时,你不应该添加新的ChecklistItem对象,而是更新被编辑的那个。
这里有一些用户界面问题需要处理,比如用户如何打开编辑项目的界面?多数app都是通过直接点击某一行完成这个动作,但是在我们的这个app中,点击动作已经被占用了,它用来控制对勾符号的开关。
为了解决这个问题,你需要先修改一下UI设计。
当一行具备两个功能的时候,标准的做法是使用‘详细信息按钮(detail disclosure button)’来完成第二个功能。
点击某一行时,还是执行原来的动作,在我们的app中就是控制对勾符号的开关,而当点击详细信息按钮时,打开编辑项目界面。
⚠️:还有一种可选择的方法是,只有点击左边对勾符号的区域时,用于开关对勾符号,而点击这一行的其他地方时,打开编辑项目界面。
还有一些app是这样做的,你可以把整个屏幕触发为可编辑状态,然后可以逐条编辑项目。具体使用哪种方法依赖于哪种方法最有利于你的数据模型。
打开故事模版选定table view cell,然后进入到属性检查器,找到Accessory选项并且选择Detail Disclosure(注意:是Detail Disclosure,不是Detail)选项。
这时对勾符号将被一个蓝色感叹号+一个大于号到按钮图标替代掉。这就意味着你要重新选择一个地方放置对勾符号。
拖拽一个新的label到cell中去,并且做出如下设置:
文本 :√(option+v可以输入这个符号)
字体:Helvetica Neue,bold,大小22
Tag:1001
如果option+v没有这个符号,你可以从Xcode顶部菜单中选择Edit->Emoji & Symbols。然后在弹出窗口的搜索栏中输入“check”,选择一个对勾符号,或者选择任何你喜欢的图形。(注意,在进行此操作时,先双击标签,处于可以输入文本的状态下再进行,另外,这些特殊符号也许在部分手机上无法正确显示)
重新调整两个标签的位置和大小,不要互相覆盖,也不要覆盖到右边的蓝色感叹号按钮上去。
重新设计后的prototype cell应该是这种样子的:
打开ChecklistViewController.swift,将configureCheckmark(for:with:)改变为:
func configureCheckmark(for cell: UITableViewCell,with item: ChecklistItem) {
let label = cell.viewWithTag(1001) as! UILabel
if item.checked {
label.text = "√"
} else {
label.text = ""
}
}
现在我们不修改cell的accessory属性了,而是修改新增的label的文本。
运行app,你会看到对勾符号从右边转移到左边了,同时这里多了一个蓝色感叹号的详细信息按钮在右边。点击某一行会开关对勾符号,而点击详细信息按钮则不会。
现在,我们开始着手处理点击详细信息按钮后打开编辑界面的事情。这非常简单,因为界面建造器允许你为详细信息按钮添加一个转场。
打开故事模版。选定table view cell并且按住ctrl拖拽到旁边的Navigation Controller上,在弹出窗口中选择Accessory Action分节下的Present Modally。
现在从Checklists界面到导航栏有两个转场了,一个是用于➕按钮,一个用于cell中的详细信息按钮。
为了使app能够区分两个转场,我们必须给它们不同的身份id。
选择新的转场箭头,然后打开属性检查器,在identifier中输入EditItem。
如果你运行app的话,点击蓝色的感叹号按钮,会打开新增待办事项界面,但是此时你点击Cancel按钮的话,不会有任何作用。
练习:想一想为什么?
答案:你还没有配置委托。一定要记得你是在prepare(for:sender:)中设置委托的,但是只有➕号按钮的AddItem转场设置委托,你还需要对EditItem转场做同样的事情。
在你修复这个问题前,你需要先使新增待办事项界面拥有编辑ChecklistItem对象的能力。
打开AddItemViewController.swift,添加一个新的实例变量:
var itemToEdit: ChecklistItem?
这个变量用于包含用户准备编辑的ChecklistItem对象。但是当新增一个待办项目时,itemToEdit会是nil,这就是视图控制器如何区分新增和编辑的。
因为itemToEdit会为nil,所以它必须是可选型的,这就是问号的作用。
还是在AddItemViewController.swift中,添加viewDidLoad()方法:
override func viewDidLoad() {
super.viewDidLoad()
if let item = itemToEdit {
title = "Edit Item"
textField.text = item.text
}
}
回忆一下viewDidLoad()是党视图控制器从故事模版中被加载时由UIKit调用,此时界面还没有展现在屏幕上。这就给了你时间配置用户界面。
在编辑模式下,也就是当itemToEdit不为nil时,你改变导航栏的名称为“Edit Item”。你通过改变导航栏的title属性来完成这一功能。
每个视图控制器都有几个内建的属性,title正是其中之一。导航控制器读取到title的值后自动改变导航栏的名称。
你同时也设置了文本框的值为item的text属性。
if let
你不能像使用普通变量那样使用可选型变量。例如,如果你直接这样写语句的话:
textField.text = item.text
这样Xcode的编译器会给出一个报错,“Value of optional type ChecklistItem?not unwarped(类型为ChecklistItem的可选型变量的值没有解包)”
这是因为itemToEdit是ChecklistItem的可选型变量。
要使用它你首先要进行解包。通过一个特殊的语法完成这一功能:
if let temporaryConstant = optionalVariable {
// temporaryConstant now contains the unwrapped value
// of the optional variable
}
如果这个可选型不是nil,if的条件为真,if语句体内的代码才会被执行。
还有一些其他方法可以读取可选型变量的值,但是使用if let是最安全的:如果可选型没有值,或者为nil,那么if语句体内的代码会被自动略过。
可选型让你头晕脑胀了吗?多做练习,熟能生巧,每个人都是这样过来的。可选型是swift的特色,多数主流语言没有这个功能,许多开发者都会在这里付出大量精力去理解它。
尽管难以理解,但是可选型可以避免空指针错误并且防止你的app挂掉。
现在AddItemViewController有能力识别什么时候进入编辑界面了。如果itemToEdit拥有一个ChecklistItem对象,那么界面会魔法般的变成编辑界面。
但是你在哪里给itemToEdit赋值呢?当然是在转场的时候了!这里最理想的地方用于给变量赋值,在界面即将显示在屏幕上前把一切都配置好。
打开ChecklistViewController.swift修改prepare(for: sender:)为下面这个样子:
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
if segue.identifier == "AddItem" {...
} else if segue.identifier == "EditItem" {
let navigationController = segue.destination as! UINavigationController
let controller = navigationController.topViewController as! AddItemViewController
controller.delegate = self
if let indexPath = tableView.indexPath(for: sender as! UITableViewCell) {
controller.itemToEdit = items[indexPath.row]
}
}
}
和以前一样,你从故事模版中读取导航控制器,并且使用topViewController属性调用AddItemViewController。
你同时也设置了视图控制器的委托属性,这样用户点击Cancel或者Done按钮时,你就可以得到通知了,这里做的事和“AddItem”转场一模一样。
比较有意思的是新增加的这一部分:
if let indexPath = tableView.indexPath(for: sender as! UITableViewCell) {
controller.itemToEdit = items[indexPath.row]
prepare(for:sender:)方法有一个叫做sender的参数。这个参数包含一个控制转场的引用,在我们的这个情况中,就是table view cell的详细信息按钮被点击时。
你设置了一个UITableViewCell对象用于定位被点击的那一行的行号相应的index-path,通过使用tableView.indexPath(for:)。
tableView.indexPath(for:)的返回类型为IndexPath?,是一个可选型,这就意味着它可能返回nil。这就是为什么在你使用它前需要用if let来解包的原因。
一旦你有了一个行号,你就可以获得需要被编辑的ChecklistItem对象,并且你同时将它分配给了AddItemViewController的itemToEdit属性。
视图控制器之间互相发送数据
我们讲过界面B(Add/Edit Item screen)回传数据给界面A(Checklists screen)是通过委托完成的。
但是这里你是从界面A向界面B传递数据,也就是说传递一个ChecklistItem对象用于编辑。
数据在视图控制器间传递有两种方法:
1、从A到B。当界面A打开界面B时,A可以给B需要的数据。你简单的在B的视图控制器中创建一个实例变量,然后A转场到B时给这个变量赋值就可以了,这一工作通常都是在prepare(for:sender:)中完成。
2、从B到A。B回传数据给A则需要使用委托。
下图说明了A发送数据给B时,是如何对B的变量赋值的,以及B是如何通过委托向A回传数据的:
我希望你能初步的了解了视图控制器间的数据传递方式。在我们的这个课程中还会出现几次这样的场景,请务必掌握这一知识。
制作iOS app的本质就是创建视图控制器并且在其中传递数据,你要把这件事变成自己的第二本能。
做完这些步骤后,你可以运行一下app,点击➕按钮会打开新增待办事项界面,而点击某一行上的蓝色感叹号按钮后会弹出编辑界面,界面上已经存在了待编辑的条目:
有个小问题:导航栏上的Done按钮一开始是被禁用的,这是因为你最初的时候在故事模版中设置禁用了它。
打开AddItemViewController.swift,添加一行代码进去:
override func viewDidLoad() {
super.viewDidLoad()
if let item = itemToEdit {
title = "Edit Item"
textField.text = item.text
doneBarButton.isEnabled = true //添加这一行
}
}
在编辑模式下,可以安全的在一开始就将Done按钮置为可用状态,因为文本被删至没有时,Done会自动被禁用。
真正的问题不在这里,眼下你运行app,编辑某一行后点击Done按钮,你会发现原先的一行并没有被修改,取而代之是,新增了一行上去。
你还没有写代码来复制并且更新数据模型,所以委托会以为你的目的是新增一行。
为了解决这个问题,你需要在委托协议中新增一个方法。
打开AddItemViewController.swift,在协议中添加如下代码:
func addItemController(_ controller: AddItemViewController,didFinishEditing item: ChecklistItem)
现在整个协议内容看起来是这个样子的:
protocol AddItemViewControllerDelegate: class {
func addItemControllerDidCancel(_ controller: AddItemViewController)
func addItemController(_ controller: AddItemViewController,didFinishAdding item: ChecklistItem)
func addItemController(_ controller: AddItemViewController,didFinishEditing item: ChecklistItem)
}
现在用户点击Done按钮后一共有两个方法用于响应。
当新增一行后调用didFinishAdding,而当编辑一行后调用didFinishEditing。
通过调用不同的委托方法就可以分别处理这两种不同的情景了。
打开AddItemViewController.swift,将done()方法修改为:
@IBAction func done() {
if let item = itemToEdit {
item.text = textField.text!
delegate?.addItemController(self, didFinishEditing: item)
} else {
let item = ChecklistItem()
item.text = textField.text!
item.checked = false
delegate?.addItemController(self, didFinishAdding: item)
}
}
首先我们检查itemToEdit属于是否包含一个对象,你应该明白if let的作用是解包可选型变量。
如果这个可选型变量不为空,你就在文本框中放入一条已存在的ChecklistItem对象并且调用新的didFinishEditing方法。
如果为空,则添加一条新的纪录进去,就像我们之前做的一样。
试着运行app,你会看到一个惊喜,app挂了。
Xcode说道:“Build Failed”,但是在AddItemViewController.swift中看不到任何报错,这是为什么?
你可以在Xcode的问题导航器(Issue navigator)中看到一个报错信息:
这个报错信息是发生在ChecklistViewController中的,因为它没有执行协议中的方法。这并不奇怪,因为你刚把didFinishEditing方法添加到协议中,但是你还没有告诉视图控制器,谁来扮演委托的角色。
⚠️:在我的这个版本的Xcode中报错信息为:Method 'addItemController(_:didFinishAdding:)' has different argument names from those required by protocol...,这是个比较奇怪的报错信息。它并没有准确的描述什么地方出了问题,仅仅是反应了Swift对目前的情况困惑不解。
当你制作自己的app时,你也许会面对Swift不可描述的奇怪报错信息。随着经验的积累,你会变得熟悉这些情况。Swift的编译器刚问世不久,它也需要被完善。
在ChecklistViewController.swift中添加以下代码,就可以把刚才的报错化为历史了:
func addItemController(_ controller: AddItemViewController, didFinishEditing item: ChecklistItem) {
if let index = items.index(of: item) {
let indexPath = IndexPath(row: index, section: 0)
if let cell = tableView.cellForRow(at: indexPath) {
configureText(for: cell, with: item)
}
}
dismiss(animated: true, completion: nil)
}
这样ChecklistItem就有了新的文本,cell也是已经存在在table view中的,你只是需要更新table view cell中的标签。
这个新的方法就是你用来寻找cell对应的ChecklistItem对象,然后通过configureText方法来更新标签。
第一行语句对我们而言比较新鲜:
if let index = items.index(of: item)
你需要从cell中读取所需的IndexPath,首先你就需要寻找到ChecklistItem对象的行号。行号和ChecklistItem在items数组中的索引值是一致的,然后你通过index(of)方法来返回这个index。
虽然在我们的这个app中不会发生,但是理论上数组中的某个对象会不存在,这个时候index(of)会返回nil,所以它的返回值是可选型的,所以我们用if let对它进行解包。
试着运行app,噢,我想我太心急了。Xcode给出了另一个报错:Cannot invoke index with an argument list of type blah blah blah。这是怎么回事?
这是因为你不能在任意对象上使用index(of),只能在“相同”的对象上使用它。index(of)以某种方式对你在数组中寻找的对象进与调用它的对象行比较,看看它们是否相等。
你的ChecklistItem对象现在并不具备这个功能。这里有好几种方式处理这个问题,我们来用最简单的一种。
在ChecklistItem.swift的类声明的那一行添加一点东西:
class ChecklistItem: NSObject {
如果你之前使用过Object-C,那么你应该对NSObject非常熟悉。
几乎所有Object-C中的对象都是基于NSObject的。这是由iOS提供的最基本的代码块,它可以提供Swift对象所没有的大量有用的基础功能。
你以Swift写代码时候大多数时候并不需要搭理NSObject,但是眼下是必须的。
将ChecklistItem建立在NSObject之上,就可以使它安全的进行比较了。后面我们在学习如何存储checklist对象时,你也必须将它转换为NSObject。
运行app,试试功能,现在一切都正常了。
重构代码
现在你已经有了一个app,可以实现新增待办事项以及编辑事项的功能,非常不错。
但是我发现AddItemViewController这个名字起的不太恰当了,毕竟这个界面现在承载着新增待办事项和编辑已有项目两个功能,我们应该将它重命名为ItemDetailViewController。
现在我们有一个好消息和一个坏消息,你想先听哪个?
好消息是Xcode有一个特殊的菜单用于重构源代码,包含重命名的工具,你可以在Edit->Refactor中找到它。
坏消息是在Xcode 8.0中这个功能并不支持Swift语言,仅支持Object-C,所以你不能使用它,差评。
所以我们的唯一选择就是手工完成这件事,幸运的是,Xcode有一个非常便利的搜索和替换功能。我们接下来一步步的做完这个工作。
1、打开搜索导航起,工程导航界面上的第三个按钮。
2、点击Find,切换为Replace(替换)。
3、将上图中的Ignoring Case,切换为Matching Case。
4、在搜索框中输入AddItemViewController。重要:确保你的拼写无误。
5、在替换框中输入ItemDetailViewController
6、点击键盘上的回车键开始搜索,这一操作不会进行替换。
搜索结果会返回所有相关的匹配项。你应该可以看到两个swfit源文件和Main.storyboard。
7、点击Preview按钮。Xcode会打开一个界面里面包含各个文件中将被替换掉部分。
仔细的逐条对比每一对替换,确保没有替换掉不应该替换掉东西。这次替换仅仅是把名为AddItemViewController的字样替换为ItemDetailViewController,包含故事模版内的。
8、点击Replace按钮并且祈祷。如果Xcode需要你确认,点击Continue。
9、在工程导航器中,选定AddItemViewController.swift,然后再点击一次,两次点击不要太快,然后你就可以对这个文件进行重命名了。
新的名字是:ItemDetailViewController.swift
还没有结束,你还要为之前的委托协议进行重命名。
1、再次切换到搜索导航器
2、确定处于Replace模式下(如果不是点击Find切换到Replace)
3、确保处于Matching Case模式下(如果不是则点击Ignoring Case切换)
4、输入搜索文本addItemViewController,注意开头是小写的a
5、输入替换文本itemDetailViewController,注意开头是小写的i
6、敲击键盘上的回车键。
搜索结果应该只包含协议委托方法的名称,分别在ChecklistViewController.swift和ItemDetailViewController.swift两个文件中。
7、点击Replace All进行替换。
替换完毕后,可以再进行一次搜索确认替换结果,此时应该搜不到任何东西了,因为都被替换完了。
现在ItemDetailViewController.swift中的协议方法应该是下面这个样子:
protocol ItemDetailViewControllerDelegate: class {
func itemDetailViewControllerDidCancel(_ controller: ItemDetailViewController)
func itemDetailViewController(_ controller: ItemDetailViewController,didFinishAdding item: ChecklistItem)
func itemDetailViewController(_ controller: ItemDetailViewController,didFinishEditing item: ChecklistItem)
}
用command+B键来重新编译以下代码,如果顺利,编译会成功通过。
⚠️:如果编译报错,那么一定是搜索和替换的关键字错了,重新检查一下,尤其注意大小写,在swift中ItemDetailViewController和itemDetailViewController是两个完全不同的对象,它们开头的字母大小写不一样。
如果你运行app崩溃的话,检查一下故事模版中Add Item这个界面的身份检查器中的Custom Class此时应该为ItemDetailViewController。有时在搜索替换时,Xcode会漏掉这个地方。
因为你做了很多改动,此时最好清理一下缓存先,通过Xcode的菜单Product->Clean来完成缓存清理。
如果没有问题,你就可以重新运行app,好好的测试下各种场景了。确保一切工作正常。(如果Xcode编译正常但是仍然显示有报错,那么就整个关掉Xcode再重新打开试试)
迭代开发(Iterative development)
如果你觉得我们这个课程里的开发过程太啰嗦了,为什么不一步到位的讲呢?非要弄出这么多弯路。这种想法是不正确的。
当你从一个设计开始实现具体功能的时候,你总会发现很多问题没有考虑清楚,所以你需要在实践的过程中不断的重构自己的代码,最终达到理想效果。
软件开发就是这样的一种工作。
一开始你只是完成一小块功能,并且看上去好像没有什么问题。然后你又添加另外一小部分进去,突然间意料之外的问题就出现了。这时候也许你就不得不推翻之前的设计一切从头开始了。直到最终完成为止你都不得的频繁的重复这个行为。
软件开发就是一个不断的细致化的过程。所以在我们的课程中,我不会给你最完美的结局方案,而是通过不断的修改至臻完善,并且详细的展示每一小块的细节。因为真实的软件开发就是这样一个工作。
而你则会从0出发直到完成一个完整的app,并且在这个过程中持续的解决问题,就像哪些专业人士做的一样。
在软件的领域不存在高屋建瓴的设计,像建筑图纸那样的东西,我并不相信这些设计。当然提前做好计划是没错的,但是不能把计划当成实现方式。在写这些教程的时候,我会画一些草图来想象这些app应该如何运作。这个工作的效果显著的,但是经常性的,在开发的过程中会遇到问题,使我不得不改变当初的想法,这还仅仅是这样一个小的app而已。
这并不是说你不要去做提前的规划,只是不要在这个上面浪费太多时间而已。
你要做的是简单的从某个步骤开始做起,直到遇到问题卡住,然后来回溯并且找到问题的解决办法。这就叫做迭代开发,这种方法比提前规划好蓝图的效果和效率要快的多了。