HoloTableView & HoloCollectionView 让你的列表更好维护

前言

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: 使用预览

UITableViewUICollectionView 扩展了 holo_makeRows: 方法,在这个方法里每次 make.row() 就代表着创建一个 cell,后边的 .model().height() 就是给这个 cell 设置 model、高度等信息

HoloTableView 简要用法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
UITableView *tableView = [[UITableView alloc] initWithFrame:self.view.bounds style:UITableViewStylePlain];
[self.view addSubview:tableView];

[tableView holo_makeRows:^(HoloTableViewRowMaker * _Nonnull make) {
// make a cell
make.row(ExampleTableViewCell.class).model(NSDictionary.new).height(44);

// make a list
for (NSObject *obj in NSArray.new) {
make.row(ExampleTableViewCell.class).model(obj).didSelectHandler(^(id _Nullable model) {
NSLog(@"did select row : %@", model);
});
}
}];

[tableView reloadData];

HoloCollectionView 简要用法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
UICollectionViewFlowLayout *flowLayout = [UICollectionViewFlowLayout new];
UICollectionView *collectionView = [[UICollectionView alloc] initWithFrame:self.view.bounds collectionViewLayout:flowLayout];
[self.view addSubview:collectionView];

[collectionView holo_makeRows:^(HoloCollectionViewRowMaker * _Nonnull make) {
// make a cell
make.row(ExampleCollectionViewCell.class).model(NSDictionary.new).size(CGSizeMake(100, 200));

// make a list
for (NSObject *obj in NSArray.new) {
make.row(ExampleCollectionViewCell.class).model(obj).didSelectHandler(^(id _Nullable model) {
NSLog(@"did select row : %@", model);
});
}
}];

[collectionView reloadData];

注:从以上简要用法里也可以看出,HoloTableViewHoloCollectionView 的调用 API 几乎一致,所以为节省篇幅以下介绍及代码示例主要以 HoloTableView 为例。

封装历程

以上篇幅主要是为了让读者对这两个库有个大概的认知,以下来讲一下对 UITableViewHoloTableView 的封装历程。以 UITableView 的实际使用举例。
以下代码极大的简化了逻辑,主要是为了展示 UITableView 代理方法的实现方式。

1、入门版写法

假设现在要写一个列表,如下是入门版代理方法的实现过程:1、总共多少行,2、每行什么 cell,3、每个 cell 高度多少,4、点击每行 cell 的事件是什么

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
return 2;
}

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
if (indexPath.row == 0) {
ProfileTableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"ProfileTableViewCell"];
return cell;
} else {
OrderTableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"OrderTableViewCell"];
return cell;
}
}

- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath {
if (indexPath.row == 0) {
return 100;
} else {
return 60;
}
}

- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
if (indexPath.row == 0) {
[self _pushVC1];
} else {
[self _pushVC2];
}
}

2、优化版写法

以上写法的问题是,假设某天要在中间插入一行别样的 cell,那么在每个代理方法都要去改对应的 if-else 逻辑,势必造成代码的可维护性很差,为了解决这一问题可将代理优化成如下样子:组装数据源,根据数据源实现代理方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// 数据源
- (NSArray<NSDictionary *> *)listData {
return @[
@{@"cell" : ProfileTableViewCell.class,
@"height" : @100,
@"didSelectSEL" : NSStringFromSelector(@selector(_pushVC1))
},
@{@"cell" : OrderTableViewCell.class,
@"height" : @60,
@"didSelectSEL" : NSStringFromSelector(@selector(_pushVC2))
}
];
}

// 代理方法实现
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
return self.listData.count;
}

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
NSDictionary *dict = self.listData[indexPath.row];
NSString *identifier = NSStringFromClass(dict[@"cell"]);
UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:identifier];
return cell;
}

- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath {
NSDictionary *dict = self.listData[indexPath.row];
return [dict[@"height"] floatValue];
}

- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
NSDictionary *dict = self.listData[indexPath.row];
SEL sel = NSSelectorFromString(dict[@"didSelectedSEL"]);
if (sel) {
[self performSelector:sel];
}
}

