我们先来讲几种循环数组的方法
首先是我们已经用过很多次的for in,像下面这个样子:
for category in categories { . . .
这一行的意思是把categories中的每一个对象依次放入名为category的临时常量中。
然而,为了得到每个对象的index-path,而不是每个对象的名称,你需要使用另一种方法:
for i in 0..
我们使用半开操作符0..<使临时i依次从0到categories.count-1递增。如果你需要数组中的索引而不是名称,这是一种常见的方式。
还有一种方法是使用enumerated()方法,你会在下一个课程中见到它,现在我们先大概了解一下:
for (i,category) in categories.enumerated() {
...
}
回到我们的app,打开storyboard,拖拽一个新的Table View Controller到画布中。在身份检查器中设置Class为CategoryPickerViewController。
选择table view cell,在属性检查器中设置Style为Basic,Identifier为Cell。
选定Category Cell,然后按住ctrl拖拽到这个新的table view controller上,然后在转场类型中选择Selection Segue下的Show。
将这个转场的Identifier设置为PickCategory。
注意:如果你的界面中,右边的table view controller顶部有一个返回Tag Location选项,是没问题的,不知道只作者截图有问题,还是Xcode版本升级导致的。
storyboard部分就到此结束了,我们下面开始代码部分。
打开LocationDetailsViewController.swift并且添加一个新的实例变量categoryName。你会用它来临时存储被选择的分类名称。
var categoryName = "No Category"
这个变量的初始值是 "No Category"。它同时也是分类列表中最上面的第一个选项。
修改一下viewDidLoad(),将categoryName的值放入标签中:
override func viewDidLoad() {
super.viewDidLoad()
descriptionTextView.text = ""
categoryLable.text = categoryName //修改这里
...
最后,添加转场方法prepare(for:sender:) :
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
if segue.identifier == "PickCategory" {
let controller = segue.destination as! CategoryPickerViewController
controller.selectedCategoryName = categoryName
}
}
这里只是简单的设置了category picker的属性selectedCategoryName。通过这一操作,现在app就有了一个分类。
运行app,实际看看效果:
嗯,看起来效果不错。你现在可以选择一个分类了,但是你选择某一行后不会自动关闭这个界面。当你点击返回按钮后,你选择的分类也不会显示在界面上。
练习:整个拼图中缺少了哪一部分?
答案:CategoryPickerViewController目前没有任何通讯方式向LocationDetailsViewController返回数据,比如用户选择了一个新的分类。
此时你一定会恍然大悟,原来如此!你忘记给它一个委托协议了。这就是为什么它无法给其他视图控制器传递消息。
确实,一个委托协议是个不错的方法,但是我想给你展示一个新的方法,这是storyboard的一个特色功能,可以达到和委托协议相同的效果,但是工作量要比创建一个委托协议小一些,它叫做:unwind segues(不知道怎么翻译这个术语合适T T)。
如果你想知道storyboard中的的红色“Exit”图标是什么,你现在有了你的答案,没错,它就是:unwind segues。
regular segue用于打开一个新的界面,而unwind segue用于关闭一个当前激活的界面。听起来很简单。然而,创建unwind segue的方法不是非常直观。
这个Exit图标似乎没有任何作用,试试按住ctrl拖拽cell上去,它不会形成一个链接。
首先,你要添加一个特殊类型的动作方法。
打开LocationDetailsViewController.swift,添加下面的方法进去:
@IBAction func categoryPickerDidPickCategory(_ segue: UIStoryboardSegue) {
let controller = segue.source as! CategoryPickerViewController
categoryName = controller.selectedCategoryName
categoryLable.text = categoryName
}
因为这个方法是以@IBAction前缀开头的,所以它是个动作方法。但是它和一般的动作方法有什么区别呢?区别在于它的参数,是一个UIStoryboardSegue对象。
通常,如果动作方法有一个参数的话,它应该是触发这个动作的控件,比如按钮和滑条。但是为了创建一个unwind segue,你需要将动作方法的参数写为UIStoryboardSegue。
这个方法内部代码的意思非常明显。你找到是那个视图转场到这个界面来的(就是源界面),在这里它就是CategoryPickerViewController,然后读取它的selectedCategoryName属性。它正好包含用户选择的分类名称。
打开storyboard。按住ctrl拖拽cell到Exit按钮上,这次应该能够创建链接了:
然后在弹出菜单的Selection Segue分节下选择categoryPickerDidPickCategory,就是你刚才创建的用于unwind segue的动作方法的名字。
如果无法创建链接,请确定你选中的是cell,而不是Content View或者其中的Label。
运行app,是不是非常简单?好像也不是那么简单,被选择的分类被忽视掉了...
这是因为虽然categoryPickerDidPickCategory()方法看到了selectedCategoryName属性,但是这个属性此时没有写入值。
你需要一个机制,当unwind segue转场被触发时,你可以把用户点击的那一行的分类名称写入到selectedCategoryName属性中。
我想这个机制应该就是prepare(for:sender:),没错,这个方法对各种转场都适用。
打开CategoryPickerViewController.swift,添加prepare(for:sender:)方法:
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
if segue.identifier == "PickedCategory" {
let cell = sender as! UITableViewCell
if let indexPath = tableView.indexPath(for: cell) {
selectedCategoryName = categories[indexPath.row]
}
}
}
这段代码看起来就是把被选择行的相应地category name(分类名称)放入selectedCategoryName属性中。
这段代码假设unwind segue转场的名称叫做“PickedCategory”,所以你还需要设置这个转场的名称。
不幸的事,unwind segue在storyboard中并不可见。没有一个普通转场那样的大大的箭头。你只能在略缩面板中选择它:
选择unwind segue,然后打开属性检查器,设置identifier为PickedCategory。
再次运行app,现在category picker应该可以正常工作了。只要你选择一条分类,界面就会自动关闭,并且新的分类的名称也可以显示在返回的界面中。
unwind segue非常棒,并且比使用委托协议要简单的多,特别是在我们设计的这个app中。
改进用户体验
虽然Tag Location界面已经具备了很多功能,但是它还是可以再改进一下。改进一些小细节,可以使你的app更加人性化,并且在竞争对手中脱颖而出。
我们先来看看Description text视图的设计:
在text view和cell的边界之间有10点的距离,但是因为它俩的背景都是白色的,这样会使用户无法分辨text view的起点位置。
有可能会导致用户刚好点在边边上,而无法编辑文本,这是非常让人讨厌的。你以为你已经点到了,但是其实没有点到,并且没有任何反馈提示,用户有可能会以为这个app就是垃圾,怒删之。
所以这里我们要改进一下,不管用户点击到了这个cell的任何位置,text view都应该被激活,即使用户正好点到了边边上。
在LocationDetailsViewController.swift的// MARK: - UITableViewDelegate注释后面添加下面的方法:
override func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? {
if indexPath.section == 0 || indexPath.section == 1 {
return indexPath
} else {
return nil
}
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
if indexPath.section == 0 && indexPath.row == 0 {
descriptionTextView.becomeFirstResponder()
}
}
tableView(willSelectRowAt)方法限定了,仅前两个分节(section)的cell可以被点击。回忆一下,||操作符是或的意思,所以仅当section为0或者1时,其中的cell可以被点击,而其余的cell都是只读的。
tableView(didSelectRowAt)方法用来处理实际被选择的行。你不需要对Category或者Add Photo进行响应,这些cell是链接到转场的。
但是假如用户点击了第一个分节中的第一行,那么你立刻激活text view。&&操作符是与的意思,就是and。
运行app,试试效果,看看点击cell边缘,而不是text view内部,能否激活text view(如果模拟器中的小键盘没有自动弹出的话,可以使用快捷键command + K)
任何你可以挽救用户体验的工作都是非常值得的。
就text view而言,一旦你激活了小键盘,就无法在关闭它,要知道小键盘可是占据了一半的屏幕,这会让用户抓狂。
当你点击屏幕中其他位置时,让小键盘自动关闭,是非常棒的一个功能,实现起来也不是特别麻烦。
在viewDidLoad()方法的最后面添加下面的语句:
let gestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(hideKeyboard))
gestureRecognizer.cancelsTouchesInView = false
tableView.addGestureRecognizer(gestureRecognizer)
gesture recognizer(手势识别器)是非常便利的一个对象,它可以识别点击和手指的移动。你只是简单的创建一个gesture recognizer对象,当特定的手势被观察到时,调用一个你指定的方法,并且把这个识别器添加到视图中。
你使用了一个UITapGestureRecognizer,它可以识别简单的点击,还有一些其它的对象,可以识别扫动,按压,合拢等。
注意一下#selector()关键字:
...target: self, action: #selector(hideKeyboard))...
每当手势发生时,通过#selector告诉UITapGestureRecognizer要被调用的方法。
这个模式叫做target-action(目标-动作),并且你已经使用过多次了,比如链接UIButton,UIButtonItems,以及其它控件的动作方法时,其实用的都是这个模式。
traget就是接受被发送消息的对象,通常就是self,action就是发送的消息。
这里你做的就是,当在table view的其它地方出现点击这个行为时,hideKeyboard消息就会被发送,所以你要执行一个方法来响应这个消息。
打开LocationDetailsViewController.swift添加hideKeyboard()方法。把它放在viewDidLoad()方法的下面,其实放在其它地方也可以:
@objc func hideKeyboard(_ gestureRecognizer: UIGestureRecognizer) {
let point = gestureRecognizer.location(in: tableView)
let indexPath = tableView.indexPathForRow(at: point)
if indexPath != nil && indexPath!.section == 0 && indexPath!.row == 0 {
return
}
descriptionTextView.resignFirstResponder()
}
⚠️:在Objective-C中,选择器(selector)是一种引用Objective-C方法名称的类型。 在Swift中,Objective-C选择器由Selector结构表示,可以使用#selector表达式来构造。 同时需要向Objective-C传递方法名称。
这就是在hideKeyboard()方法名称前加上@objc前缀的作用。
本书写作的时候Swift版本还是3,不需要添加这个前缀,但是Swift4中必须要这样做,我在后面翻译的时候,也会不断的将Swift4的新特性加进去。
话说Swift的本意是要摆脱Objective-C,但是毕竟iOS框架与Objective-C已经相爱相杀20余年了,所以你懂的。。
无论何时,用户点击table view内任何地方时,手势识别器就会调用这个方法。方便的是,它也传递了一个引用作为参数给自己,它可以让你向手势识别器询问点击的发生位置。
gestureRecognizer.location(in: tableView)方法返回一个CGPoint数。CGPoint是你在UIKit中随处可见的一种结构。它包含两个字段,x和y,用于描述界面上的位置。
使用这个CGPoint数,你就可以向table view询问目前是位置上是哪个index-path。这非常重要,因为当用户点击text view内部时,你不能把键盘隐藏掉。而如果点击的是其它地方,你则需要隐藏这个键盘。
练习:这里的if语句你熟悉吗?能不能试着解释一下呢?
答案:用户可能会在table view内部轻击,而不在单元格内,例如在两个部分之间的某个位置或部分text view上。 在这种情况下,indexPath将是nil,所以此时IndexPath是个可选型,需要使用if let或者感叹号来对它进行解包。
只有当index-path不是section为0和row为0时,你才隐藏小键盘。
⚠️:如果一个可选型有可能为nil的话,你不能对其强制解包,除非你愿意承担app崩溃掉的风险。所以上面方法中的indexPath!.section和indexPath!.row看起来很危险,但是这里是没问题的,因为前面有一个短路语句,就是indexPath != nil,如果indexPath为nil则整个if条件为假,其中的语句不会执行。
另外一个可选的写法是:
if indexPath == nil ||
!(indexPath!.section == 0 && indexPath!.row == 0) {
descriptionTextView.resignFirstResponder()
}
能看明白吗?这个if语句和之前的意思完全相反,但目的是相同的,这个if语句使用了||或操作符,意思是indexPath为nil或者index-path不是section为0和row为0时,隐藏小键盘(感叹号出现在表达式前面时,是非的意思)。
熟练的使用各种逻辑操作符,是你编程生涯中很重要的一个环节,幸好它不是很难。
当然你也可以用if let安全的解包,像下面这个样子:
if let indexPath = indexPath, indexPath.section != 0 &&
indexPath.row != 0 {
return
}
descriptionTextView.resignFirstResponder()
我只是给你简单的展示了一下if语句的多样性。你可以选择一个你喜欢的一直用下去就好了。
运行app,点击text view会自动弹出一个小键盘,如果没有就用快捷键command + K。
我们还可以实现,当用户进行滚动操作的时候,也自动隐藏小键盘。
打开storyboard,选择Tag Location界面中的table view。在属性检查器中找到Keyboard,选择其中的Dismiss on drag选项,这样就可以实现了,简单吧。
如果设置没有生效的话,就在真实设备上试试,模拟器中的虚拟键盘有时候不是很聪明。
同时试试Dismiss interactively选项,看看那个你更加喜欢。