iOS Apprentice中文版-从0开始学iOS开发-第二十一课

制作table view cell的几种方法

在AllListsViewController中创建table view cell的方法比在ChecklistViewController中略复杂一些。在后者中你仅仅是通过简单的一个语句就获得了一个新的table view cell:

let cell = tableView.dequeueReusableCell(withIdentifier: "ChecklistItem",for: indexPath)

但是在AllListsViewController中为了实现同样的目的我们写了一大堆代码:

let cellIdentifier = "Cell"
if let cell =
       tableView.dequeueReusableCell(withIdentifier: cellIdentifier) {
  return cell
} else {
  return UITableViewCell(style: .default,reuseIdentifier: cellIdentifier)
}

这里我们还是调用了dequeueReusableCell(withIdentifier),只是以前我们在故事模版中放置了cell并且给了它一个身份标示,而这次没有。

如果这个table view找不到任何可重用的cell,这个方法会返回nil,这时你不得不手动创建cell,这就是else后面跟的代码的作用。

这实际上是两种不同类型的dequeueReusableCell(...),其中一个有IndexPath参数而另一个没有。在AllListsViewController中我们使用的是没有IndexPath参数的这一个。两者的区别在于有IndexPath参数的这一个仅用于标准cell。如果在AllListsViewController中使用有IndexPath参数的这个方法,app就会崩溃掉。

制作cell有四种方法:

1、使用标准cell。这是最简单也是最快的一种方法。我们在ChecklistViewController中做的就是。

2、使用静态cell。你在Add/Edit界面中使用的就是静态cell。静态cell最大的优势就是不用给它提供数据源方法,适用于你提前知道cell内容的情况。

3、使用nib文件。一个nib(也叫做XIB)就像一个迷你的仅仅包含一个自定义的UITableViewCell对象的故事模版。这和使用标准cell非常相似,只是你是在故事模版之外使用它。

4、手动创建,就是我们在AllListsViewController中使用的方法。在早期的iOS版本中,只有这一种方法。这种方法要复杂一些,但是更加灵活。

当你手动创建一个cell时,你需要指定一个确定的cell style,就会得到一个已经包含标签和图片的预置布局的cell。

在All Lists View Controller中,你使用了“Default”style,在稍后你会将它切换为“Subtitle”,这会在主标签的下方,给你一个小一点的次级标签。

使用标准cell style意味着你不需要设计你自己的cell布局。对于大多数app而言标准cell已经足够用了。

标准cell和静态cell都可以使用标准cell style。标准cell和静态cell的默认style都是“Custom”,这种style要求你使用自己的标签,但是你可以通过界面建造器将它改变为内建的style。

最后,你需要注意的是:有时我看到其他人是这样写代码的,使用代码为每一行创建一个新的cell而不是试着重用cell。你千万不要这样做!一定要首先向table view请求看看是否有可以重用的cell,使用dequeueReusableCell(...)这个方法。

为每一行都创建一个新的cell,会使app变慢,创建一个对象总是比重用一个对象要慢。所以为每一行都创建一个新的cell会占据大量内存,为了用户着想,你也应该重用cell。

查看待办事项分类

目前,由AllListsViewController中的lists数组组成的数据模型包含了少量的Checklist对象。数据模型中同时还有来自ChecklistViewController的items数组,其中包含ChecklistItem对象。

你也许已经注意到了,当你点击任何一行时,无论是哪一行,都会展示一模一样的待办事项。

而实际上,每个待办事项分类,都应该对应不同的待办事项内容。我们之后会完成这一工作。

首先,我们来设置好映射被选择的待办事项分类的名称,作为界面的标题。

打开ChecklistViewController.swift,添加一个实例变量:

var checklist: Checklist!

过会我再讲为什么这必须是个可选型。

还是在ChecklistViewController.swift中,将viewDidLoad()方法修改为:

override func viewDidLoad() {
        super.viewDidLoad()
        title = checklist.name
    }

这一步的作用是改变界面的标题,就是导航栏的标题,将导航栏的标题修改为Checklist对象的名称。

当执行转场时,你会将这个checklist对象给到ChecklistViewController。

打开AllListViewController.swift,将tableView(didSelectRowAt)修改为:

