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

属于你自己的待办清单app(Your own to-do app)

To-do list(待办清单)app是Apple Store里最流行的app类型之一,仅次于fart app(国外非常流行的一种整蛊app)。iPhone甚至有自己内建的提醒app(幸运的是,并没有内建的fart app)。

制作一个待办清单app,某种程度上已经成了iOS开发初学者的一种仪式,所以你也要做一个,以免被当作异教徒烧死。

属于你自己的这个待办清单app—Checklists,在它完成的时候,看起来应该是这个样子的:

完成时的Checklists

这个app可以使你将待办事项组织为一个列表,并且核对你已经做完的事项。同时你会设置一个提醒器,在哪些事项处理时间到来时在iPhone上弹窗提醒,即使app并没有运行,也同样会提醒。

Table views and navigation controller(表视图和导航控制器)

本次课程将为你介绍两种在iOS中最常用的UI元素:table view和navigation controller。

一个table view可以展示一个列表。上面图中的三个界面都使用了table view。事实上,这个app的所有界面都由table view组成。table view这种组件的适用性非常强并且也是你在iOS开发的路上遇到的最重要的一个环节,没有之一。

navigation controller(导航控制器)允许你建立一个界面层级,让你从一个页面转到另一个页面,通过导航栏顶部标题为‘back’的按钮。

在这个app中,比如你点击分类列表中叫做“会议”的分类,就会滑入具体的包含几个会议的待办列表界面中。而左上角的按钮会是你通过一个平滑的动画,回到先前的分类列表界面中。移动这些具备层级关系的界面,就是navgation controller的作用。

navigation controller和table view经常一起使用:

导航栏和列表视图

看一眼你的iPhone中内建的app—日历、信息、备忘录、联系人、邮件,设置—你会注意到,即使他们不同,但是他们都以同样的方式给你提供良好的服务。

那是因为它们都使用了table view和navigation controller:

这些app都包含table view和navigation controller

(音乐app同样也在底部包含一个选项卡,你会在下一个课程学到这些东西)

如果你想要学会如何开发iOS app,那么你必须精通这两种组件,因为它们几乎在每种app中都会出现。这就是为什么你要对本次课程投入极大精力。你同时会学会如何从一个界面传递数据到另一个界面,这是非常重要的一个技能,并且大多数初学者都会被它弄晕。

当本次课程结束时,你会非常熟悉view controller、table view和delegate这些概念,你在睡着时也可以对它们编程(虽然我希望你能梦到点其他好东西)。

这次你将会面对非常长的源代码,所以多花点时间去沉淀它们。我非常鼓励你在这些代码上自己修修改改,多去实验。改变内容看看会发生什么,即使把app搞崩溃了也不要紧。

错误会带来bug,在本次课程的全程,你会在体验痛苦到撕裂头发的挫折感,到你一瞬间意识到错误在哪里并且修正它们的舒适感的过程中。

毫无疑问,多写多练就是学习代码的最快方法。

顺便说一下:如果你有不清楚的事情——比如,你想知道为什么swift中的方法名称看起来如此滑稽—不要惊慌!有点信心,并且坚持下去,所有的东西都会在合适的时候被解释清楚。

Checklists app的设计

为了你知道你前进的方向,这里展示一下Checklists app工作方式的概览:

Checklists的所有界面

这里简单翻译一下:
1、Checklists的待办项目分类列表(点击➕或者i按钮转到2,点击具体某个条目的名称转到4)
2、添加或者编辑Checklists条目(点击默认图标转到3)
3、选择图标(比如你可以为“行程”这个分类添加一个飞机样式的图标)
4、每一个Checklists的具体待办事项(点击➕或者i按钮转到5)
5、添加或编辑某一个Checklists的具体待办事项(点击日期转到6)
6、选择一个提醒时间
7、时间到了以后,在iPhone上弹出提醒窗口

app的主界面展示你所有的Checklists(1)。你可以创建多个Checklists项目。

一个checklists需要一个名称,一个图标,0个或多个具体的待办内容。你可以点击Add/Edit来增加一个checklists或者改变checklists的名称或图标,就是(2),(3)所示。

如果你点击Checklists的名称,则会转到具体的待办事项界面,如(4)所示。

一条具体的待办事项需要一个描述,一个对勾复选框用于表示是否完成,和一个可以选择的执行时间。你可以通过点击Add/Edit来增加一个待办条目或者编辑一个现有的待办条目,如(5)所示。

如果选择了提醒时间,则当到这个时间时,iOS会自动提醒用户。即使app不是正在运行的状态也可以提醒,这是非常高级的一个功能,但是我觉得你有能力完成它。

你可以在随书附件中找到所有关于本次课程的源代码,所以你可以尽情的作出修改,以了解它们是如何工作的,不要怕弄坏它们。

准备好了嘛?我们要开始了!

