一种组件化的 Table View 的实现

背景

最近在做一个项目时,需要实现一些列表界面,总体上是上下滚动的,中间的部分段有可以横滚的,有一个个小标签式的,也有可循环滚动的焦点图的……且类似的界面大量出现,并随机组合。可以参照网易云音乐,早期版本的蘑菇街,小红书等等。

按以往的想法是,继承 UITableViewController 然后分多个 section,所有的数据与点击都在一个 VC 中完成。如果全是占满行的 Cell,勉强可以接受,但很快你会发现,你的代码变得庞大而臃肿,且不可维护。更要命的是,代码无法复用,且一旦有需求变动,留下 Bug 的几率很大。网上关于 UITableView 瘦身的优化方法已经很多了,基本上也就是增加一个 ViewModel 层,将代码换了个地方,没什么很大的意思。但是在这次遇到的项目背景下,我想应该可以用更好的组件化的方式来实现。

组件定义

我们定义 UITableView 中的一个 section 为一个组件(component),它需要管理自己的标头(header)、行高、Cell 数量等:

@protocol RTTableComponent <NSObject>
@required

- (NSString *)cellIdentifier;
- (NSString *)headerIdentifier;

- (NSInteger)numberOfItems;
- (CGFloat)heightForComponentHeader;
- (CGFloat)heightForComponentItemAtIndex:(NSUInteger)index;

- (__kindof UITableViewCell *)cellForTableView:(UITableView *)tableView
                                   atIndexPath:(NSIndexPath *)indexPath;
- (__kindof UIView *)headerForTableView:(UITableView *)tableView;

- (void)reloadDataWithTableView:(UITableView *)tableView
                      inSection:(NSInteger)section;
- (void)registerWithTableView:(UITableView *)tableView;
@optional

- (void)willDisplayHeader:(__kindof UIView *)header;
- (void)willDisplayCell:(__kindof UITableViewCell *)cell forIndexPath:(NSIndexPath *)indexPath;

- (void)didSelectItemAtIndex:(NSUInteger)index;

@end

上面代码中:- (void)registerWithTableView:(UITableView *)tableView 提供了一个入口供组件注册自定义的 UITableViewCell

继承自 UIViewController——这里不用 UITableViewController 是为了灵活性,比如有时候 TableView 不需要占满屏——实现一个 RTComponentController,它维护一个成员为 id<RTTableComponent> 类型的数组:

@interface RTComponentController : UIViewController <UITableViewDataSource, UITableViewDelegate>
@property (nonatomic, readonly, strong) UITableView *tableView;
@property (nonatomic, strong) NSArray <id<RTTableComponent> > *components;
- (CGRect)tableViewRectForBounds:(CGRect)bounds;
@end

然后在具体的实现中,将大部分 DatasourceDelegate 的方法转发到 components 上:


- (CGRect)tableViewRectForBounds:(CGRect)bounds
{
    return bounds;
}

#pragma mark - UITableView Datasource & Delegate

- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView
{
    return self.components.count;
}

- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section
{
    return self.components[section].numberOfItems;
}

- (CGFloat)tableView:(UITableView *)tableView heightForHeaderInSection:(NSInteger)section
{
    return self.components[section].heightForComponentHeader;
}

- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath
{
    return [self.components[indexPath.section] heightForComponentItemAtIndex:indexPath.row];
}

- (UIView *)tableView:(UITableView *)tableView viewForHeaderInSection:(NSInteger)section
{
    return [self.components[section] headerForTableView:tableView];
}

- (UITableViewCell *)tableView:(UITableView *)tableView
         cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
    return [self.components[indexPath.section] cellForTableView:tableView atIndexPath:indexPath];
}

- (void)tableView:(UITableView *)tableView
willDisplayHeaderView:(UIView *)view
       forSection:(NSInteger)section
{
    if ([self.components[section] respondsToSelector:@selector(willDisplayHeader:)]) {
        [self.components[section] willDisplayHeader:view];
    }
}