override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        let checklist = lists[indexPath.row]
        performSegue(withIdentifier: "ShowChecklist", sender: checklist)
    }

和以前一样,你使用performSegue()来执行转场。这个方法之前有一个参数sender,之前是nil。现在你用来传递用户点击的那一行的Checklist对象。

你可以在sender参数中放置任何东西。如果你通过故事模版执行转场(而不是像现在这样手动转场),那么sender就会引用被触发的空控件,例如用于Add按钮的UIBarButton对象或者用于列表中某一行的UITableViewCell。

但是因为你是通过手动开始转场的,所以你可以在sender中放入最方便的对象。

将Checklist对象放入sender参数时,还不会将这个对象给到ChecklistViewController。这一步发生在“prepare-for-segue”中,你还没有在代码里写这个方法。

在AllListsViewController.swift中添加以下方法:

override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
        if segue.identifier == "ShowChecklist" {
            let controller = segue.destination as! ChecklistViewController
            controller.checklist = sender as! Checklist
        }
    }

你之前应该见到过这个方法。prepare(for:sender:)在转场执行后立即被调用。你可以在这里,在新的视图还没有在屏幕上可视化之前设置新视图的属性。

⚠️:转场的目标是ChecklistVieController,不是UINavigationController,这和之前有点不同。
到Add/edit界面的转场是一种modally presented(这个真心不知道怎么翻译)方式,针对与嵌入导航控制器中的视图控制器。
而这次是“Push”型的转场,直接转到Checklist View Controller。
看看故事模版就知道在All Lists界面和Checklist界面之间没有导航控制器。这个转场直接从一个视图转到另一个。

在prepare(for:sender:)中,你需要将被点击行的Checklist对象给到ChecklistViewController。这就是为什么之前你将Checklist对象放入sender参数中的原因。(你也可以将Checklist对象临时存储到一个实例变量里,但是把这个对象放入sender参数中更加简单)

所有这一切发生在ChecklistViewController被加载前,ChecklistViewController被实例化时的一瞬间。这就是说它的viewDidLoad()方法在prepare(for:sender:)之后被调用。

在这一时刻,这个视图控制器的checklist属性被来自sender的Checklist对象填充,并且viewDidLoad()可以据此修改界面的标题。

涉及转场的步骤

这一系列过程解释了为什么checklist属性被声明为可选型。因为直到调用viewload()前,它都是nil。

nil通常不是Swift中允许的变量取值,但是可选型例外。

之前我们声明可选型时用的是问号,这里是一个感叹号,感叹号的作用和问号非常类似,区别在于用感叹号时,你不需要用if let去对它进行解包。

使用这种隐式解包可选型时,需要非常小心,因为它们没有任何保护措施。

运行app,点击一个待办事型分类,转入的屏幕界面的标题会显示为这个待办事项分类的名称。

注意一点,把Checklist对象给到ChecklistViewController并不会形成一个拷贝。

你仅仅是传递这个对象的一个引用到视图控制器,用户对Checklist对象做出的任何变更,都会体现在AllListsViewController上。

这两个视图控制器读取的都是同一个Checklist对象。过会在Checklist中添加新的ChecklistItem时,这一点会成为你的便利条件。

类型扮演(type cast)

在prepare(for:sender:)中,你写了这样的代码:

override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
        ...
            controller.checklist = sender as! Checklist
            ...
    }

这里的as!是什么呢?

如果你足够细心的话,你会注意到“as something”已经出现过好几次了。这就是类型扮演(type cast)

类型扮演通知Swift解释具有不同数据类型的值。这和电影中的某一个演员正好相反,在电影中一个演员只扮演一个角色,而在swift中,类型扮演的实际作用就是改变了对象的角色。

上面的方法中,sender的参数是Any?,这意味着这个参数可以是任何类型的对象:一个UIBarButtonItem,一个UITableViewCell,或者一个Checklist对象。感谢这里的问号,使得它甚至可以为nil。

但是controller.checklist总是期待一个合适的Checklist对象,它无法处理其他对象,比如UITableViewController,因此,swift需要你只能把Checklist对象放入checklist属性中。

通过“sender as! Checklist”,你告诉了Swift它可以安全的将sender作为Checklist对象处理。