重要:我们的课程是基于Xcode 8的,如果你还在使用Xcode 7,请立刻去Mac store中升级版本。
但是也不要升级的太高了,Apple公司也常常在一个新版本即将发布前,发布一个可用的beta版本。不要用beta版本来学习我们的内容,beta版本经常会出现你预料之外的东西,然后把你带到云里雾里。坚持用官方的正式版本。

初识table view

了解table view是非常重要的,你将通过检查它的工作方式作为开始。制作一个列表从来没有像这么愉快过。

因为聪明的开发者都会把工作分解为小的,简单的步骤,所以这就是你在这一小节课程中要做的事情:

1、放一个table view到app的界面上

2、在table view中放入数据

3、允许用户点击表中的某一行触发一个对勾符号的显示和关闭

一旦你拥有了这些基础部分并且能够运行,你将会持续将本次课程中的新功能不断的添加上去,直到最终完成这个app。

运行Xcode,并且新建一个工程项目。选择Single View Application模版:

选择Xcode模版

Xcode会要求你填写一些选项:

填写各种选项

照着下面填写:

Product Name:Checklists

Team:保持默认不要动

Organization Name:你的或者你公司的名字

Organization Identifier:使用你自己的标识符,倒过来的域名或者邮箱

Language:Swift

Device:iPhone

Use Core Data,Include Unit Tests,Include UI Tests:这三个复选框不要选中

点击Next选择一个目录保存project

如果你想的话,你可以立刻运行这个app,不过此时你只能得到一个白色的屏幕。

Checklists只运行在竖屏模式下,但是Xcode会默认竖屏及横屏方向都可以运行。

点击工程导航器中的Checklists工程项目(蓝色图标那个),打开工程设置界面,并且找到General子页。然后在Deployment Info分节中找到Device Orientation,确保仅选中Portrait复选框。

设置app支持的设备方向

随着landscape复选框被取消选中,你旋转设备就再也起不到任何效果。app始终为竖屏显示。

上下颠倒(Upside down)
设置设备方向的地方还有一个上下颠倒选项,但是你基本用不到它。
如果你的app支持上下颠倒,用户就可以旋转它们的iPhone,这样会使得Home键位于app界面的上方,而不是下方。
这会导致一些混乱,特别是当用户在运行app的过程中,如果处于上下颠倒的状态,此时突然接入一个电话,很容易导致用户拿反手机,听筒在下,话筒在上。
但是iPad的app,基本上需要同时支持4个方向,包括上下颠倒。

编辑storyboard
Xcode创建了一个由single view controller(单视图控制器)组成的基础app。回忆一下view controller是用于代表你app上的一个屏幕界面和一个相应的ViewController.swift文件和Main.storyboard
上设计的用户界面。

这个storyboard将你的app上的所有的view controller的设计包含到一个文件之中,用箭头展示它们之间的流动。在storyboard的术语中,每一个view controller都是一个场景(scene)。

你已经在BullsEye中使用过了storyboard,但是在本次课程中,你将解锁storyboard的全部力量。

点击Main.storyboard打开界面建造器。

目前唯一的场景

这个场景的大小是匹配iPhone6或者iPhone7的。我们曾经使用过底部的View as:面板切换到稍微小一点的iPhone SE上,因为这样可以节约出一点空间。然而,你在storyboard中选择哪种类型的设备来编辑都是一样的:app会随着iPhone设备的不同,自动重新调整自己的大小。

在纲要面板中选择View Controller。

小帖士:回忆一下,纲要面板展示了storyboard中所有场景的视图层级。如果你看不到这个纲要面板,那么就点击下图中箭头指向的小按钮,这个按钮负责纲要面板的可视与否。

就是它

选择纲要面板上最上面的View Controller Scene并且点击键盘上的delete删除它,操作完毕后,纲要面板上会显示一个大大的“No Scene”。

你删除它是因为你不需要一个标准的view controller,而是需要一个table view controller。这种特殊的view controller类型在操作table view时会简单很多。

将ViewController的类型改变为table view controller,首先我们需要编辑一下它的swift文件。

点击ViewController.swift打开代码编辑界面,将下面这一行:

class ViewController: UIViewController {

修改为:

class ChecklistsViewController: UITableViewController {

通过这一改动,你告诉swift编译器你的view controller现在是UITableViewController的对象了,不再是标准的UIViewController。

记住,所有以UI开头的东西都属于UIKit。这些预制的组件像模块一样为你的app服务。

当Xcode创建工程的时候,它假设你需要一个建立在UIViewController之上的ViewController对象,但是在这里,你用UITableViewController代替了它。

你同时将ViewController重命名为ChecklistsViewController,使得它的描述更加清晰。这是属于你自己的对象-因为它没有以UI开头命名。

在本次课程中,你将添加数据和功能到这个ChecklistsViewController对象,使它能具体完成某些工作。你同时也会添加一些新的view controller到这个app中。

在左边的工程导航器中,点击选中ViewController.swift,然后再点击一次,你就可以编辑它的名称了。(不要用双击,双击的话会在一个新的窗口打开这个文件)。

将它的名称修改为ChecklistViewController.swift:

重命名这个swift文件

你也许会看到一个警告:“The document could not be saved. The file has been changed by another application”。点击Save Anyway,这样这个警告就离开你的视线了。

回到storyboard,去对象库(Object Library)中拖出一个Table View Controller到画布上:

拖一个Table View Controller到storyboard中

这样就添加了一个新的Table View Controller场景到storyboard。

点击选择黄色图标的Checklist View Controller,然后打开身份检查器(Xcode窗口的左边,检查器面板的第三个),并且在Custom Class下面的Class选项的文本框中输入ChecklistViewController(或者使用小的那个下拉箭头进行选择)。

小帖士:当你这样做的时候,确保选中的是Table View Controller而不是其中的Table View。当你确实选中Table View Controller的时候应该会有一个浅蓝色的边框包围着整个场景。

将Table View Controller和ChecklistViewController关联起来

纲要面板上的场景名称现在应该变为“Checklist View Controller”了。你成功的将ChecklistViewController从一个标准的view controller改变为一个table view controller了。

就像它的名字一样,你现在可以在storyboard里看到了,这个view controller包含了一个Table View对象。很快我们会讲到controller和view的区别。

如果这里没有一个指向你的新的table view controller的大箭头,那么你需要选定ChecklistViewController,然后在其属性指示器里选中Is Initial View Controller复选框。

指向初始视图控制器的箭头

初始视图控制器(Initial View Controller)是你在屏幕上看到的第一个界面。没有它,当你的app运行时,iOS就不知道应该读取哪一个view controller,然后你的app将以一个黑屏启动。

在模拟器中运行app

你应该可以看到一个空的列表。这就是table view。你可以上线滚动这个列表,但是它里面没有任何数据。

table view

顺便说下,你用那个型号的iPhone模拟器都没关系。Table View会重新调整自己的大小适应任何设备,你在iPhone SE上看到的将和在iPhone7上看到的别无二致。

个人而言,我会使用iPhone SE模拟器,因为它占地比较小,可以节约Mac屏幕的空间,特别是你的屏幕不够大的话。

⚠️:当你运行app时,会看到一个警告“Prototype table cells must have reuse identifiers”。现在不用管它,我们后面会处理这个问题。

来解刨一下table view

首先,我们来更多的了解一下table view,它就是一个UITableView的对象,显示为一个列表。

⚠️:我不太确定为什么它的名字会叫做表格,因为通常来说表格就像excel那样拥有多行多列,然而UITableView只有一列。比起表格来,称呼它为列表更加合适,我想我们是无法理解这个名字了。UIKit还提供了一个叫做UICollectionView的对象,它的和UITableView有点类似,但是可以拥有多列。

table view有两种风格,一种是“plain(无格式)”,另一种是“grouped(分组)”。它们大致相同,只有些小区别。最明显的一个却别就是“grouped”模式中,若干行会被会被放进一个浅灰色背景的盒子里分组管理。

左边为plain,右边为grouped

plain模式经常被用作容纳相同事物的列表,比如通讯录,或是地址薄,每一行上面都是一个人名或者地址。

grouped模式经常被作来容纳不同事物的列表,比如通信录中的多种属性,姓、名、座机号码、手机号码等。

在我们的Checklists app中,你会同时用到这两种模式。

table的数据就是每一行中的内容。在Checklists最初的版本里,每一行里会有一个待办事项,你可以检查它们是否已经完成。

理论上你可以拥有任意多的行数,比如数千行,虽然并不推荐这样做。如果你让你的用户向下滑动数千行去寻找一个他需要的项目,好无疑问就是秒删app的节奏。

table在一中叫做cells(细胞、单元格)中展现数据。一个cell关联一行,单并不总是这样。一个cell也是一种视图(view),它在某一行可见时可以展现一行数据。如果你的屏幕大小只能同时容纳10行,那么你就只有10个cell,那怕你一共有数千行数据。cell是复用的,它只展示哪些可见的行的数据。

无论何时,当某一行滚动出屏幕不可见时,它的cell就会被一个新的滚动至屏幕上可见的行重新利用。听起来有点绕,我们看下图示:

cell只显示屏幕上可见的行的数据

在过去,你需要付出巨大的努力去为你的table创建cell,但是现在,Xcode有一种非常便利的叫做prototype cells的功能,使你可以在界面建造器中可视化的设计你的cell。

打开storyboard,点击空的cell选定它。

选择prototype cell

很多时候难以分辨你到底有没有选中它,这时你可以看看纲要面板上被选中的是不是一个叫Table View Cell的东西被选中了,或者你也可以从纲要面板上直接选择它,就像上图中的一样。

从对象库里拖拽一个label到cell上。确保将label的两侧拉伸到cell的边缘,仅留下一点点空白。

添加一个label到prototype cell

除了这个label以外,你还需要添加一个对勾符号到cell中。这个对勾符号由一种叫accessory(配件)的东西提供,一个出现在cell右侧的内建的视图。你可以从几种标准配件中选择,或者也可以使用你自定义的。

再次选定Table View Cell。打开属性检查器设置Accessory字段为Checkmark:

选择Accessory字段为Checkmark

如果你看不到这个选项,确定你选择的是Table View Cell而不是其他东西。

这时你的cell看起来是这个样子:

prototype cell的设计,一个label和一个checkmark

你需要重新调整一下label的右侧边界,使它不要和这个对勾符号重叠。

你还需要设置cell的重用标示(reuse identifier)。这是当旧的行滚动出屏幕,新的行混动入屏幕并且可见时,table view寻找空闲的cell,并重用它们时使用的内部名称。

table需要为这些新的行分配cell,此时循环利用存在的cell比重新创建它们要效率快的多。这个技术保证了table view始终可以平滑的滚动。

reuse identifier对你在同一个表里使用不同的cell也是非常重要的。例如,一个cell可能包含图片和标签,而另一个cell可能包含标签和按钮。你必须给这两个cell不同的名称,才能正确的调用它们。

虽然Checklists只包含一种类型的cell,但是你必须还是要给它一个名称。

还是选中Table View Cell在属性检查器中找到Identifier字段,并且键入ChecklistItem作为它的重用标示。

给table view cell取个名字

运行app,激动吗,然并卵,你依然看到的是空白的表格。

你仅仅是对表格的cell进行了设计,并不是实际的行。记得吗,cell仅显示可见行,cell自己并不是实际的数据。要添加数据到table中,你需要写点代码了。

数据来源

打开ChecklistViewController.swift并且添加以下方法到最底部的花括号前面。

override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return 1
    }
    
    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "ChecklistItem", for: indexPath)
        return cell
    }