- (void)tableView:(UITableView *)tableView
  willDisplayCell:(UITableViewCell *)cell
forRowAtIndexPath:(NSIndexPath *)indexPath
{
    if ([self.components[indexPath.section] respondsToSelector:@selector(willDisplayCell:forIndexPath:)]) {
        [self.components[indexPath.section] willDisplayCell:cell
                                               forIndexPath:indexPath];
    }
}

- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath
{
    if ([self.components[indexPath.section] respondsToSelector:@selector(didSelectItemAtIndex:)]) {
        [self.components[indexPath.section] didSelectItemAtIndex:indexPath.row];
    }
}

给定一个基础实现 RTBaseComponent,没有标头,0 个 Cell:

@interface RTBaseComponent : NSObject <RTTableComponent>
@property (nonatomic, weak) id<RTTableComponentDelegate> delegate;

@property (nonatomic, strong) NSString *cellIdentifier;
@property (nonatomic, strong) NSString *headerIdentifier;

+ (instancetype)componentWithTableView:(UITableView *)tableView;
+ (instancetype)componentWithTableView:(UITableView *)tableView delegate:(id<RTTableComponentDelegate>)delegate;

- (instancetype)init NS_UNAVAILABLE;
- (instancetype)initWithTableView:(UITableView *)tableView;
- (instancetype)initWithTableView:(UITableView *)tableView delegate:(id<RTTableComponentDelegate>)delegate NS_DESIGNATED_INITIALIZER;

- (void)registerWithTableView:(UITableView *)tableView NS_REQUIRES_SUPER;
- (void)setNeedUpdateHeightForSection:(NSInteger)section;

@end


@interface RTBaseComponent ()
@property (nonatomic, weak) UITableView *tableView;
@end


@implementation RTBaseComponent

- (instancetype)initWithTableView:(UITableView *)tableView delegate:(id<RTTableComponentDelegate>)delegate
{
    self = [super init];
    if (self) {
        self.cellIdentifier = [NSString stringWithFormat:@"%@-Cell", NSStringFromClass(self.class)];
        self.headerIdentifier = [NSString stringWithFormat:@"%@-Header", NSStringFromClass(self.class)];
        self.tableView = tableView;
        self.delegate = delegate;

        [self registerWithTableView:tableView];
    }
    return self;
}

- (void)registerWithTableView:(UITableView *)tableView
{
    [tableView registerClass:[UITableViewCell class]
      forCellReuseIdentifier:self.cellIdentifier];
}

- (NSInteger)numberOfItems
{
    return 0;
}

- (CGFloat)heightForComponentHeader
{
    return 0.f;
}

- (CGFloat)heightForComponentItemAtIndex:(NSUInteger)index
{
    return 0.f;
}

......

@end

然后继承自 RTBaseComponent,实现一个有标头的组件:

@interface RTHeaderComponent : RTBaseComponent
@property (nonatomic, copy) NSString *title;
@property (nonatomic, strong) UIFont *titleFont;
@property (nonatomic, strong) UIColor *titleColor;
@property (nonatomic, strong) UIView *accessoryView;

- (CGRect)accessoryRectForBounds:(CGRect)bounds;

@end

@implementation RTHeaderComponent

- (void)registerWithTableView:(UITableView *)tableView
{
    [super registerWithTableView:tableView];
    [tableView registerClass:[UITableViewHeaderFooterView class]
forHeaderFooterViewReuseIdentifier:self.headerIdentifier];
}

- (CGFloat)heightForComponentHeader
{
    return 36.f;
}

- (__kindof UIView *)headerForTableView:(UITableView *)tableView
{
    UITableViewHeaderFooterView *header = [tableView dequeueReusableHeaderFooterViewWithIdentifier:self.headerIdentifier];
    header.textLabel.text = self.title;
    header.textLabel.textColor = self.titleColor ?: [UIColor darkGrayColor];
    self.accessoryView.frame = [self accessoryRectForBounds:header.bounds];
    [header.contentView addSubview:self.accessoryView];
    return header;
}