3、复用版写法

以上的优化版解决简单场景还好,但如果场景复杂,需要组装的数据源就会变得复杂起来,而且每个页面都要写这么一套还是会显得很繁琐。由此,我们可以想到进一步封装,将这一套逻辑封装成统一的框架,随处可用。
需要提供的数据源工具无非就两种,一种 row:带有 cell 类型,高度,数据,点击事件等;一种 section:带有 headerfooter 等和一份 row 的数组。
使用时,组装好一份 section 数组,根据这个数据来实现代理方法就好了,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
// 数据类型
@interface Section : NSObject
@property (nonatomic, copy) NSArray<Row *> *rows;
@property (nonatomic, assign) Class header;
@property (nonatomic, assign) Class footer;
@property (nonatomic, assign) CGFloat headerHeight;
@property (nonatomic, assign) CGFloat footerHeight;
@property (nonatomic, strong) id headerModel;
@property (nonatomic, strong) id footerModel;
@end

@interface Row : NSObject
@property (nonatomic, assign) Class cell;
@property (nonatomic, assign) CGFloat height;
@property (nonatomic, strong) id model;
@property (nonatomic, copy) void (^didSelectHandler)(id _Nullable model);
@end

// 代理对象
@interface TableViewProxy () <UITableViewDataSource, UITableViewDelegate>
@property (nonatomic, copy) NSArray <Section *> *sectionArray;
@end

@implementation TableViewProxy
#pragma mark - reload 方法
- (void)reloadDataWithTableView:(UITableView *)tableView listData:(NSArray<Section *> *)listData {
tableView.delegate = self;
tableView.dataSource = self;

self.sectionArray = listData;

[tableView reloadData];
}

#pragma mark - UITableViewDataSource, UITableViewDelegate
- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView {
return self.sectionArray.count;
}

- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
Section *section = self.sectionArray[section];
return section.rows.count;
}

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
Section *section = self.sectionArray[indexPath.section];
Row *row = section.rows[indexPath.row];
UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:NSStringFromClass(row.cell)];
if (!cell) {
cell = [[row.cell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:NSStringFromClass(row.cell)];
}
return cell;
}

- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath {
Section *section = self.sectionArray[indexPath.section];
Row *row = section.rows[indexPath.row];
return row.height;
}

- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
Section *section = self.sectionArray[indexPath.section];
Row *row = section.rows[indexPath.row];
if (row.didSelectHandler) {
row.didSelectHandler(row.model);
}
}

这样一来,使用的时候只需组装出一份 section 数组进行 reloadData 就可以刷出列表了,如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 组装数据
Section *section = [Section new];
Row *row1 = [Row new];
row1.cellClass = ProfileTableViewCell.class;
row1.height = 100;

Row *row2 = [Row new];
row2.cellClass = OrderTableViewCell.class;
row2.height = 60;
row2.didSelectHandler = ^(id _Nullable model) {
[self _pushVC1];
};

section.rows = @[row1, row2];
NSArray *sections = @[section];

// 刷新列表
self.proxy = [TableViewProxy new];
[self.proxy reloadDataWithTableView:self.tableView listData:sections];

至此,就是 HoloTableView 封装及分发代理方法的核心思想了,进一步要做的就是完善 rowsection 的属性支持,实现更全面的代理方法。

完善的属性支持见:HoloTableRow.hHoloTableSection.h
全面的代理实现见:HoloTableViewProxy.m

而除了完善更多的字段支持和代理实现之外,如何更方便快捷的组装数据就是 HoloTableView 的另一重点了。以下来介绍链式语法的实现来便捷的组装数据。

链式语法