这些方法比你曾经在BullsEye中见到的方法要复杂的多,它们都各有两个参数,并且会给调用者返回一个值。除此以外,它们的工作模式和你之前处理过的一模一样。

这两个特定的方法是UITableView的data source protocol(数据来源协议)的一部分。

data source是你的数据和table view之间的连接。通常view controller扮演着data source的角色,因此必须执行这些方法。

table view必须知道你一共有多少行数据以及如何展现每一行。但是你不能简单点将数据倒进去。你不能对它说:“亲爱的table view,这里是我的100行数据,你给我把它们放到屏幕里”

取而代之的是,你需要这样告诉table view:“这个view controller现在是你的数据源(data source)了。你可以在任何你想要的时候向它询问任何有关数据的事情”

一旦它连接到了数据源—就是你的view controller—table view会发送“ numberOfRowsInSection”信息去寻找那里到底有多少行数据。

当table view需要将具体的某一行显示到屏幕上时,它会发送一个“cellForRowAt”信息,去为cell匹配数据源。

你每时每刻都可以在iOS中见到这种模式:一个对象委托另一个对象做了某些事情。在我们这个情况中,当table view需要数据源的时候,ChecklistViewController负责这一工作。

table view和data source的约会方法

你实现的第一个方法tableView(numberOfRowsInSection),返回了一个值1。这样做可以告诉table view你仅有一行数据。

return语句在swift中十分重要。它使一个方法可以返回数据给它的调用者(caller)。在tableView(numberOfRowsInSection)中,调用者是UITableView的对象,它想知道表中有多少行数据。

一个方法中的语句通常使用实例变量和从参数中接受到的其他数据进行一些计算。当这个方法结束时,return语句的作用就是告诉你:“嗨,我做完了,这是你要的结果”。这个返回值通常被称作方法的结果(result of the method)。

对于tableView(numberOfRowsInSection)来说,它的回答相当简单:“这里只有一行,所以我返回1”

现在table view知道了只有一行数据,它开始调用你添加的第二个方法—tableView(cellForRowAt)—来获得为这一行准备好的cell。这个方法抓取一份prototype cell的拷贝,并且使用一个return语句将它给回到table view。

在tableView(cellForRowAt)中,我们经常做的事情就是把行的数据(row data)放入cell中,但是app仍然没有任何数据。

运行app,你会看到一个cell,然后啥也没有:

总算有点啥了

注意一下,iPhone的状态栏有部分和table view重叠了。状态栏没有属于划分给自己的独立的空间,仅仅是被简单的放到屏幕的最顶端。稍后,我们会通过放置一个导航栏(navigation bar)到table view的顶部,来解决这个小小的整容问题。