- (void)willDisplayHeader:(__kindof UIView *)header
{
    UITableViewHeaderFooterView *headerView = (UITableViewHeaderFooterView *)header;
    headerView.textLabel.font = self.titleFont ?: [UIFont preferredFontForTextStyle:UIFontTextStyleHeadline];
    self.accessoryView.frame = [self accessoryRectForBounds:header.bounds];
}

......

注意,上面需要在 willDisplayHeader: 中设置 textLabel 的字体(可能是苹果的 bug)

同时为了满足横滚等需求,实现一个 RTCollectionComponent,它管理一个 UICollectionView 实例,实现它的 DatasourceDelegate,提供一个入口供子类注册自定义的 UICollectionViewCell,并最终将它添加到 cell.contentView 上:

@interface RTCollectionComponent : RTActionHeaderComponent <UICollectionViewDataSource, UICollectionViewDelegate, UICollectionViewDelegateFlowLayout>
@property (nonatomic, readonly, strong) UICollectionView *collectionView;

- (void)configureCollectionView:(UICollectionView *)collectionView NS_REQUIRES_SUPER;

- (CGRect)collectionViewRectForBounds:(CGRect)bounds;

@end

结果

在 Demo 中项目自定义了四种 Component

  • RTDemoTagsComponent
  • RTDemoBannerComponent
  • RTDemoImageItemComponent
  • RTDemoItemComponent

最终实现的界面效果类似如下:


19-5.png
19-6.png

而整个 VC 的代码只是挂载了四个 Component,在其他 VC 中这些组件也可以选择性地复用,且有较高的配置灵活性:

- (void)viewDidLoad {
    [super viewDidLoad];

    RTDemoTagsComponent *tags = [RTDemoTagsComponent componentWithTableView:self.tableView
                                                                   delegate:self];
    self.components = @[tags,
                        [RTDemoImageItemComponent componentWithTableView:self.tableView
                                                                delegate:self],
                        [RTDemoBannerComponent componentWithTableView:self.tableView
                                                             delegate:self],
                        [RTDemoImageItemComponent componentWithTableView:self.tableView
                                                                delegate:self],
                        [RTDemoItemComponent componentWithTableView:self.tableView
                                                           delegate:self]];

    [tags reloadDataWithTableView:self.tableView
                        inSection:0];
}

单个 Component 的数据可以由 VC 发起请求后一起塞回,或者每个 Component 自己在 - (void)reloadDataWithTableView:inSection: 方法中请求,而 VC 负责触发一次请求,取决于具体实现与需求。

总结

一个程序员的日常无非就是在处理产品经理的各种合理非理的需求,在真正动手之前多停下来思考一下,磨刀不误砍柴功,以不变应对万变的需求。在上面这种实现中,无论临时增加或减少一个展示段,无非就是增加、减少一个 Component,修改起来没有痛苦。而如果像以前一样用 switch (indexPath.section) 的办法,不仅改起来不方便,还容易 Crash

以上所有代码匀可以在 Github 上找到,并已经发布到 Cocoapods

本文只针对 UITableView 做了简单的组件化,同样的操作可以应用到 UICollectionView 上,且更多实用,并且现在已经有开源实现:https://github.com/Instagram/IGListKit,或者 DDComponent,使用更简单。如何更全面的、完全的组件化?参考以下两个实现:HubFrameworkComponentKit

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 213,752评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,100评论 3 387
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 159,244评论 0 349
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,099评论 1 286
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,210评论 6 385
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,307评论 1 292
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,346评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,133评论 0 269
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,546评论 1 306
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,849评论 2 328
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,019评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,702评论 4 337
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,331评论 3 319
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,030评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,260评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,871评论 2 365
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,898评论 2 351

推荐阅读更多精彩内容