HoloTableView 完全参照 Masonry 的设计思路,甚至 API 的定义也是高度致敬 Masonry。先说下 Masonry 里的链式语法的大体实现,主要是:- (NSArray *)mas_makeConstraints:(void(NS_NOESCAPE ^)(MASConstraintMaker *make))block; 这个 API
1、声明一个 block,传入一个 maker 对象
2、给 maker 声明了一堆的属性(leftright 等),每个属性的返回值类型都相同,就可以实现链式语法
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 类型,通过给这个类提供一堆的属性(cellmodelheight 等),每个属性的返回值类型相同,实现链式调用
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
2
3
@property (nonatomic, strong, readonly) MASConstraint *left;
@property (nonatomic, strong, readonly) MASConstraint *top;
@property (nonatomic, strong, readonly) MASConstraint *right;

也有一些带参数的,比如 equalTo 方法:make.top.equalTo(self)

1
2
3
4
5
- (MASConstraint * (^)(id))equalTo {
return ^id(id attribute) {
return self.equalToWithRelation(attribute, NSLayoutRelationEqual);
};
}

HoloTableRow 提供的字段都是需要传入参数的,采用的是:声明 block 属性和入参,标记为 readonly,并重写这个属性的 getter 方法。getter 方法里创建一个 block 对象并返回,执行这个 block 的话,将传入参数设置给 已创建的 HoloTableRow 对象,并返回 self,保证可以继续链式调用。以 model 这个字段举例:

1
2
3
4
5
6
7
8
9
10
// .h 声明
@property (nonatomic, copy, readonly) HoloTableRowMaker *(^model)(id model);

// .m 重写 getter
- (HoloTableRowMaker *(^)(id))model {
return ^id(id obj) {
self.tableRow.model = obj;
return self;
};
}

调用的时候就是 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
2
3
4
[self.tableView holo_makeRows:^(HoloTableViewRowMaker * _Nonnull make) {
make.row(ExampleTableViewCell.class);
make.row(ExampleTableViewCell.class);
}];

直接调用 holo_makeRows: 方法的话,会默认创建一个 tag 为 nil 的 section(section 的用法下一章节介绍) 用来承载将要创建的 row,就像系统代理方法 numberOfSectionsInTableView: 一样,不实现的话默认为 1。

每次 make.row() 都是创建一个 row(即一个 cell),上边示例代理里执行了两次那就是创建两行 cell,传入 cell 的 class。

A:怎么给 cell 设置 model 呢?
Q: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
2
3
4
// 给 cell 赋值,这里的 model 就是 make.model() 传入的数据
- (void)holo_configureCellWithModel:(id)model {
self.model = model;
}

A:怎么给 cell 设置高度呢?
Q:三种方式:
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
2
3
4
// 返回 cell 高度,这里的 model 就是 make.model() 传入的数据
+ (CGFloat)holo_heightForCellWithModel:(id)model {
return 44;
}

除了 height 这个逻辑(height/heightHandler/heightSEL),你在 HoloTableRowMaker.h 这个文件里会发现提供给的很多字段都同时拥有三个类似的属性:xxxxxxHandlerxxxSEL,这类的字段都拥有调用优先级
1、如果你的 cell 里实现了 xxxSEL 方法,会优先使用这个方法,否则的话
2、如果你的 maker 设置了 xxxHandler block,会优先使用这个 block,否则的话
3、才会使用 xxx 这个字段

有些功能可能只有以上其中两个字断,优先级同样如上。

A:HoloTableRowMaker.h 里 tag 属性的作用是什么?
Q:HoloTableView 消除了 NSIndexPath 的概念,所以需要用 tag 值来给每个 row,每个 section 打上标记,通过这个 tag 来找到目标,执行更新、删除等操作。(tag 的用法后续章节介绍 row、section 更新操作时会有示例)

A:cell 不再是自己创建了,那如果 cell 有事件产生怎么回调到 VC 里呢?
Q:rowMaker 的 willDisplayHandler 这个 block 会在 willDisplayCell: 代理方法执行的时候被回调,并且传回当前 cell,就可以拿到 cell 对象了,至于 cell 头文件暴露了什么也一并可以操作了。

1
2
3
4
5
[self.tableView holo_makeRows:^(HoloTableViewRowMaker * _Nonnull make) {
make.row(ExampleTableViewCell.class).willDisplayHandler(^(UITableViewCell * _Nonnull cell, id _Nullable model) {
NSLog(@"cell:%@", cell);
});
}];