练习:修改app,使它可以显示5行。

这完全没多难:

override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return 5
    }

如果你视图到storyboard中去复制5个prototype cell出来,那么你完全没明白我们上面讲的内容。

当你使用tableView(numberOfRowsInSection) 返回5时,你就告诉来table view,这里有5行。

然后table view会发送5次“cellForRowAt”消息,每一次请求一个cell。而tableView(cellForRowAt) 当前仅是返回prototype cell的拷贝,所以你会看到5行一模一样的东西:

返回的5行一模一样

在tableView(cellForRowAt) 中创建cell 的方法有许多种,到目前为止你用的是最简单的一种:

1、在storyboard中为table view添加一个prototype cell;

2、为这个prototype cell设置一个重用标示(reuse identifier);

3、调用tableView.dequeueReusableCell(withIdentifier)。如果必要或者一个存在的cell不再被使用了,回收掉的时候,它会创建一个新的prototype cell的拷贝。

一旦你有了一个cell,你应该从相应的行拿出数据讲它填满并且给回到table view。这时我们下一小节要做的事情。

将行数据放入cell

目前所有行(相当于cell)都包含一个预置的“Label”。让我们给每一行放置不同的文本。

打开storyboard并且选择cell中的label。然后打开label的属性检查器,将Tag字段设置为1000.

设置label的tag为1000

tag(标签)就是用户接口控件的一个数字ID,用于标示它的身份,以便将来很容易的可以找到它。为什么是1000呢?其实没啥特别的理由。它可以是除了0以外的任何数字,因为0是所有tag的默认值。

再次确认一下,你是对Label的tag做的设置,不要点错设置到Table View Cell或者Content View上去了。选错地方是经常出现的一个错误,它会带来你意料之外的结果。

打开ChecklistViewController.swift,改变tableView(cellForRowAt)为下面这样:

override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "ChecklistItem", for: indexPath)
        
        let label = cell.viewWithTag(1000) as! UILabel
        
        if indexPath.row == 0 {
            label.text = "Walk the dog"
        } else if indexPath.row == 1 {
            label.text = "Brush my teeth"
        } else if indexPath.row == 2 {
            label.text = "Learn iOS development"
        } else if indexPath.row == 3 {
            label.text = "Soccer practice"
        } else if indexPath.row == 4 {
            label.text = "Eat ice cream"
        }
        
        return cell
    }

第一行是之前就有的。它用来获取一个prototype cell的拷贝,一个新的或者是回收再用的,并且将它放入局部常量cell中。

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

(回忆一下,cell之所以是个常量,是因为它是用let定义的,不是var。又因为它是在方法内部定义的,所以它是局部的)

那么indexPath又是什么呢?

indexPath是一个指向表中具体某一行的一个简单的对象。当table view为cell请求数据源时,你可以通过这一行的indexPath.row属性的内部行号,就可以找到是哪个cell需要数据了。

⚠️:还存在一种情况就是,表格会将若干行分组到一个段里面。比如在通信录app中,会根据姓名对行进行分组。所有以A开头的姓名为一个分组,所有以B开头的姓名为一个分组,等等。
要找出某一行是属于那一个段的,需要用到indexPath.section属性。我们的Checklists app不需要这种分组,所以你可以忽略indexPath.section属性。

你添加的新的代码中,第一行是:

let label = cell.viewWithTag(1000) as! UILabel

这里你向table view cell请求标示为1000的视图。这是你刚刚在storyboard中给label设置的标示,所以它会返回一个相应UILabel对象。

在引用UI元素时,使用tag是非常便利的,不需要再弄一个@IBOutlet变量。

练习:为什么你不能直接使用@IBOutlet变量然后直接在storyboard中将view controller和cell中的label连接起来?毕竟,你在BullsEye中就是这样创建label的引用的,所以为什么这里就不可以呢?

答案:表格中肯定会有一个以上的cell,每个cell都会自己的label。如果你从prototype cell中连接一个label的outlet到view controller,那么这个outlet只能引用其中一个cell中的label,而不能全部引用。自从label属于cell,而不是view controller中的一个整体开始,你就不能再用在view controller上创建outlet这种做法了。晕了吗?现在别太操心这件事。

回到代码。接下来的代码不应该对你太陌生:

if indexPath.row == 0 {
            label.text = "Walk the dog"
        } else if indexPath.row == 1 {
            label.text = "Brush my teeth"
        } else if indexPath.row == 2 {
            label.text = "Learn iOS development"
        } else if indexPath.row == 3 {
            label.text = "Soccer practice"
        } else if indexPath.row == 4 {
            label.text = "Eat ice cream"
        }

你之前见过这种if-else if结构。它只是通过查看包含行号的indexPath.row的值,来改变相应行的label的文本。第一行的cell获取文本“Walk the dog”,第二行的cell获取文本“Brush my teeth”,以此类推。

