如果你写过 iOS 项目的话,应该会了解到,iOS 里面最常用的一个控件就是 UITableView;即便没写过 iOS 项目,你应该也会在一些流行的 App 里面看到过它,比如:YouTube,Facebook,Twitter,Medium 等等。一般来讲,当你想要在一个页面上,展示一个数量动态变化的数据的时候,你应该会考虑使用 UITableView。
还有一个基础控件是 CollectionView,它相对来讲更灵活,所以我个人更喜欢用这个。稍后我还会写一篇文章来讲它。
所以,在你的项目里面,不可避免的会用到 UITableView。
比较常见的做法是使用 UITableViewController,它有一个内置的 UITableView;通过简单的设置就可以让它工作起来,你需要做的只是设置好数组数据和显示数据的 Cell。它使用起来很简单,而且也可以满足需求,但是它有一个缺点:这会让 UITableViewController 里面的代码变得超级长,而且这打破了 MVC 模式。关于 MVC 具体是什么,或者我们为什么要去了解它,你可以先看一下 这篇文章 (译文),它很好的介绍了 iOS 里面所有的架构模式。
即便你不想去弄懂所有的这些模式,至少对于 UITableViewController 里面的那上千行代码,你总是想要重构划分一下的吧。
在我的上一篇文章里面,我提到了 从 Controller 向 Model 传递数据的三种方式 。
在这篇文章里面,我要讲的是我处理 tableView 所有的方式,也就是在上篇文章里提到的 - 代理的方式。用这种处理方式,可以让代码看起来更整洁、模块化、易重用。
这次不适用 UITableViewController,而是把它划分成几个类:
先从 UITableViewCell 开始吧。
以单视图应用(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 了。
在这里,我们使用基于故事版的(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。
创建一个 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 呢?
首先,在 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 didFailDataUpdateWithError(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 了。
你上面创建的每一个部分,在整个项目里都是可以重用的,当然这是做代码划分的另一个好处了。
ps:想看所有代码的话,可以查看 Github 上面的这个仓库 。
附: 原文链接