还有一种方式是,传入的 model 是你自己自装的对象:model 里声明 block,VC 里实现这个 blcok,cell 内部调用这个 block。

或者:给 UIResponder 扩展了一个方法 UIResponder+HoloEvent.m,沿着响应者链向上传递消息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// UIResponder 扩展方法
- (void)holo_event:(NSString *)event params:(NSDictionary *)params {
[self.nextResponder holo_event:event params:params];
}

// cell 里发送消息
[self.nextResponder holo_event:@"event" params:@{}];
// vc 里拦截消息
- (void)holo_event:(NSString *)event params:(NSDictionary *)params {
if ([event isEqualToString:@"event"]) {
NSLog(@"params: %@", params);
return;
}
}

section

同样可以通过 holo_makeSections: 方法创建一个或多个 section,每个 section 可以包含着 headerfooer 和 一堆的 row

1
2
3
4
[self.tableView holo_makeSections:^(HoloTableViewSectionMaker * _Nonnull make) {
make.section(@"tag1");
make.section(@"tag2")
}];

holo_makeRows: 方法一样,每次 make.section() 都是创建一个 section,可以给这个 section 设置 header 及相关的数据,比如 model、高度等;可以设置 footer 及相关的数据,比如 model、高度等:

1
2
3
4
5
6
7
8
9
[self.tableView holo_makeSections:^(HoloTableViewSectionMaker * _Nonnull make) {
make.section(@"tag1")
.header(ExampleHeaderView.class).headerModel(@{}).headerHeight(100)
.footer(ExampleFooterView.class).footerModel(@{}).footerHeight(100);

make.section(@"tag2")
.header(ExampleHeaderView.class).headerModel(@{}).headerHeight(80)
.footer(ExampleFooterView.class).footerModel(@{}).footerHeight(80);
}];

header 或者 footer 的设计可以类比 row,所拥有的字段也都很像,查看:HoloTableSectionMaker.h

section 同样可以通过 makeRows 方法便捷的创建它所拥有的 row

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
[self.tableView holo_makeSections:^(HoloTableViewSectionMaker * _Nonnull make) {
make.section(@"tag1")
.header(ExampleHeaderView.class).headerModel(@{}).headerHeight(100)
.footer(ExampleFooterView.class).footerModel(@{}).footerHeight(100)
.makeRows(^(HoloTableViewRowMaker * _Nonnull make) {
make.row(ExampleTableViewCell.class).height(44);
make.row(ExampleTableViewCell.class).height(44);
});

make.section(@"tag2")
.header(ExampleHeaderView.class).headerModel(@{}).headerHeight(100)
.footer(ExampleFooterView.class).footerModel(@{}).footerHeight(100)
.makeRows(^(HoloTableViewRowMaker * _Nonnull make) {
make.row(ExampleTableViewCell.class).height(66);
make.row(ExampleTableViewCell.class).height(66);
});
}];

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 身上,比如 didSelectCellwillDisplayCell 等事件,所以给 row 提供了对应的 didSelectHandlerwillDisplayHandler 字段。这个字段的使用也大多都在 VC 里,但很多业务逻辑其实只是 cell 自己内部的事情。某些时候,如果把这些逻辑交给 cell 自己处理可能会让代码更清晰,更好维护,所以有了 2.0 版本的迭代。
HoloTableView 的 2.0 时代,可以把更多的事件分发到 cell 内部处理,比如 didSelectCellwillDisplayCell 等事件,cell 遵守 HoloTableViewCellProtocol.h 协议并实现 - (void)holo_didSelectCellWithModel:(id)model;- (void)holo_willDisplayCellWithModel:(id)model; 等方法即可。
需要注意的是,一旦 cell 实现了对应的方法,对应的 didSelectCellwillDisplayCell 等 block 便失效了,也就是前文提到的调用优先级

