前言
受 Masonry
链式语法和之前公司做法的影响,我封装了 HoloTableView
& HoloCollectionView
两个库,提供链式调用组装数据源数据及代理事件。
1、省去了设置代理(tableView.dataSource = self;
tableView.delegate = self;
)、遵守代理(<UITableViewDataSource, UITableViewDelegate>
)以及实现各种代理方法的工作。
2、消除了 NSIndexPath
的概念,用代码执行次数和顺序决定 cell
数量及位置,并且将所有的代理事件分发到每个 cell
身上。
Preview 1: GitHub 地址
https://github.com/HoloFoundation/HoloTableView
https://github.com/HoloFoundation/HoloCollectionView
Preview 2: 使用预览
给 UITableView
、UICollectionView
扩展了 holo_makeRows:
方法,在这个方法里每次 make.row()
就代表着创建一个 cell
,后边的 .model()
、.height()
就是给这个 cell
设置 model、高度等信息
HoloTableView
简要用法:
1 | UITableView *tableView = [[UITableView alloc] initWithFrame:self.view.bounds style:UITableViewStylePlain]; |
HoloCollectionView
简要用法:
1 | UICollectionViewFlowLayout *flowLayout = [UICollectionViewFlowLayout new]; |
** 注:从以上简要用法里也可以看出,HoloTableView
和 HoloCollectionView
的调用 API 几乎一致,所以为节省篇幅以下介绍及代码示例主要以 HoloTableView
为例。**
封装历程
以上篇幅主要是为了让读者对这两个库有个大概的认知,以下来讲一下对 UITableView
到 HoloTableView
的封装历程。以 UITableView
的实际使用举例。
以下代码极大的简化了逻辑,主要是为了展示 UITableView
代理方法的实现方式。
1、入门版写法
假设现在要写一个列表,如下是入门版代理方法的实现过程:1、总共多少行,2、每行什么 cell,3、每个 cell 高度多少,4、点击每行 cell 的事件是什么
1 | - (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section { |
2、优化版写法
以上写法的问题是,假设某天要在中间插入一行别样的 cell,那么在每个代理方法都要去改对应的 if-else 逻辑,势必造成代码的可维护性很差,为了解决这一问题可将代理优化成如下样子:组装数据源,根据数据源实现代理方法
1 | // 数据源 |
3、复用版写法
以上的优化版解决简单场景还好,但如果场景复杂,需要组装的数据源就会变得复杂起来,而且每个页面都要写这么一套还是会显得很繁琐。由此,我们可以想到进一步封装,将这一套逻辑封装成统一的框架,随处可用。
需要提供的数据源工具无非就两种,一种 row
:带有 cell
类型,高度,数据,点击事件等;一种 section
:带有 header
、footer
等和一份 row
的数组。
使用时,组装好一份 section
数组,根据这个数据来实现代理方法就好了,如下:
1 | // 数据类型 |
这样一来,使用的时候只需组装出一份 section
数组进行 reloadData
就可以刷出列表了,如:
1 | // 组装数据 |
至此,就是 HoloTableView
封装及分发代理方法的核心思想了,进一步要做的就是完善 row
和 section
的属性支持,实现更全面的代理方法。
完善的属性支持见:HoloTableRow.h、HoloTableSection.h
全面的代理实现见:HoloTableViewProxy.m
而除了完善更多的字段支持和代理实现之外,如何更方便快捷的组装数据就是 HoloTableView
的另一重点了。以下来介绍链式语法的实现来便捷的组装数据。
链式语法
HoloTableView
完全参照 Masonry
的设计思路,甚至 API 的定义也是高度致敬 Masonry
。先说下 Masonry
里的链式语法的大体实现,主要是:- (NSArray *)mas_makeConstraints:(void(NS_NOESCAPE ^)(MASConstraintMaker *make))block;
这个 API
1、声明一个 block,传入一个 maker
对象
2、给 maker
声明了一堆的属性(left
,right
等),每个属性的返回值类型都相同,就可以实现链式语法
3、调用 maker
的每个属性都是往同一个对象上(当前 View)设置不同的值(left 约束,right 约束等)
4、最后执行 install
方法返回这个包含这堆约束的数组
HoloTableView
也是这样的做法,提供 - (void)holo_makeRows:(void(NS_NOESCAPE ^)(HoloTableViewRowMaker *make))block;
API 用来组装数据
1、声明一个 block,传入一个 maker
对象
2、给 maker
声明了一个属性:row
,这个属性再返回 HoloTableRowMaker
类型,通过给这个类提供一堆的属性(cell
,model
,height
等),每个属性的返回值类型相同,实现链式调用
3、调用这些属性都是为了组装 HoloTableRow
,设置每个 row
的各种数据
4、最后内部执行 install
方法返回一个包含 HoloTableRow
的数组
特别要说明一点的是,Masonry
提供的 mas_makeConstraints:
方法是为了操作同一个 View,所以他的 Maker 提供的每个属性都是同级的,每个属性的返回值类型都相同
但是 HoloTableView
提供的 holo_makeRows:
方法是为了组装一堆的 cell,每次 make.row()
都代表创建一个新的 cell,每个 cell 都有独立的数据。所以 maker
提供了一个一级属性 row
,通过这个属性创建 HoloTableRow
对象并返回另一个 RowMaker
类来提供二级属性操作 HoloTableRow
对象
另外一点不同的是,Masonry
提供的属性大部分都是不带参数的,比如 make.top.left.right
,这些是通过属性(property)提供的:
1 | @property (nonatomic, strong, readonly) MASConstraint *left; |
也有一些带参数的,比如 equalTo 方法:make.top.equalTo(self)
1 | - (MASConstraint * (^)(id))equalTo { |
HoloTableRow
提供的字段都是需要传入参数的,采用的是:声明 block 属性和入参,标记为 readonly
,并重写这个属性的 getter
方法。getter
方法里创建一个 block 对象并返回,执行这个 block 的话,将传入参数设置给 已创建的 HoloTableRow
对象,并返回 self
,保证可以继续链式调用。以 model 这个字段举例:
1 | // .h 声明 |
调用的时候就是 make.row(UITableViewCell.class).model(NSDictionary.new)
,这里的 model()
就是执行了上边的 block,将入参(一个字典对象)设置给了 tableRow
对象的 model
属性,再返回 self
。
之所以选择定义成一个属性而不是像 equalTo
一样定义成方法是因为入参看起来更明确,此外还有个好处是,兼容 Swift 里的方法调用。如果定义成方法,原本在 OC 里调用方式为 .model(NSObject.new)
的链式,在 Swift 需要写成这样 .model()(NSObject.new)
,Swift 需要先执行 model()
方法获取闭包再调用,这种写法显然是不能接受的。
用法介绍
了解了以上介绍的「组装数据源思想」和「通过链式语法便捷组装数据」这两块主要卖点后,接下来就看下具体怎么使用的吧
row
UITableView
最常见的用法就是创建一个简单的列表,在文章开头的预览里也看到了:
1 | [self.tableView holo_makeRows:^(HoloTableViewRowMaker * _Nonnull make) { |
直接调用 holo_makeRows:
方法的话,会默认创建一个 tag 为 nil 的 section(section 的用法下一章节介绍) 用来承载将要创建的 row,就像系统代理方法 numberOfSectionsInTableView:
一样,不实现的话默认为 1。
每次 make.row()
都是创建一个 row(即一个 cell),上边示例代理里执行了两次那就是创建两行 cell,传入 cell 的 class。
Q:怎么给 cell 设置 model 呢?
A:make.row().mode()
& - (void)holo_configureCellWithModel:(id)model;
通过 .mode()
传入 model 数据,但是传入的 model 怎么设置给 cell 呢?你在 cell 的 .m 里怎么拿到这个 model 呢?HoloTableView
是根据你传入的 cell class,new 一个 cell 对象,用这个 cell 调用默认的方法:holo_configureCellWithModel:
,所以你需要在你的 cell 里实现这个方法,就能拿到传入的 model 了。如果你不喜欢这个方法的名字,可以通过 make.row().mode().configSEL()
传入你自己的方法名。
1 | // 给 cell 赋值,这里的 model 就是 make.model() 传入的数据 |
Q:怎么给 cell 设置高度呢?
A:三种方式:
1、make.row().height(44)
2、make.row().heightHandler(^CGFloat(id _Nullable model) { return 44 })
3、+ (CGFloat)holo_heightForCellWithModel:(id)model;
如果 cell 的高度固定又比较简单可以通过 .height()
直接设置一个高度。
如果 cell 的高度是变化的,可以通过 .heightHandler
这个 block 返回高度。
如果这些计算高度的代码不想写在 VC 里,想要 cell 自己处理,那么可以在你的 cell 的 .m 里实现 holo_heightForCellWithModel:
方法返回高度。建议尽量用这个方法来控制高度,可以把 cell 高度的代码逻辑内敛到 cell 内部。当然,如果你不喜欢这个方法名还可以通过 make.row().heightSEL()
指定自己的方法名。
1 | // 返回 cell 高度,这里的 model 就是 make.model() 传入的数据 |
除了 height 这个逻辑(height
/heightHandler
/heightSEL
),你在 HoloTableRowMaker.h 这个文件里会发现提供给的很多字段都同时拥有三个类似的属性:xxx
、xxxHandler
、xxxSEL
,这类的字段都拥有调用优先级:
1、如果你的 cell 里实现了 xxxSEL
方法,会优先使用这个方法,否则的话
2、如果你的 maker 设置了 xxxHandler
block,会优先使用这个 block,否则的话
3、才会使用 xxx
这个字段
有些功能可能只有以上其中两个字断,优先级同样如上。
Q:HoloTableRowMaker.h 里 tag 属性的作用是什么?
A:HoloTableView
消除了 NSIndexPath
的概念,所以需要用 tag 值来给每个 row,每个 section 打上标记,通过这个 tag 来找到目标,执行更新、删除等操作。(tag 的用法后续章节介绍 row、section 更新操作时会有示例)
Q:cell 不再是自己创建了,那如果 cell 有事件产生怎么回调到 VC 里呢?
A:rowMaker 的 willDisplayHandler
这个 block 会在 willDisplayCell:
代理方法执行的时候被回调,并且传回当前 cell,就可以拿到 cell 对象了,至于 cell 头文件暴露了什么也一并可以操作了。
1 | [self.tableView holo_makeRows:^(HoloTableViewRowMaker * _Nonnull make) { |
还有一种方式是,传入的 model 是你自己自装的对象:model 里声明 block,VC 里实现这个 blcok,cell 内部调用这个 block。
或者:给 UIResponder
扩展了一个方法 UIResponder+HoloEvent.m,沿着响应者链向上传递消息:
1 | // UIResponder 扩展方法 |
section
同样可以通过 holo_makeSections:
方法创建一个或多个 section
,每个 section
可以包含着 header
、fooer
和 一堆的 row
:
1 | [self.tableView holo_makeSections:^(HoloTableViewSectionMaker * _Nonnull make) { |
和 holo_makeRows:
方法一样,每次 make.section()
都是创建一个 section,可以给这个 section 设置 header 及相关的数据,比如 model、高度等;可以设置 footer 及相关的数据,比如 model、高度等:
1 | [self.tableView holo_makeSections:^(HoloTableViewSectionMaker * _Nonnull make) { |
header
或者 footer
的设计可以类比 row
,所拥有的字段也都很像,查看:HoloTableSectionMaker.h
section
同样可以通过 makeRows
方法便捷的创建它所拥有的 row
:
1 | [self.tableView holo_makeSections:^(HoloTableViewSectionMaker * _Nonnull make) { |
protocol
前文介绍 row 的时候提到了,如何将 model 设置 cell?cell 需要实现 - (void)holo_configureCellWithModel:(id)model;
方法;如何设定 cell 的高度?cell 可以实现 + (CGFloat)holo_heightForCellWithModel:(id)model;
方法。除了这两个最常用的方法, HoloTableView
给 cell、header、fooer 提供了很多的其他功能的默认方法,参见:
HoloTableViewCellProtocol.h
HoloTableViewHeaderProtocol.h
HoloTableViewFooterProtocol.h
在 HoloTableView
的 1.x 时代,是把 UITableView
的代理方法分发到每个 row 身上,比如 didSelectCell
、willDisplayCell
等事件,所以给 row 提供了对应的 didSelectHandler
、willDisplayHandler
字段。这个字段的使用也大多都在 VC 里,但很多业务逻辑其实只是 cell 自己内部的事情。某些时候,如果把这些逻辑交给 cell 自己处理可能会让代码更清晰,更好维护,所以有了 2.0 版本的迭代。
在 HoloTableView
的 2.0 时代,可以把更多的事件分发到 cell 内部处理,比如 didSelectCell
、willDisplayCell
等事件,cell 遵守 HoloTableViewCellProtocol.h 协议并实现 - (void)holo_didSelectCellWithModel:(id)model;
、- (void)holo_willDisplayCellWithModel:(id)model;
等方法即可。
需要注意的是,一旦 cell 实现了对应的方法,对应的 didSelectCell
、willDisplayCell
等 block 便失效了,也就是前文提到的调用优先级。
header
、footer
同样适用于以上逻辑。以 cell 为例,遵守 HoloTableViewCellProtocol
协议并实现方法:
1 |
|
section & row:make、update、remake、insert、remove
除了前文提到的 holo_makeSections:
方法用于给 tableView 新增 section
;holo_makeRows:
方法用于给 tableView 新增 row
之外,还提供了一系列的方法用于对 section
、row
的增删改查操作。
参见:UITableView+HoloTableView.h
特别说明的是:
1、每个方法都同时拥有一个 withReloadAnimation:
方法,比如:
1 | - (void)holo_makeRows:(void(NS_NOESCAPE ^)(HoloTableViewRowMaker *make))block; |
区别在于上边的方法执行过后需要执行 [tableView reloadData]
刷新列表,下边的方法则会自动刷新,但并非调用的 reloadData
方法,而是对应的 insertRowsAtIndexPaths:
、reloadRowsAtIndexPaths:
等方法。
和原生方法表现一致:如果是新增或者更新一个或者几个 cell 的话,可以使用带 withReloadAnimation:
的方法,仅刷新某一行性能更好。但如果刷新的 cell 很多的话反而不如 reloadData
方法性能表现更好,所以需要合理选择。
2、类比 Masonry
的 API 逻辑:make
系列的方法就是新增 section
或 row
update
系列的方法就是通过 make.tag()
匹配到对应的 section
或 row
,进行更新某部分数据,比如 model、高度等remake
系列的方法就是通过 make.tag()
匹配到对应的 section
或 row
,将原本的数据清空,重新赋值数据
1 | // make |
比如以上代码:
1)holo_makeRows:
方法创建了一个 row
,给了 calss
,model
,height
,设置了 tag 标记
2)如果执行 holo_updateRows:
方法的话,那这个 cell 就是从高度 44 刷新到了 88
3)如果执行 holo_remakeRows:
方法的话,因为将原来的数据清空了,所以 model 就不在了,class 也不在了,需要重新赋值
scrollDelegate、delegate、datasource
在 HoloTableView
代理了 UITableViewDelegate
、UITableViewDataSource
之后,某些场景下,你可能还想再拿到这些代理方法,自己实现一些逻辑,尤其是 UIScrollViewDelegate
的代理方法最为常用。
因为当前 tableView 的代理已经设置给 HoloTableViewProxy.h 对象,再次夺回代理的话有两种方式:
1 | // 方式一 |
设置过代理之后,一旦自己实现了某个代理方法,那这个代理方法 HoloTableView
就不再处理了。比如:
1 | - (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView { |
swipe action
UITableViewCell
的左滑、右滑功能在 iOS 各个系统版本间的差异比较大,直接写在业务代码里判断会非常多,HoloTableView
将这部分功能做了个总结,提供了 HoloTableViewRowSwipeAction.h 这个类,提供的属性都标记了 iOS 版本,调用起来相对会比较方便。
可以组装好数组传递给 HoloTableRowMaker.h 类的 leadingSwipeActions
or trailingSwipeActions
属性:
1 | HoloTableViewRowSwipeAction *leftSwipeAction1 = [HoloTableViewRowSwipeAction rowSwipeActionWithStyle:HoloTableViewRowSwipeActionStyleNormal title:@"left1"]; |
Plugin
之前遇到过一个关于 SDWebImage 4.x & 5.x 对 GIF 类型的处理问题 的bug,在排查问题过程中,查看了 SD 5.x 源码,发现 SD 的一个策略:把对很多第三方库的支持封装成插件(plugin),引入某个插件便能够拥有对某个库的支持,比如这些:Integration with 3rd party libraries 。
对于 UITableViewCell
的左滑、右滑功能,业界也有个比较知名的三方库:MGSwipeTableCell。HoloTableView
借鉴 SDWebImage
的做法,封装了一个 plugin 库:HoloTableViewMGPlugin,API 的定义完全参照的 MGSwipeTableCell
,字段尽量保持一致,以降低上手成本。
需要注意的是,HoloTableViewMGPlugin
和 HoloTableView
一样,只是帮你处理代理事件。至于其他的必要条件还是要满足的,比如你的 cell 需要继承 MGSwipeTableCell
基类。
使用姿势:
1 |
|
TODO
- Diff reload data.
- Adapt new APIs from iOS 13 and iOS 14. ( HoloCollectionView )
- Modern Objective-C and better Swift support.
后记
感谢不知出处的前辈大佬们提供的「数据源代理」和「链式语法组装数据」思路。
感谢 Masonry、SDWebImage、MGSwipeTableCell 提供的 API 定义、封装思路 及 源码支持。
希望 HoloTableView、HoloCollectionView 这两个库可以切实的帮助到你减轻列表维护压力,有任何问题欢迎 Issues 或 PR。