另一个类型扮演的例子是:

let controller = segue.destination as! ChecklistViewController

转场的destination(目的地)属性引用转场结束时接受到的视图控制器,显然 ,苹果的工程师无法提前预言这个视图控制器就是我们命名的ChecklistViewController。

所以你不得不在读取任何这个对象的属性前,先将它由通用类型UIViewController扮演为这个app中存在的ChecklistViewController。

在举一个例子,在loadChecklistItems()中:

 items = unarchiver.decodeObjectForKey("ChecklistItems")
                                                  as! [ChecklistIt]

NSKeyedUnarchiver将"ChecklistItems"键值下冻结的对象解码到一个数组中,但是你必须告诉swift这确实是一个包含ChecklistItem对象的数组。

没有类型扮演的的话,swfit会认为这是任何类型,这样就会造成和items数组的数据类型不相容的事情发生。

还有一种使用as?的类型扮演,这是用于可选型的类型扮演,或者说这个类型扮演可能会为nil。我们会在后面接触到这种例子。

如果你不太理解这些内容也不要担心,我们会通过大量的例子让你消化这个内容。

你使用类型扮演的最终原因是,iOS架构的通信原理是由Object-C写成的,swift在类型上的要求比OC要宽松一些,在OC中你需要更加精确的指明类型。

添加和编辑待办事项分类

让我们快速完成添加和编辑待办事项分类功能。这是另一个拥有静态cell的UITableViewController。

如果之前的代码你已经了然于心了,那么现在工作对你就是小菜一碟!

在工程导航器中新增一个Cocoa Touch Class模版或者直接新增一个swift文件,取名为ListDetailViewController。

将模版中原有的内容都删掉,替换为下面的语句:

import UIKit

protocol ListDetailViewControllerDelegate: class {
    func listDetailViewControllerDidCancel(_ controller: ListDetailViewController)
    func listDetailViewController(_ controller: ListDetailViewController,didFinishAdding checklist: Checklist)
    func listDetailViewController(_ controller: ListDetailViewController,didFinishEditing checklist: Checklist)
}

class ListDetailViewController: UITableViewController,UITextFieldDelegate {
    
    @IBOutlet weak var textField: UITextField!
    @IBOutlet weak var doneBarButton: UIBarButtonItem!
    
    weak var delegate: ListDetailViewControllerDelegate?
    
    var checklistToEdit: Checklist?
    
}

我仅仅是把ItemDetailViewController.swift中的内容拷贝过来改了改名字。同时注意一下,你现在要处理的是Checklist对象,而不是ChecklistItem。

添加一个viewDidLoad()方法:

override func viewDidLoad() {
        super.viewDidLoad()
        
        if let checklist = checklistToEdit {
            title = "Edit Checklist"
            textField.text = checklist.name
            doneBarButton.isEnabled = true
        }
    }

这样当用户编辑已经存在的待办事项分类时,可以将界面的标题修改为Edit Checklist,并且将被修改的待办事项分类的名称放入text field。

同时也添加一个viewWillAppear()方法,用于自动弹出小键盘:

override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        textField.becomeFirstResponder()
    }

然后给Cancel按钮以及Done按钮添加动作方法:

@IBAction func cancel() {
        delegate?.listDetailViewControllerDidCancel(self)
    }
    
    @IBAction func done() {
        if let checklist = checklistToEdit {
            checklist.name = textField.text!
            delegate?.listDetailViewController(self, didFinishEditing: checklist)
        } else {
            let checklist = Checklist(name: textField.text!)
            delegate?.listDetailViewController(self, didFinishAdding: checklist)
        }
    }

这些代码对你应该非常熟悉了。这和之前的编辑及添加待办事项界面几乎一模一样。

为了在done()方法中创建新的Checklist对象,你使用了Checklist的init(name)方法,并且将textField.text作为参数传入到name中。

你不能像下面这样去实现这个目的,这样做是达不到预期效果的:

 let checklist = Checklist()
checklist.name = textField.text!

因为Checklist不具备一个没有任何参数的init()方法,所以Checklist()会返回一个报错。它只有一个init(name)方法,所以你每次创建一个新的Checklist对象时,都必须用这个方法进行初始化。