headerfooter 同样适用于以上逻辑。以 cell 为例,遵守 HoloTableViewCellProtocol 协议并实现方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#import "ExampleTableViewCell.h"
#import <HoloTableView/HoloTableView.h>

@interface ExampleTableViewCell () <HoloTableViewCellProtocol>
@property (nonatomic, strong) id model;
@end

@implementation HoloExampleTableViewCell

#pragma mark - HoloTableViewCellProtocol
- (void)holo_configureCellWithModel:(NSDictionary *)model {
self.model = model;
}

+ (CGFloat)holo_heightForCellWithModel:(NSDictionary *)model {
return 44;
}

- (void)holo_didSelectCellWithModel:(id)model {
}

- (void)holo_willDisplayCellWithModel:(id)model {
}
// etc

@end

section & row:make、update、remake、insert、remove

除了前文提到的 holo_makeSections: 方法用于给 tableView 新增 sectionholo_makeRows: 方法用于给 tableView 新增 row 之外,还提供了一系列的方法用于对 sectionrow 的增删改查操作。
参见:UITableView+HoloTableView.h

特别说明的是:
1、每个方法都同时拥有一个 withReloadAnimation: 方法,比如:

1
2
3
- (void)holo_makeRows:(void(NS_NOESCAPE ^)(HoloTableViewRowMaker *make))block;
- (void)holo_makeRows:(void(NS_NOESCAPE ^)(HoloTableViewRowMaker *make))block
withReloadAnimation:(UITableViewRowAnimation)animation;

区别在于上边的方法执行过后需要执行 [tableView reloadData] 刷新列表,下边的方法则会自动刷新,但并非调用的 reloadData 方法,而是对应的 insertRowsAtIndexPaths:reloadRowsAtIndexPaths: 等方法。
和原生方法表现一致:如果是新增或者更新一个或者几个 cell 的话,可以使用带 withReloadAnimation: 的方法,仅刷新某一行性能更好。但如果刷新的 cell 很多的话反而不如 reloadData 方法性能表现更好,所以需要合理选择。

2、类比 Masonry 的 API 逻辑:
make 系列的方法就是新增 sectionrow
update 系列的方法就是通过 make.tag() 匹配到对应的 sectionrow,进行更新某部分数据,比如 model、高度等
remake 系列的方法就是通过 make.tag() 匹配到对应的 sectionrow,将原本的数据清空,重新赋值数据

1
2
3
4
5
6
7
8
9
10
11
12
// make
[self.tableView holo_makeRows:^(HoloTableViewRowMaker * _Nonnull make) {
make.row(ExampleTableViewCell.class).model(@{}).height(44).tag(@"a");
}];
// update
[self.tableView holo_updateRows:^(HoloTableViewUpdateRowMaker * _Nonnull make) {
make.tag(@"a").height(88);
}];
// remake
[self.tableView holo_remakeRows:^(HoloTableViewUpdateRowMaker * _Nonnull make) {
make.tag(@"a").height(88);
}];

比如以上代码:
1)holo_makeRows: 方法创建了一个 row,给了 calssmodelheight,设置了 tag 标记
2)如果执行 holo_updateRows: 方法的话,那这个 cell 就是从高度 44 刷新到了 88
3)如果执行 holo_remakeRows: 方法的话,因为将原来的数据清空了,所以 model 就不在了,class 也不在了,需要重新赋值

key-Class map

在查看 HoloTableViewRowMaker.h 提供的 row 字段之外,会发现还有个 rowS 字段,顾名思义,row 接收的是 cell 的 class 类型,而 rowS 接收的是 cell class 的 string 形式,你可以这么用:row(ExampleTableViewCell.class),也可以这么用:rowS(@"ExampleTableViewCell")

有一种场景,比如说首页是一个包含很多模块的列表,而到底展示哪些模块是由后台接口配置的,接口返回了一些模块的名称,每个模块名称对应着一种 cell。

而接收字符串类型的 rowS 字段还拥有的另外一个功能,就是 key-Class 映射。提前做好一份 string 对 cell class 的映射关系,比如 @"cell" : ExampleTableViewCell.class,就可以用 rowS(@"cell") 来设置 cell 了。