⚠️:任何设计计数的时候,电脑一般都从0开始计数。如果你的列表中有4行,那么它们的行号就分别为0,1,2,3。和我们平时的习惯不太一样,但是程序通常都这样运行。
所以第一行的indexPath.row是0,第二行的是1,以此类推。
从0开始计数,对你一开始可能不太适应,但是久而久之它会变成你的本能。

运行app,现在这5行,每一行都有属于自己的文本了:

现在表中终于展现出内容了

这就是使用tableView(cellForRowAt)方法向表格提供数据的方法。你首先得到一个UITableViewCell对象,然后通过indexPath.row改变其中cell的内容。

仅仅是为了好玩,让我们放入100行试试。

if indexPath.row % 5 == 0 {
            label.text = "Walk the dog"
        } else if indexPath.row % 5 == 1 {
            label.text = "Brush my teeth"
        } else if indexPath.row % 5 == 2 {
            label.text = "Learn iOS development"
        } else if indexPath.row % 5 == 3 {
            label.text = "Soccer practice"
        } else if indexPath.row % 5 == 4 {
            label.text = "Eat ice cream"
        }

记得把下面改为return 100

override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return 100
    }

这里你使用了一种叫做求余的运算,就是%这个操作符,来决定行号。(这种运算也叫做取模运算)

它会返回两个数相除后的余数。例如:13 % 4 = 1,因为13处以4余1.而12 % 4 = 0,因为没有余数。

所以第一行及第一行后每个5行都显示“Walk the dog”,而第二行及第二行后每五行都显示“Brush my teeth”,以此类推。

我想你已经得到了启示:让每五行重复出现一次这个工作,比起你自己在100行中慢慢敲,让电脑自己计算来的快多了。

如果你没反应过来的话,就先不要管它,等一段时间回来再看看,再想想。

运行app,现在你应该可以看到这种效果了:

有100行的数据

⚠️:在模拟其中向下滚动屏幕,就和在手机不太一样,你需要按下鼠标然后向下拖动,如果你只是在Mac中向下滑动触摸板,是不会向下滚动的。

练习:你觉得目前table view使用了几个cell?

答案:虽然这里有100行,但是屏幕上只能显示14行。如果你去数屏幕上可见的行的话,你会得到13行这个数字,这里存在一种可能性,当你滚动屏幕时有时下面的一行还没完全出来,而最顶上一行也没完全消失,还保持在可见状态。这样就多出来了一行,所以屏幕上最多可以显示14行(iPhone6s,7会多一些行数,但是原理是相同的)。

如果你非常快速的滚动的话,我猜table view会多准备几个临时的cell,但是这只是猜的,我并不确定。只是这件事情对我们重要吗?完全不重要,需要多少个cell,你大可以交给table view自己去搞定,你完全不用操心这个事。你所做的全部事情就是当table view需要一个cell时,给它一个填满数据的cell到相应的行。

通常cell都比行数要少的多。如果哪个app为每一行都准备一个cell,那么iOS系统的内存很快会被耗尽,特别是表格比较大的时候。因为并不是所有行都在屏幕上同时可见,所以为每一行准备一个cell是极大的浪费,并且会使系统很慢。iOS是非常善于持家的,它随时会在需要的时候对cell进行循环利用。

你现在知道了为什么UITableView可以使每一行都看起来不同,那是因为它们的数据不同,你有很多不同的数据,并且你有cell,可以使数据显示在屏幕上,虽然cell只有十几个,但是它们可以循环利用。

(作者在此处自创了一首歌来歌颂cell,应该是根据圣诞歌改编的,说实话,惨不忍睹T T)

奇怪的报错?

在我们这个课程中,大家最多的问题就是:“我都是照着你说的做的,但是为什么突然我的app崩溃了,神马鬼?”

如果你也遇到了这个情况,请确认你是不是无意中添加了一个断点(breakpoint)。断点是一种联调工具,它可以使你的app在你指定的一行中断,并且跳转到Xcode的调试器。它们的出现方式和app崩溃差不多,但其实只是你的app暂停运行了而已。

断点的样子是一个蓝色的箭头,位于代码编辑窗口最左边的边缘:

左边这个蓝色的粗箭头就是断点

如果你的app突然崩溃,并且正好在代码编辑窗口的左边有哪样一个蓝色的箭头,那么你可以点击那个箭头,然后往别的地方拖拽,随着一个消失的小气泡动画,这个箭头就没有了(添加断点的话,仅仅是在代码窗口的左边缘点击一下,就可以添加一个断点了,这也是为什么那么容易错误的添加断点的原因)。

顺便说一下本书官方论坛的地址:forums.raywenderlich.com,不过上面都是外国人。

点击每一行

