如果你写过
iOS 项目的话,应该会了解到,iOS 里面最常用的一个控件就是 UITableView;即便没写过
iOS 项目,你应该也会在一些流行的 App 里面看到过它,比如:YouTube,Facebook,Twitter,Medium
等等。一般来讲,当你想要在一个页面上,展示一个数量动态变化的数据的时候,你应该会考虑使用 UITableView。
还有一个基础控件是 CollectionView,它相对来讲更灵活,所以我个人更喜欢用这个。稍后我还会写一篇文章来讲它。
所以,在你的项目里面,不可避免的会用到 UITableView。
比较常见的做法是使用 UITableViewController,它有一个内置的 UITableView;通过简单的设置就可以让它工作起来,你需要做的只是设置好数组数据和显示数据的
Cell。它使用起来很简单,而且也可以满足需求,但是它有一个缺点:这会让 UITableViewController
里面的代码变得超级长,而且这打破了 MVC 模式。关于 MVC 具体是什么,或者我们为什么要去了解它,你可以先看一下
这篇文章(译文),它很好的介绍了 iOS 里面所有的架构模式。
即便你不想去弄懂所有的这些模式,至少对于 UITableViewController 里面的那上千行代码,你总是想要重构划分一下的吧。
在我的上一篇文章里面,我提到了 从 Controller 向 Model 传递数据的三种方式。
在这篇文章里面,我要讲的是我处理 tableView 所有的方式,也就是在上篇文章里提到的 - 代理的方式。用这种处理方式,可以让代码看起来更整洁、模块化、易重用。
这次不适用 UITableViewController,而是把它划分成几个类:
DRHTableViewController:UIViewController 的子类,然后添加一个
UITableView 作为子视图
DRHTableViewCell:UITableViewCell 的子类
DRHTableViewDataModel:它有一个 API 方法:创建数据并用代理的方式返回数据给
DRHTableViewController
DRHTableViewDataModelItem:数据类:它包括了展示在 DRHTableViewCell
里面的所有数据
先从 UITableViewCell 开始吧。
一、TableViewCell
以单视图应用(Single View Application)为模板,创建一个新工程;然后删掉自带的
ViewController.swift 和 Main.storyboard 文件。稍后我们会一步步的创建所有用到的文件。
首先,创建一个 UITableViewCell 的子类。如果你想用 XIB,就勾选“Also create
XIB file”这个选项。
在这里,我们想要做的是一个 Medium 主页的简化版,所以需要添加下面这些子视图:
用户头像
姓名标签
日期标签
文章标题
文章概要
约束条件(Autolayout)你可以随意加,这不是重点。给每个视图添加一个对应的属性,完了在你的
DRHTableViewCell.swift 文件里面,应该有类似下面的这部分代码:
class DRHTableViewCell:
UITableViewCell {
@IBOutlet weak var avatarImageView: UIImageView?
@IBOutlet weak var authorNameLabel: UILabel?
@IBOutlet weak var postDateLabel: UILabel?
@IBOutlet weak var titleLabel: UILabel?
@IBOutlet weak var previewLabel: UILabel?
} |
在这里,我把每个 @IBOutlet 默认的 “!” 改成了 “?”。当你从 InterfaceBuilder
里面拖拽 UILabel 到代码里的时候,它会自动强制解包开这个标签,然后在它后面加上 “!”。这里面有一部分原因是为了和
objective-C API 保持一致性,但是我个人总是喜欢避免强制解包,所以我这里用 optional
标识符做了替换。
接下来,还需要一个方法:用数据去填充上面的这些标签和图片。在数据这块,我们不是在 Cell 里创建很多的变量去表示它,而是为它创建一个新的类
DRHTableViewDataModelItem:
class DRHTableViewDataModelItem
{
var avatarImageURL: String?
var authorName: String?
var date: String?
var title: String?
var previewText: String?
} |
最好还是用 Date 类型去存储 date,但是这里为了方便,就把它存储成了 String 型。
所有的变量都是可选的(optional),所以不用去担心默认值的问题,稍后还会为它添加一个 Init()
方法。现在再回到 DRHTableViewCell.swift 文件,添加下面这些代码(用数据去填充
Cell 里面的标签和图片):
func configureWithItem
(item: DRHTableViewDataModelItem) {
// setImageWithURL(url: item.avatarImageURL)
authorNameLabel?.text = item.authorName
postDateLabel?.text = item.date
titleLabel?.text = item.title
previewLabel?.text = item.previewText
} |
setImageWithURL() 方法具体的实现,依赖于具体项目里面对图片缓存的处理;所以这里没有去管它。
现在我们已经有了 Cell,可以创建 TableView 了。
二、TableView
在这里,我们使用基于故事版的(storyboard-based)ViewController。你可以先看下
我的上一篇文章,了解下怎么更好的使用故事版。
首先,创建一个 UIViewController 的子类:
在这面,用 UIViewController 而不是 UITableViewController,这样可以有更多的控制。比如把
UITableView 创建成一个子视图,就可以根据自己的需要,用约束条件去设置它的位置。
接下来,创建一个故事版文件,用相同的名字给它命名:DRHTableViewController。从对象库里面拖拽出来一个
ViewController,并设置它为上面创建的类。
添加一个 UITableView,并让它跟 View 的四边对齐。
最后,在 DRHTableViewController 里面添加 tableView 属性。
class DRHTableViewController:
UIViewController {
@IBOutlet weak var tableView: UITableView?
} |
我们已经创建了 DRHTableViewDataModelItem 类,现在在 viewController
里面添加一个本地变量
fileprivate
var dataArray = [DRHTableViewDataModelItem]() |
这个变量用来存储将要展示在 tableView 上面的数据。
记住,我们不会在 ViewController 里面去创建数据:dataArray 只是一个空数组;而是在稍后用代理的方式给它填充数据。
现在在 viewDidLoad 方法里面设置 tableView 的一些基本属性。在这里颜色和样式都可以随意设置,但是唯一需要确认的是注册
nib 文件:
tableView?.register(nib:
UINib?, forCellReuseIdentifier: String) |
在调用这个方法之前(这个方法里面的 identifier 参数很难写),我们先不创建 nib 文件,而是在
DRHTableViewCell 里面添加两个方法:nib、identifier。
要尽量避免去重复写一些很难写的字符串;如果实在没有办法,可以创建一个 字符串变量,并用它来代替。
打开 DRHTableViewCell,在开头添加下面的代码:
class DRHMainTableViewCell:
UITableViewCell {
class var identifier: String {
return String(describing: self)
}
class var nib: UINib {
return UINib(nibName: identifier, bundle: nil)
}
.....
} |
保存这些修改,然后回到 DRHTableViewController,调用 registerNib
方法:
tableView?.register(DRHTableViewCell.nib,
forCellReuseIdentifier: DRHTableViewCell.identifier) |
不要忘了设置 tableViewDataSource 和 tableViewDelegate 为 self:
override func
viewDidLoad() {
super.viewDidLoad()
tableView?.register (DRHTableViewCell.nib, forCellReuseIdentifier:
DRHTableViewCell.identifier)
tableView?.delegate = self
tableView?.dataSource = self
} |
写完之后,编译器会报错:“Cannot assign value of type DRHTableViewController
to type UITableViewDelegate”
当你使用 UITableViewController 子类的时候,tableView 的代理和数据源是已经设置好了的。但是如果你是在
UIViewController 中创建 UITableView 的话,就需要让 UIViewController
继承一下 UITableViewControllerDelegate 和 UITableViewControllerDataSource。
只要为 DRHTableViewController 添加两个扩展,就可以解决了:
extension DRHTableViewController:
UITableViewDelegate {
}
extension DRHTableViewController: UITableViewDataSource
{
} |
又会报错:“type DRHTableViewController does not conform
to protocol UITableViewDataSource”。这是因为有一些必须实现的方法,需要你在这个扩展里面实现它们:
extension DRHTableViewController:
UITableViewDataSource {
func tableView(_ tableView: UITableView, cellForRowAt
indexPath: IndexPath) -> UITableViewCell {
}
func tableView(_ tableView: UITableView, numberOfRowsInSection
section: Int) -> Int {
}
} |
UITableViewDelegate 所有的方法都是非必须的,所以即使你没有实现,这里也不报错。按住
Command 键,点击 UITableViewDelegate,可以看到它具体都有哪些方法。它最常用的方法是
选择/取消选择 某个 cell,设置 cell 高度,配置 tableView 的 header/footer
等。
上面两个方法都是需要返回值的,所以编译器又报错了:“Missing return type”。让我们来解决它。
首先,需要设置 section 里面 row 的数量:我们已经有了 dataArray,可以直接使用它的
count 就可以:
func tableView(_
tableView: UITableView, numberOfRowsInSection
section: Int) -> Int {
return dataArray.count
} |
在这里,我没有重载另一个方法:numberOfSectionsInTableView。这个方法是非必须的,它默认是返回
1;而这个项目里面 tableView 只有一个 section,所以不需要去重载这个方法。
最后一步,配置 UITableViewDataSource 还需要在 cellForRowAtIndexPath
方法里面返回 cell:
func tableView
(_ tableView: UITableView, cellForRowAt indexPath:
IndexPath) -> UITableViewCell {
if let cell = tableView.dequeueReusableCell(withIdentifier:
DRHTableViewCell.identifier, for: indexPath) as?
DRHTableViewCell
{
return cell
}
return UITableViewCell()
} |
我们分行来看一下。
为了创建 cell,我们可以使用 DRHTableViewCell 的 identifier 作为参数去调用
dequeueReusableCell 方法。它会返回一个 UITableViewCell,所以我们需要用一个可选标识符把它从
UITableViewCell 转换成 DRHTableViewCell:
let cell = tableView.dequeueReusableCell(withIdentifier:
DRHTableViewCell.identifier, for: indexPath) as?
DRHTableViewCell |
然后安全解包它(safe-unwrap):如果成功,就返回这个自定义的 cell:
if let cell
= tableView.dequeueReusableCell(withIdentifier:
DRHTableViewCell.identifier, for: indexPath) as?
DRHTableViewCell
{
return cell
} |
如果安全解包失败,就返回一个默认的 UITableViewCell:
if let cell
= tableView.dequeueReusableCell (withIdentifier:
DRHTableViewCell.identifier, for: indexPath) as?
DRHTableViewCell
{
return cell
}
return UITableViewCell() |
我们是不是漏了什么?对,还需要用数据去配置 cell 视图:
func tableView
(_ tableView: UITableView, cellForRowAt indexPath:
IndexPath) -> UITableViewCell {
if let cell = tableView.dequeueReusableCell(withIdentifier:
DRHTableViewCell.identifier, for: indexPath) as?
DRHTableViewCell
{
cell.configureWithItem(item: dataArray[indexPath.item])
return cell
}
return UITableViewCell()
} |
我们已经为最后一部分做好准备了:创建 DataSource 并连接到 TableView。
三、DataModel
创建一个 DRHTableViewDataModel 类。
我们会在这个类里面获取数据,至于获取方式,可以是从一个 JSON 文件,或者是 HTTP 请求,或者是别的数据源,这不是本文的重点。我们假定已经有了一个
API 方法,它可以返回一个可选类型的数据对象和一个可选类型的错误信息:
class DRHTableViewDataModel
{
func requestData() {
// code to request data from API or local JSON
file will go
here
// this two vars were returned from wherever:
// var response: [AnyObject]?
// var error: Error?
if let error = error {
// handle error
} else if let response = response {
// parse response to [DRHTableViewDataModelItem]
setDataWithResponse(response: response)
}
}
} |
在 setDataWithResponse 方法里面,我们需要用一个 AnyObject 类型的数组对象
response,构建出一个 DRHTableViewDataModelItem 类型的数组;所以,紧接着添加下面这些代码:
private func
setDataWithResponse(response: [AnyObject]) {
var data = [DRHTableViewDataModelItem]()
for item in response {
// create DRHTableViewDataModelItem out of AnyObject
}
} |
在这个方法里面,我们创建了一个 DRHTableViewDataModelItem 类型的空数组,我们需要用
response 数组去构建它。然后我们遍历 reponse 数组里面的每个 item;在这个遍历循环里面,我们需要根据
AnyObject 类型的 item 创建一个 DRHTableViewDataModelItem
类型的对象。
我们还没有给 DRHTableViewDataModel 创建初始化方法,所以回到 DRHTableViewDataModel
类,创建这个初始化方法。在这里,我们用一个 Dictionary [String: String]?
类型的对象作为参数,创建一个 Optional 类型的初始化方法(或者说是 可失败的初始化)。
init?(data:
[String: String]?) {
if let data = data, let avatar = data[“avatarImageURL”],
let name = data[“authorName”], let date = data[“date”],
let title = data[“title”], let previewText = data[“previewText”]
{
self.avatarImageURL = avatar
self.authorName = name
self.date = date
self.title = title
self.previewText = previewText
} else {
return nil
}
} |
如果这个 Dictionary 里面,缺少了任意一个必需的 key 值,或者说这个字典本身就是一个
nil 的话,那么这次初始化就是失败的(返回 nil)。
有了这个可失败的初始化方法(Failable Init),就可以补全 DRHTableViewDataModel
类里面的 setDataWithResponse 方法了:
private func
setDataWithResponse(response: [AnyObject]) {
var data = [DRHTableViewDataModelItem]()
for item in response {
if let drhTableViewDataModelItem =
DRHTableViewDataModelItem(data: item as? [String:
String]) {
data.append(drhTableViewDataModelItem)
}
}
} |
在 for 循环之后,我们得到了一个 DRHTableViewDataModelItem 类型的数组。那么我们怎么把这个数据传递给
TableView 呢?
四、Delegate
首先,在 DRHTableViewDataModel.swift 文件里面创建一个代理 协议 DRHTableViewDataModelDelegate,放在
DRHTableViewDataModel 类的正上方:
protocol DRHTableViewDataModelDelegate:
class {
} |
在这个协议里面,创建两个方法:
protocol DRHTableViewDataModelDelegate:
class {
func didRecieveDataUpdate (data: [DRHTableViewDataModelItem])
func didFailDataUpdateWithError(error: Error)
} |
Swift 协议中,class 这个关键字限定了该协议只接受 class 类型(不接受结构体或者枚举类型),从而可以对它使用弱引用(weak
reference )。为了确保代理和委托对象之间不会有循环引用,在这里需要用到弱引用。
然后,在 DRHTableViewDataModel 里面添加一个可选的弱引用。
weak var delegate:
DRHTableViewDataModelDelegate? |
现在,需要在可能用到它的地方调用它。具体到这个例子,在请求失败的时候需要传递错误信息,在创建成功的时候需要传递数据。错误处理的方法可以放在
requestData 方法里面调用:
class DRHTableViewDataModel
{
func requestData() {
// code to request data from API or local JSON
file will go
here
// this two vars were returned from wherever:
// var response: [AnyObject]?
// var error: Error?
if let error = error {
delegate?.didFailDataUpdateWithError(error: error)
} else if let response = response {
// parse response to [DRHTableViewDataModelItem]
setDataWithResponse(response: response)
}
}
} |
最后,在 setDataWithResponse 方法里面调用第二个代理方法:
private func
setDataWithResponse(response: [AnyObject]) {
var data = [DRHTableViewDataModelItem]()
for item in response {
if let drhTableViewDataModelItem =
DRHTableViewDataModelItem(data: item as? [String:
String]) {
data.append(drhTableViewDataModelItem)
}
}
delegate?.didRecieveDataUpdate(data: data)
} |
五、显示数据
有了 DRHTableViewDataModel 就可以向 tableView 里面传递数据了。
首先,需要在 DRHTableViewController 里面创建 dataModel 的引用:
private let
dataSource = DRHTableViewDataModel() |
然后,还需要请求数据。我会在 ViewWillAppear 方法里面去做这个事情,这样每次视图出现的时候数据都会得到更新:
override func
viewWillAppear(_ animated: Bool) {
super.viewWillAppear(true)
dataSource.requestData()
} |
这是一个简单的例子,所以我在 viewWillAppear 方法里面请求数据。在真正的 app 里面,这需要根据很多因素视情况而定,比如缓存时间、API
的使用、App 自身的逻辑等等。
然后,在 viewDidLoad 方法里面,把它的代理赋值给 self:
dataSource.delegate
= self |
又报编译错误,这是因为 DRHTableViewController 还没有继承 DRHTableViewDataModelDelegate。在文件的末尾添加下面的代码就可以搞定:
extension DRHTableViewController:
DRHTableViewDataModelDelegate {
func didFailDataUpdateWithError (error: Error)
{
}
func didRecieveDataUpdate(data: [DRHTableViewDataModelItem])
{
}
} |
最后,我们需要处理 didFailDataUpdateWithError 和 didRecieveDataUpdate
这两种情况:
extension DRHTableViewController:
DRHTableViewDataModelDelegate {
func didFailDataUpdate WithError(error: Error)
{
// handle error case appropriately (display alert,
log an error, etc.)
}
func didRecieveDataUpdate(data: [DRHTableViewDataModelItem])
{
dataArray = data
}
} |
给 dataArray 赋值就表示,其实我们是想要重新加载 tableView 的数据的。但是在这里我们并没有在
didRecieveDataUpdate 方法里去做这件事,而是用对 dataArray 添加 属性观察者(property
observer)的方式来实现:
fileprivate
var dataArray = [DRHTableViewDataModelItem]()
{
didSet {
tableView?.reloadData()
}
} |
设置属性观察者(Setter Property Observer)会在设置完成之后,运行它里面的这些代码。
就是这些!
现在,你有了一个 tableView 模板,它配置了自定义的数据源和自定义的 cell。
你不再需要那个把所有代码都搞在一起,弄了有上千行代码的 tableViewController 了。
你上面创建的每一个部分,在整个项目里都是可以重用的,当然这是做代码划分的另一个好处了。 |