不仅是 cell,包括 section 的 header、footer 也拥有相同的功能,配置 key-Class 映射关系的方式也很方便,在 holo_makeTableView: 方法里,由 HoloTableViewMaker.h 这个 maker 提供:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 注册 key-Class map
[self.tableView holo_makeTableView:^(HoloTableViewMaker * _Nonnull make) {
make
.makeRowsMap(^(HoloTableViewRowMapMaker * _Nonnull make) {
make.row(@"cell1").map(ExampleTableViewCell1.class);
make.row(@"cell2").map(ExampleTableViewCell2.class);
})
.makeHeadersMap(^(HoloTableViewHeaderMapMaker * _Nonnull make) {
make.header(@"header1").map(ExampleHeaderView1.class);
make.header(@"header2").map(ExampleHeaderView2.class);
})
.makeFootersMap(^(HoloTableViewFooterMapMaker * _Nonnull make) {
make.footer(@"footer1").map(ExampleFooterView1.class);
make.footer(@"footer2").map(ExampleFooterView2.class);
});
}];

// 使用 key 值
[self.tableView holo_makeSections:^(HoloTableViewSectionMaker * _Nonnull make) {
make.section(TAG).headerS(@"header1").footerS(@"footer1").makeRows(^(HoloTableViewRowMaker * _Nonnull make) {
make.rowS(@"cell1");
make.rowS(@"cell1");
});

make.section(TAG).headerS(@"header2").footerS(@"footer2").makeRows(^(HoloTableViewRowMaker * _Nonnull make) {
make.rowS(@"cell2");
make.rowS(@"cell2");
});
}];

scrollDelegate、delegate、datasource

HoloTableView 代理了 UITableViewDelegateUITableViewDataSource 之后,某些场景下,你可能还想再拿到这些代理方法,自己实现一些逻辑,尤其是 UIScrollViewDelegate 的代理方法最为常用。
因为当前 tableView 的代理已经设置给 HoloTableViewProxy.h 对象,再次夺回代理的话有两种方式:

1
2
3
4
5
6
7
8
9
// 方式一
self.tableView.holo_proxy.dataSource = self;
self.tableView.holo_proxy.delegate = self;
self.tableView.holo_proxy.scrollDelegate = self;

// 方式二
[self.tableView holo_makeTableView:^(HoloTableViewMaker * _Nonnull make) {
make.dataSource(self).delegate(self).scrollDelegate(self);
}];

设置过代理之后,一旦自己实现了某个代理方法,那这个代理方法 HoloTableView 就不再处理了。比如:

1
2
3
4
5
6
7
- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView {
if ([self.dataSource respondsToSelector:@selector(numberOfSectionsInTableView:)]) {
return [self.dataSource numberOfSectionsInTableView:tableView];
}

return self.holoSections.count;
}

swipe action

UITableViewCell 的左滑、右滑功能在 iOS 各个系统版本间的差异比较大,直接写在业务代码里判断会非常多,HoloTableView 将这部分功能做了个总结,提供了 HoloTableViewRowSwipeAction.h 这个类,提供的属性都标记了 iOS 版本,调用起来相对会比较方便。

可以组装好数组传递给 HoloTableRowMaker.h 类的 leadingSwipeActions or trailingSwipeActions 属性:

1
2
3
4
5
6
7
8
9
10
11
HoloTableViewRowSwipeAction *leftSwipeAction1 = [HoloTableViewRowSwipeAction rowSwipeActionWithStyle:HoloTableViewRowSwipeActionStyleNormal title:@"left1"];
HoloTableViewRowSwipeAction *leftSwipeAction2 = [HoloTableViewRowSwipeAction rowSwipeActionWithStyle:HoloTableViewRowSwipeActionStyleNormal title:@"left2"];