同时确保用户无法选择text field所在行的cell:

override func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? {
        return nil
    }

最后添加text field的委托方法,根据用户的输入是否为空来启用或者禁用Done按钮。

func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
        let oldText = textField.text! as NSString
        let newText = oldText.replacingCharacters(in: range, with: string) as NSString
        doneBarButton.isEnabled = (newText.length > 0)
        return true
    }

这也是你在ItemDetailViewController中做过一次的事。

让我们在界面建造器中为这个新的视图控制器制作用户界面。

打开故事模版,拖拽一个Navigation Controller到画布中并且将它放置在其他视图控制器的下面。

界面建造器已经假定你要嵌入一个table view controller到导航控制器中,这样就为你省了不少事。

选定新的table view controller(名字叫做“root view controller”的那个)并且打开身份检查器。将class中填写为ListDetailViewController。

将导航栏的标题由“Root View Controller”修改为Add Checklist。(如果双击不好使的话,你可以在纲要面板中选定Root View Controller然后在属性检查器中进行改名)

添加Cancel和Done按钮并且将按钮和动作方法链接起来。同时将Done按钮和doneBarButton链接起来,并且取消选定Enable选项。

小贴士:如果你无法将 bar button拖拽到导航栏上,也可以直接往略缩面板里拖。

选中table view,然后在属性检查器中设置Static Cells,和style设置为Grouped。然后删除掉多余的两个cell。

拖拽一个Text Field到cell中,然后对其进行如下配置:

Border Style: none
Font size: 17
Placeholder text: Name of the List
Adjust to Fit: disabled
Capitalization: Sentences
Return Key: Done
Auto-enable Return key: check

然后将这个Text Field和textField outlet链接起来。

然后按住ctrl将Text Field拖拽到视图控制器上,在弹出窗口中选择delegate。这样这个视图控制器就是text field的委托了。

打开text field的链接检查器,将Did End on Exit拖拽到代表视图控制器的黄色圆圈图标上,在弹出窗口中选择done。

(以上步骤如果不熟悉,可以回头去看看之前的课程,这些步骤我们都详细做过一遍)

最终的结果

回到All Lists View Controller(就是叫做Checklists的那个),并且拖拽一个bar button上去,并且将这个button设置为Add。

按住ctrl拖拽这个新的Add按钮到下面的导航控制器上,并且在弹出窗口选择Present Modally segue。

选择这个新的转场,并且将其命名为AddChecklist。

你的故事模版现在看起来应该是这个样子:

全家福,三个导航控制和四个table view controller

坚持一下,就快完了。你还需要将AllListsViewController做成ListDetailViewController的委托。我们之前也做过一次类似的事情。

通过在All Lists view controller的class声明行中添加ListDetailViewControllerDelegate来使得它遵循这一协议。

打开AllListsViewController.swift:

AllListsViewController: UITableViewController,ListDetailViewControllerDelegate {

还是在AllListsViewController.swift中,扩展一下prepare(for:sender:),

override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
        if segue.identifier == "ShowChecklist" {
            let controller = segue.destination as! ChecklistViewController
            controller.checklist = sender as! Checklist
        } else if segue.identifier == "AddChecklist" {
            let navigationController = segue.destination as! UINavigationController
            let controller = navigationController.topViewController as! ListDetailViewController
            controller.delegate = self
            controller.checklistToEdit = nil
        }
    }

第一个if中的内容不要改动,从else if开始添加新内容。

这段代码的作用和以前一样,旬斋导航控制器中的视图控制器,并且设置它的delegate为self。

在AllListsViewController.swift的底部,添加协议方法:

func listDetailViewControllerDidCancel(_ controller: ListDetailViewController) {
        dismiss(animated: true, completion: nil)
    }
    
    func listDetailViewController(_ controller: ListDetailViewController, didFinishAdding checklist: Checklist) {
        let newRowIndex = lists.count
        lists.append(checklist)
        let indexPath = IndexPath(row: newRowIndex, section: 0)
        let indexPaths = [indexPath]
        tableView.insertRows(at: indexPaths, with: .automatic)
        dismiss(animated: true, completion: nil)
    }
    
    func listDetailViewController(_ controller: ListDetailViewController, didFinishEditing checklist: Checklist) {
        if let index = lists.index(of: checklist) {
            let indexPath = IndexPath(row: index, section: 0)
            if let cell = tableView.cellForRow(at: indexPath) {
                cell.textLabel!.text = checklist.name
            }
        }
        dismiss(animated: true, completion: nil)
    }

