UITableView 优雅的使用方式
UITableView
在开发过程中经常使用的组件,在日常使用的软件中随处可见它的影子。这篇文章通过使用泛型来改善UITableViewCell
的方式来优雅的使用UITableViewCell
写在前面
我想大多数的开发者都写过很多的 TableView 的 delegate
和 dataSource
代理方法,反复且繁琐的书写设置 cell 个数、判断对应的 cell 高度、对应的 cell 类型选择,在方法中来根据不同的 cell 类型来调用 cell 内部的数据设置方法等代码非常的浪费时间。
下面我从一个简单的情景出发,也和我们大多数时候的实际开发情况相关,从中引出问题和解决问题。
情景
我们有一个 tableView
,里面包含一些 cell,要求:
- 基本数据模型:每个 cell 需显示一张图片、标题
- 动作类型不同:有些 cell 可以点击,有些 cell 带有开关
- 高度不同:点击类型的 cell 高度为 64,开关的为 44
- 显示顺序:1~2 为点击类 cell,3 为带开关 cell
按照以往的写法,我们通常是构建个数据模型,来满足基本数据模型:
struct TableViewModel {
var title: String?
var image: UIImage?
init(title: String?, image: UIImage?) {
self.title = title
self.image = image
}
}
看起来不错,接下来我们创建两种不同类型的 tableViewCell:
/// 可点击的常规 cell
class TableViewCell: UITableViewCell {
func config(_ viewModel: TableViewModel) {
textLabel?.text = viewModel.title
imageView?.image = viewModel.image
}
}
/// 带有开关的 cell,继承自常规 cell
class SwitcherTableViewCell: TableViewCell {
let switcher: UISwitch = UISwitch()
override init(style: UITableViewCellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
selectionStyle = .none
switcher.addTarget(self, action: #selector(didChangedSwitch), for: .valueChanged)
accessoryView = switcher
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@objc func didChangedSwitch() {
print("didChangedSwitch")
}
}
至此,cell 和数据模型都创建完毕了,开始着手在设置 TableView 了,顺便复习下稳得不能再稳的几个方法
emmm… 设置下代理和注册一下所用的 cell
tableView.delegate = self
tableView.dataSource = self
tableView.register(TableViewCell.self, forCellReuseIdentifier: "TableViewCell")
tableView.register(SwitcherTableViewCell.self, forCellReuseIdentifier: "SwitcherTableViewCell")
设置 cell section 和 row
func numberOfSections(in tableView: UITableView) -> Int {
return 1
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return 3
}
设置 cell 高度
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
switch indexPath.row {
case 0, 1:
return 64
case 2:
return 44
default:
return 44
}
}
设置具体 cell
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let model = models[indexPath.row]
switch indexPath.row {
case 0, 1:
let cell = tableView.dequeueReusableCell(withIdentifier: "TableViewCell", for: indexPath)
(cell as? TableViewCell)?.config(model)
return cell
case 2:
let cell = tableView.dequeueReusableCell(withIdentifier: "SwitcherTableViewCell", for: indexPath)
(cell as? SwitcherTableViewCell)?.config(model)
return cell
default:
return UITableViewCell()
}
}
cell 选中事件
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
if indexPath.row == 2 { return }
let cell = tableView.cellForRow(at: indexPath) as? TableViewCell
cell?.didSelected(at: indexPath)
tableView.deselectRow(at: indexPath, animated: true)
}
上述的写法基本上可以满足需求,没毛病
问题
按照上面提到的情景,除开一些简便的封装、设置 identity 常量等操作,有以下几个问题:
- 因为 cell 个数及种类都不是很多,所以根据 row 判断 cell 的代码不是很长,如果一旦个数增多,种类变得丰富,那么上面这种繁琐的判断无疑使得代码非常长;
- 中途若有新增或删除 cell,或者打乱 cell 顺序,牵一而动全身,整个代码得大幅改动,而且还可能因为忘记注册新的 cell 导致崩溃;
- 维护起来看得眼睛疼0.0;
- 其他地方用到了 tableView 还得这样写一遍…
改善目标
在不影响调用逻辑的情况下:
- 减轻代理方法内的代码行数,如
cellForRowAt
、heightForRowAt
; - 新增、删除、打乱顺序做到改动最小;
- 可 CV 编程、可复用,不做重复的事情;
改善方案
- 使用常量来代替字符串式的
reuseidentifier
- 通过使用
Swift
的泛型以及associatedtype
「关联类型」来构造「黑魔法」 - 调用反转,以前是
cell.config(xxx)
,现在反过来xxx.config(cell)
首先,我们需要创建一个包含常规 cell 在代理方法中常用的一些属性、事件动作方法的协议,遵循此协议需要设置对应的属性、事件动作
public protocol KSYCellSelectable {
func didSelected(at indexPath: IndexPath)
}
public protocol KSYCellConfigurable {
var reuseIdentifier: String { get }
var cellClass: AnyClass { get }
var selection: KSYCellSelectable? { get }
var height: CGFloat { get }
func config(_ cell: UITableViewCell)
}
Cell 也是会有一个自己的设置显示数据的方法,不过数据的类型统一为关联对象
public protocol KSYCellViewModel {
associatedtype ViewModel
var viewModel: ViewModel? { get }
func config(_ viewModel: ViewModel)
}
最后我们需要一个构造器来实现 KSYCellConfigurable
协议,通过 Swift 的泛型,在对应的实现方法中调用 cell 的设置显示数据方法
public struct KSYCellConfigurator<Cell: UITableViewCell>: KSYCellConfigurable where Cell: KSYCellViewModel {
public let reuseIdentifier: String = NSStringFromClass(Cell.self)
public let cellClass: AnyClass = Cell.self
public var selection: KSYCellSelectable?
public var height: CGFloat
public func config(_ cell: UITableViewCell) {
guard let `cell` = cell as? Cell else {
fatalError("cell is not KSYCellViewModel?! ")
}
cell.config(viewModel)
}
public let viewModel: Cell.ViewModel
public init(viewModel: Cell.ViewModel, height: CGFloat = 44, selection: KSYCellSelectable? = nil) {
self.viewModel = viewModel
self.height = height
self.selection = selection
}
}
事件处理,这里以选中为例
public struct KSYCellSelectedAction: KSYCellSelectable {
fileprivate var selectedAction: ((IndexPath) -> Void)
public init(selectedAction: @escaping ((IndexPath) -> Void)) {
self.selectedAction = selectedAction
}
public func didSelected(at indexPath: IndexPath) {
selectedAction(indexPath)
}
}
实践,才是检验真理的...
一切就绪之后,以后的写法中,所有的 cell 需要实现 KSYCellViewModel
协议,并且指定不同的数据模型类型和实现协议的方法
class TableViewCell: UITableViewCell, KSYCellViewModel {
typealias ViewModel = TableViewModel
var viewModel: ViewModel?
func config(_ viewModel: TableViewModel) {
self.viewModel = viewModel
textLabel?.text = viewModel.title
imageView?.image = viewModel.image
}
}
在 vc 或者设置 tableView 的地方,我们通过方法获取设置一个基本的 cell 数据源
var items = setupItems()
func setupItems() -> [[KSYCellConfigurable]] {
let cell1 = KSYCellConfigurator<TableViewCell>(
viewModel: TableViewModel(title: "say", image: UIImage(named: "DistanceIcon.png")) ,
height: 64,
selection: KSYCellSelectedAction(selectedAction: { (indexPath) in
print("did Selected indexPath section: \(indexPath.section) row: \(indexPath.row)")
}))
let cell2 = KSYCellConfigurator<TableViewCell>(
viewModel: TableViewModel(title: "oh yeah", image: UIImage(named: "DistanceIcon.png")) ,
height: 64,
selection: KSYCellSelectedAction(selectedAction: { (indexPath) in
print("did Selected indexPath section: \(indexPath.section) row: \(indexPath.row)")
}))
let cell3 = KSYCellConfigurator<SwitcherTableViewCell>(
viewModel: TableViewModel(title: "oh yeah switch", image: UIImage(named: "DistanceIcon.png")) ,
height: 44)
return [[cell1, cell2, cell3]]
}
tableView 代理该怎么设置还是怎么设置,但是注册对应的 cell 方法变成了循环检查 cell 数据源中的类型
for section in items {
for configure in section {
self.tableView?.register(configure.cellClass.self, forCellReuseIdentifier: configure.reuseIdentifier)
}
}
运用上述方法后,改写后面的代理方法
func numberOfSections(in tableView: UITableView) -> Int {
return items.count
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return items[section].count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let configure = items[indexPath.section][indexPath.row]
let cell = tableView.dequeueReusableCell(withIdentifier: configure.reuseIdentifier, for: indexPath)
configure.config(cell)
return cell
}
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
let configure = items[indexPath.section][indexPath.row]
return configure.height
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
if let selection = items[indexPath.section][indexPath.row].selection {
selection.didSelected(at: indexPath)
}
tableView.deselectRow(at: indexPath, animated: true)
}
上述的代理方法可以复制到任何使用上述方法来设置 tableView 的地方,继承已经实现过的类,以后可以不用再写 tableView 的代理方法
使用总结
- 自定义的 UITableViewCell 实现
KSYCellViewModel
协议,指定 cell 所需的数据模型类型; - 统一使用
KSYCellConfigurator
来创建 cell 和 cell 的数据源及事件方法; - 代理方法统一为上述写法,若
tableView
为单一section
,可以将数组的纬度降低。
主要思想是提取 cell 的基础数据属性,其它使用 associatedtype
和 Swift 的泛型来指定 cell 的数据源,通过构造器的形式来将 cell 的设置方法反转。
想看 demo 的小伙伴可以戳 地址