HoloTableViewRowSwipeAction *rightSwipeAction1 = [HoloTableViewRowSwipeAction rowSwipeActionWithStyle:HoloTableViewRowSwipeActionStyleNormal title:@"Right1"];
HoloTableViewRowSwipeAction *rightSwipeAction2 = [HoloTableViewRowSwipeAction rowSwipeActionWithStyle:HoloTableViewRowSwipeActionStyleNormal title:@"Right2"];

[self.tableView holo_makeRows:^(HoloTableViewRowMaker * _Nonnull make) {
make.row(HoloExampleTableViewCell.class)
.leadingSwipeActions(@[leftSwipeAction1, leftSwipeAction2])
.trailingSwipeActions(@[rightSwipeAction1, rightSwipeAction2]);
}];

Plugin

之前遇到过一个关于 SDWebImage 4.x & 5.x 对 GIF 类型的处理问题 的bug,在排查问题过程中,查看了 SD 5.x 源码,发现 SD 的一个策略:把对很多第三方库的支持封装成插件(plugin),引入某个插件便能够拥有对某个库的支持,比如这些:Integration with 3rd party libraries

对于 UITableViewCell 的左滑、右滑功能,业界也有个比较知名的三方库:MGSwipeTableCellHoloTableView 借鉴 SDWebImage 的做法,封装了一个 plugin 库:HoloTableViewMGPlugin,API 的定义完全参照的 MGSwipeTableCell,字段尽量保持一致,以降低上手成本。

需要注意的是,HoloTableViewMGPluginHoloTableView 一样,只是帮你处理代理事件。至于其他的必要条件还是要满足的,比如你的 cell 需要继承 MGSwipeTableCell 基类。
使用姿势:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#import <HoloTableViewMGPlugin/HoloTableViewMGPlugin.h>

[self.tableView holo_makeRows:^(HoloTableViewRowMaker * _Nonnull make) {
// 方式一:直接赋值
make.row(ExampleMGSwipeTableCell.class)
.canSwipe(YES)
.makeSwipButtons(^(HoloTableRowMGMaker * _Nonnull make) {
make.direction(MGSwipeDirectionLeftToRight).title(@"Left").backgroundColor(UIColor.redColor);
make.direction(MGSwipeDirectionRightToLeft).title(@"Right").backgroundColor(UIColor.redColor)
.callback(^BOOL(MGSwipeTableCell * _Nonnull cell) {
NSLog(@"tag Right2 swip button");
return YES;
});
});

// 方式二:blcok 回调
make.row(ExampleMGSwipeTableCell.class)
.canSwipeHandler(^BOOL(MGSwipeTableCell * _Nonnull cell, MGSwipeDirection direction, CGPoint fromPoint) {
return YES;
})
.swipeButtonsHandler(^NSArray<UIView *> * _Nonnull(MGSwipeTableCell * _Nonnull cell, MGSwipeDirection direction, MGSwipeSettings * _Nonnull swipeSettings, MGSwipeExpansionSettings * _Nonnull expansionSettings) {
if (direction == MGSwipeDirectionLeftToRight) {
return @[[MGSwipeButton buttonWithTitle:@"Left" backgroundColor:UIColor.redColor]];
} else {
return @[[MGSwipeButton buttonWithTitle:@"Right" backgroundColor:UIColor.redColor]];
}
})
.willBeginSwipingHandler(^(MGSwipeTableCell * _Nonnull cell) {
NSLog(@"begin swiping: %@", cell);
})
.willEndSwipingHandler(^(MGSwipeTableCell * _Nonnull cell) {
NSLog(@"end swiping: %@", cell);
});
}];

TODO

  • Diff reload data.
  • Adapt new APIs from iOS 13 and iOS 14. ( HoloCollectionView )
  • Modern Objective-C and better Swift support.

后记

感谢不知出处的前辈大佬们提供的「数据源代理」和「链式语法组装数据」思路。
感谢 MasonrySDWebImageMGSwipeTableCell 提供的 API 定义、封装思路 及 源码支持。

希望 HoloTableViewHoloCollectionView 这两个库可以切实的帮助到你减轻列表维护压力,有任何问题欢迎 Issues 或 PR。