这些方法会在用户点击Cancel或者Done按钮时被调用。

这些代码你都应该很熟悉才对,我们之前都有完整的做过一次。

同时添加table view的数据源方法来允许用户删除某一条记录:

override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCellEditingStyle, forRowAt indexPath: IndexPath) {
        lists.remove(at: indexPath.row)
        let indexPaths = [indexPath]
        tableView.deleteRows(at: indexPaths, with: .automatic)
    }

运行app,现在你可以新增或者删除待办事项分类了:

好玩吧

⚠️:如果app崩溃了,那么就检查一下是不是所有的链接都做好了。任何一点细节的丢失,都会导致app崩溃。

你还无法对已经存在的条目进行修改,然我们来完成这最后一点代码。

之前我们也是通过转场的方式进入到编辑界面,但是这一次我们不这样做,我们要通过手动的方式来从故事模版中读取这个新的视图控制器,多掌握一些方法总是好的。

打开AllListsViewController.swift,添加一个tableView(accessoryButtonTappedForRowWith)方法。这个方法是table view的委托方法之一,其作用就和名字一样一目了然。

override func tableView(_ tableView: UITableView, accessoryButtonTappedForRowWith indexPath: IndexPath) {
        let navigationController = storyboard!.instantiateViewController(withIdentifier: "ListDetailNavgationController") as! UINavigationController
        let controller = navigationController.topViewController as! ListDetailViewController
        controller.delegate = self
        let checklist = lists[indexPath.row]
        controller.checklistToEdit = checklist
        present(navigationController, animated: true, completion: nil)
    }

在这个方法内,你为Add/Edit Checklist界面创建了新的视图控制器对象,并且将其展现在屏幕上。这和转场的作用大致相似。这个视图控制器被嵌入到故事模版中,并且你请求故事模版对象读取它。

你是在哪里获取这个故事模版对象的呢?每个视图控制器都有一个storyboard属性来引用这个视图控制器是从哪个故事模版中被读取的。你可以使用这个属性来故事模版的所有功能,比如实例化其他视图控制器。

这个storyboard属性是可选型,因为视图控制器并不全部从故事模版中读取,但是我们眼下的这个是,所以我们使用感叹号对其解包。因为我们可以确定在我们这个app中storyboard不会为nil,所以直接用感叹号强制解包就可以,而不需要用if let的方式。

调用instantiateViewController(withIdentifier)时用到了一个字符串“ListDetailNavigationController”,这就是请求故事模版创建新视图控制器的方式,在我们这个例子中,这个新的视图控制器就是包含ListDetailViewController的导航控制器。

你可以直接实例化ListDetailViewController,但是ListDetailViewController是嵌入在导航控制器内部的,如果直接实例化它而不管导航控制器的话,你就无法看到界面标题,以及Done和Cancel按钮。

打开故事模版,选择指向List Detail View Controller的导航控制器,然后打开身份检查器,将Storyboard ID填写为ListDetailNavigationController:

运行app,点击某一行上的详细信息按钮试试,如果app崩溃了,重新保存一下故事模版再运行一次。

练习:设置List Detail View Controller的identifier为ListDetailNavigationController,而不是导航控制器,然后运行app看看会发生什么,试着解释一下为什么会这样,如果你可以解释的话,那么证明你已经掌握了这些内容。

⚠️:你还能跟上我的步伐吗?
如果你对这一切非常茫然并且想要放弃的话,千万要打消这个念头。
学习新的东西本来就是一个枯燥的过程,编程尤其如此。你可以关掉电脑,去睡一觉,过几天以后再重新打开看看。
说不定就灵关一闪的明白了起来。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,088评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,715评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,361评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,099评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 60,987评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,063评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,486评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,175评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,440评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,518评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,305评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,190评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,550评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,880评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,152评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,451评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,637评论 2 335

推荐阅读更多精彩内容