当你点击屏幕上的任意一行的时候,它会变成浅灰色表明已被选中。但是你手指离开时,它仍然是浅灰色的被选中状态。我们要对这里做些优化,当你点击每一行时,可以显示或者关闭一个对勾符号。

被选中的行为浅灰色

点击每一行会发生什么,由table veiw的委托(delegate)处理。还记得我之前说过的在iOS系统中你经常会发现一个对象委托另一个对象去做一些事情吗?数据源(data source)就是一个这样的例子,table view也有一些依赖于其他人的帮助,那就是table view delegate。

委托的概念在iOS中非常普遍。一个对象经常会依赖于另一个对象去替它完成某些任务。这种独立关注点的做法使系统保持简单,因为每一个对象只做自己擅长的事情,除此以外的事情就由别的对象去处理。table view就是一个极好的例子。

因为每一个app都对自己的数据有着不同的需求,这就要table view必须能够处理各种不同类型的数据。比起将table view复杂化或者让你自己去修改table view,UIKit采用的方案是选择一个委托,让另一个对象去填满cell的数据,这就是我们的data source(数据源)。

table view本身并不关心数据源是谁以及你的app要处理什么样的数据,它仅仅是发送 cellForRowAt消息并且接受一个返回的cell。table view组件通过这种将处理数据的责任交给其他对象的方式来保持自身的简洁。

同样的,table view知道如何识别用户点击了某一行,但是之后对用户做出何种响应,则由其他对象完成,视每个app具体而定。在我们这个app里你要显示或者关闭一个对勾符号;其他的app则会做各自完全不同的事。

通常组件只需要一个委托,但是table view把它的委托分裂为两个独立的部分:UITableViewDataSource用于把数据放进表格,UITableViewDelegate用于执行点击每一行后执行的任务。

为了了解这些,我们需要打开storyboard并且按住ctrl点击table view查看它的连接:

data source和delegate都连接到了view controller

你可以看到table view的data source和delegate都连接到了view controller上。这是UITableViewController的标准惯例。(你可以以这种基本方式使用table view,但是后期我们会手动连接source和delegate到别到地方)

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

override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        tableView.deselectRow(at: indexPath, animated: true)
    }

这个tableView(didSelectRowAt)方法是tableView的delegate method(委托方法)的一种,当用户点击某一行的时候被调用。运行app,并且点击某一行,当你点击的时候会变成浅灰色,而当你手指离开后会恢复原状。

我们现在来使tableView(didSelectRowAt)控制显示或者关闭对勾符号,将这个方法改变成下面这个样子:

override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        if let cell = tableView.cellForRow(at: indexPath) {
            if cell.accessoryType == .none {
                cell.accessoryType = .checkmark
            } else {
                cell.accessoryType = .none
            }
        }
        tableView.deselectRow(at: indexPath, animated: true)
    }

这个对勾符号是属于cell的一部分(accessory(配件)属性,记得吗?),所以首先你需要找到被点击的那一行的UITableViewCell对象。你只需要简单的问table view:“你给我的cell的indexPath是多少啊?”

因为理论上存在指定的index-path上没有cell的情况,例如那些不可见的行,所以我们需要使用if let语句。

if let可以告诉swift你仅希望在确实存在一个UITableViewCell对象时执行剩余的代码。在我们这个app里,其实不存在没有cell的情况,只是考虑到存在我们未知的情况,才用if let加个保险。

当确定了一个UITableViewCell对象时,你会去查看它的accessory(配件),你可以使用accessoryType成员去查看它。如果配件不存在,则将配件设置为对勾符号;如果已经有一个对勾符号了,则将它设置为空。

⚠️:使用tableView.cellForRow(at)去寻找cell。
tableView.cellForRow(at)和我们之前添加的tableView(cellForRowAt)数据源方法是不一样的,认识到这一点非常重要。
除去名称比较相似以外,它们是来自不同对象的不同方法。
数据源方法tableView(cellForRowAt)的目的是当某一行可见时,传递一个新的(或者重新利用的)一个cell对象到table view。你从不会手动调用这个方法;只有UITableView可以调用它的数据源方法。
tableView.cellForRow(at)的目的同样是返回一个cell对象,但是是一个正在显示的某一行中的一个已存在的cell。它不会创建一个新的cell。如果这一行目前还没有cell,那么它会返回一个空值nil,意思是没有找到cell。(你使用if let的目的就是为了避免返回空值时导致app崩溃)
还记得我说的方法必须有一个清晰描述的名称吗?UIKit在这方面做的很好,但是在这里我们遇到了两个名称非常相似的方法,这可能会导致我们迷惑不解。注意这个陷阱!

运行app看看效果,你应该已经可以控制每一行上的对勾显示与否了。

点击某一行就可以控制对勾是否显示

⚠️:如果对勾符号不是立即显示或者关闭,而是当你点击另外一行时才做出反应,那么你需要确定我们刚才添加的方法是叫做tableView(didSelectRowAt),而不是tableView(didDeselectRowAt),Xcode的自动匹配功能有时会导致你选择错误的方法。

点击一行,使对勾关闭显示,然后滚动屏幕使这一行消失,在滚动回来(非常快速的滚动,否则看不到这个效果),你会发现这个对勾符号又重新显示出来了。除此以外,有些行上的对勾还莫名其妙的没有了,这是神马鬼?

继续我们cell和row的故事:你控制的是cell上的对勾符号,也就是cell的配件,但是这个cell也许在你滚动的时候会重新分配给其他行,而原先那一行又得到了一个新的cell。所以,是否显示这个对勾符号应该是根据row来确定,而不是根据cell确定。

你需要一些方法跟踪对勾符号在每一行上的状态,来替代使用cell去判断是否显示对勾符号。这意味着我们需要扩展数据源,并且使用合适的数据模型,这是我们下一小节的内容。

⚠️:方法会有多个参数
你在BullsEye中使用到的大多数方法都只有一个参数,或者根本没有参数,但是这些table view的data soource和delegate方法都有两个参数:

override func tableView(
_ tableView: UITableView,                         //参数1
numberOfRowsInSection section: Int)    //参数2
-> Int {                                                        //返回值
       ...
    }

override func tableView(
_ tableView: UITableView,                     //参数1
cellForRowAt indexPath: IndexPath)    //参数2
-> UITableViewCell {                             //返回值
...
}

override func tableView(
_ tableView: UITableView,                             //参数1
didSelectRowAt indexPath: IndexPath) {    //参数2
...
}

第一个参数是调用这些方法的UITableView对象。这样做带来了便利,你不在需要弄一个@IBOutlet来向table view回传消息。

对numberOfRowsInSection而言第二个参数是分组数。对cellForRowAt和didSelectRowAt第二参数是index-path。

方法并不仅限于两个参数,它们可以有许多个参数。但是实际而言两个或者三个参数已经够用了,并且如果大多数方法如果都有五个以上参数的话,你早就被吓跑了。

在其他一些语言里典型的方法也许会是下面这个样子:

Int numberOfRowsInSection(UITableView tableView,Int section) {
... }

但是swift中有些不一样,与iOS框架兼容的主要的东西都是用Object-C语言写成的。

让我们再看一眼numberOfRowsInSection:

override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        ...
    }

这个方法正式的全名叫做tableView(numberOfRowsInSection)。如果你把它读出来,应该会有点感觉。(中文读出来就是“表格中每一组的行数”)。它请求具体的某个列表中的具体某一分组中的行数。

其中第一个参数是这个样子的:

_ tableView: UITableView

参数的名称是tableView。冒号后面跟着的是参数的类型。过会我会告诉你这个下划线是什么。

第二个参数是这个样子的:

numberOfRowsInSection section: Int

这个参数有两个名字“numberOfRowsInSection”和“ section”。

第一个名字numberOfRowsInSection是在调用这个方法的时候使用。这个名字就是所谓的参数的外部名称。而在方法的内部,它使用第二个名字“ section”,就是参数的内部名称,冒号后面的就是参数的类型,这是一个Int型的参数。

当一个参数不需要外部名称的时候,我们就用一个下划线替代它。在Object-C中你经常会看到方法的第一个参数是一个下划线。像这种第一个参数只有一个名字,而第二个参数有两个名字的方法很奇怪不是吗?确实很奇怪。

如果你曾经使用过Object-C的话,不要怀疑,它和其他语言比起来就是很奇怪。但是如果你习惯了的话,其实你会发现它的可读性还是不错的。

一些有其他语言编程经验的人会非常困惑不解,因为它们发现ChecklistViewController.swift中有三个方法,它们的名称一模一样都是tableView()。但是swfit不这样认为,在swift中参数的名称是方法全名的一部分。所以这三个方法实际上名称是不同的,它们分别是:

tableView(numberOfRowsInSection)
tableView(cellForRowAt)
tableView(didSelectRowAt)

一些开发者在引用这些方法的时候把下划线和冒号也包括进去了,但是我们不会这样做,因为那样太难读了:

tableView(_: numberOfRowsInSection)
tableView(_: cellForRowAt)
tableView(_: didSelectRowAt)

顺便说一下,方法的返回值类型就是跟在 -> 符号之后的。如果没有这个符号,比如说tableView(didSelectRowAt),那就是说这个方法没有返回值。

这里的新内容有点多,我希望你还能跟上我的思路。如果你掉队了的话,就停下来休息一会,然后从头再来一遍。毕竟你刚被排山倒海而来的新概念洗礼了一遍,迷惑是正常的。

但是千万别害怕,目前有所疑问是正常的。只要你搞清楚我们要做的事情的大方向,就ok了。

推荐阅读更多精彩内容