DZNEmptyDataSet-源码分析与仿写(二)

前言

阅读优秀的开源项目是提高编程能力的有效手段,我们能够从中开拓思维、拓宽视野,学习到很多不同的设计思想以及最佳实践。阅读他人代码很重要,但动手仿写、练习却也是很有必要的,它能进一步加深我们对项目的理解,将这些东西内化为自己的知识和能力。然而真正做起来却很不容易,开源项目阅读起来还是比较困难,需要一些技术基础和耐心。
本系列将对一些著名的iOS开源类库进行深入阅读及分析,并仿写这些类库的基本实现,加深我们对底层实现的理解和认识,提升我们iOS开发的编程技能。

DZNEmptyDataSet

DZNEmptyDataSet是UITableView/UICollectionView父类的扩展,当视图没有内容时用来显示自定义的空白页。它的效果如下:


DZNEmptyDataSet地址:https://github.com/dzenbot/DZNEmptyDataSet,这里我们选取了它早期的v1.0版,讲一下它的内部实现原理和实现过程。

实现原理

DZNEmptyDataSet通过KVO监控列表页的内容变化,当页面没有数据时,显示自定义的空白页。
DZNEmptyDataSet像UITableView一样提供数据源DataSource协议,让使用者能够完全配置空白页的显示内容和样式。

实现过程

DZNTableDataSetView

DZNTableDataSetView类是页面没内容时显示的空白页,它提供的接口属性如下,都是空白页显示的内容项。

@interface DZNTableDataSetView : UIView

//单行标题
@property (nonatomic, strong, readonly) UILabel *titleLabel;
//多行详细内容标签
@property (nonatomic, strong, readonly) UILabel *detailLabel;
//图片
@property (nonatomic, strong, readonly) UIImageView *imageView;
//按钮
@property (nonatomic, strong, readonly) UIButton *button;
//控件之间的垂直间距
@property (nonatomic, assign) CGFloat verticalSpace;

......

@end

这里的页面布局使用了原生约束。关于UI布局,更详细的介绍在后面。
这个页面的内容根据使用者的配置动态变化。比如使用者只选择了标题和详情,那么图片和按钮就要隐藏。在约束中,找出要显示的控件,调整间距,达到动态布局的目的。
- (void)updateConstraints
{
[super updateConstraints];

    [_contentView removeConstraints:_contentView.constraints];

    CGFloat width = (self.frame.size.width > 0) ? self.frame.size.width : [UIScreen mainScreen].bounds.size.width;

    NSInteger multiplier = [[UIDevice currentDevice] userInterfaceIdiom] == UIUserInterfaceIdiomPhone ? 16 : 4;
    NSNumber *padding = @(roundf(width/multiplier));
    NSNumber *imgWidth = @(roundf(_imageView.image.size.width));
    NSNumber *imgHeight = @(roundf(_imageView.image.size.height));
    NSNumber *trailing = @(roundf((width-[imgWidth floatValue])/2.0));

    NSDictionary *views = NSDictionaryOfVariableBindings(self,_contentView,_titleLabel,_detailLabel,_imageView,_button);
    NSDictionary *metrics = NSDictionaryOfVariableBindings(padding,trailing,imgWidth,imgHeight);

    if (!self.didConfigureConstraints) {
        self.didConfigureConstraints = YES;

        [self addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:@"H:[self]-(<=0)-[_contentView]"
                                                                     options:NSLayoutFormatAlignAllCenterY metrics:nil views:views]];

        [self addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:@"V:[self]-(<=0)-[_contentView]"
                                                                     options:NSLayoutFormatAlignAllCenterX metrics:nil views:views]];
    }

    [_contentView addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:@"H:|-padding-[_titleLabel]-padding-|"
                                                                         options:0 metrics:metrics views:views]];


    [_contentView addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:@"H:|-padding-[_detailLabel]-padding-|"
                                                                         options:0 metrics:metrics views:views]];


    [_contentView addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:@"H:|-padding-[_button]-padding-|"
                                                                         options:0 metrics:metrics views:views]];

    [_contentView addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:@"H:|-trailing-[_imageView(imgWidth)]-trailing-|"
                                                                         options:0 metrics:metrics views:views]];

    NSMutableString *format = [NSMutableString new];
    NSMutableArray *subviews = [NSMutableArray new];

    if (_imageView.image) [subviews addObject:@"[_imageView(imgHeight)]"];
    if (_titleLabel.attributedText.string.length > 0) [subviews addObject:@"[_titleLabel]"];
    if (_detailLabel.attributedText.string.length > 0) [subviews addObject:@"[_detailLabel]"];
    if ([_button attributedTitleForState:UIControlStateNormal].string.length > 0) [subviews addObject:@"[_button]"];

    [subviews enumerateObjectsUsingBlock:^(id obj, NSUInteger idx, BOOL *stop) {
        [format appendString:obj];
        if (idx < subviews.count-1) {
            if (_verticalSpace > 0) [format appendFormat:@"-%.f-", _verticalSpace];
            else [format appendString:@"-11-"];
        }
    }];

    if (format.length > 0) {
        [_contentView addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:[NSString stringWithFormat:@"V:|%@|", format]
                                                                             options:0 metrics:metrics views:views]];
    }
}

这个类中,属性的初始化使用了Getter和Setter方式。推荐一篇大神写的文章,其中介绍了Getter/Setter的使用和实践:iOS应用架构谈 view层的组织和调用方案,还有一个开源项目,也是用这种风格写的代码,作者是大神@ZeroJ,项目地址:https://github.com/jasnig/DouYuTVMutate,有兴趣可以去学习一下。

UITableView+DataSet

通过监控tableView的contentSize属性变化,决定是否显示自定义空白页。
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context
{
if (context == DZNContentSizeCtx)
{
NSValue *new = [change objectForKey:@"new"];
NSValue *old = [change objectForKey:@"old"];

        if (new && old && ![new isEqualToValue:old]) {
            if ([keyPath isEqualToString:kContentSize]) {
                [self didReloadData];
            }
        }
    }
    else {
        [super observeValueForKeyPath:keyPath ofObject:object change:change context:context];
    }
}

在使用者设置数据源委托时,向页面添加contentSize属性监控。
- (void)setDataSetSource:(id<ZCJTableViewDataSetDataSouce>)source
{
[self addObserver:self forKeyPath:kContentSize options:NSKeyValueObservingOptionNew|NSKeyValueObservingOptionOld|NSKeyValueObservingOptionPrior context:ZCJContentSizeCtx];

    objc_setAssociatedObject(self, kDataSetDataSource, source, OBJC_ASSOCIATION_ASSIGN);
}

这里使用对象关联技术,为这个UITableView的扩展类添加属性。关于对象关联,详细介绍在后面。
从datasource中取得使用者填写的数据,像title,detail,image等配置自定义的空白页。
- (void)reloadDataSet
{
if ([self totalNumberOfRows] == 0)
{
[self.dataSetView updateConstraintsIfNeeded];

        // Configure labels
        self.dataSetView.detailLabel.attributedText = [self detailLabelText];
        self.dataSetView.titleLabel.attributedText = [self titleLabelText];

        // Configure imageview
        self.dataSetView.imageView.image = [self image];

        // Configure button
        [self.dataSetView.button setAttributedTitle:[self buttonTitleForState:0] forState:0];
        [self.dataSetView.button setAttributedTitle:[self buttonTitleForState:1] forState:1];
        [self.dataSetView.button setBackgroundImage:[self buttonBackgroundImageForState:0] forState:0];
        [self.dataSetView.button setBackgroundImage:[self buttonBackgroundImageForState:1] forState:1];

        // Configure vertical spacing
        self.dataSetView.verticalSpace = [self verticalSpace];

        // Configure scroll permission
        self.scrollEnabled = [self isScrollAllowed];

        // Configure background color
        self.dataSetView.backgroundColor = [self dataSetBackgroundColor];
        if (self.scrollEnabled && [self dataSetBackgroundColor]) self.backgroundColor = [self dataSetBackgroundColor];

        self.dataSetView.hidden = NO;

        [self.dataSetView updateConstraints];
        [self.dataSetView layoutIfNeeded];

        [UIView animateWithDuration:0.25
                         animations:^{
                             self.dataSetView.alpha = 1.0;
                         }
                         completion:NULL];
    }
    else if ([self isDataSetVisible] && [self needsReloadSets]) {
        [self invalidateContent];
    }
}

比如空白页的标题,取得由使用者配置的数据源
- (NSAttributedString *)titleLabelText
{
if (self.dataSetSource && [self.dataSetSource respondsToSelector:@selector(titleForTableViewDataSet:)]) {
return [self.dataSetSource titleForTableViewDataSet:self];
}
return nil;
}

基础知识

UI布局

对于iOS UI布局方式,一般有四种。分别是:IB布局、手写Frame布局、代码原生约束布局以及以Masonry为代表的第三方布局类库。
IB布局是在XIB或StoryBoard上对页面控件布局,IB布局能够直观、方便地调整界面元素的关系,开发效率比较高。但对于一些动态展示、定制的页面,代码逻辑不够清晰。
原生约束布局是用NSLayoutConstraint控制UI。优点是:灵活,不依赖上层。缺点是不够直观,不方便,代码量大,不易维护。
Masonry等第三方框架布局,优点是代码优雅,可读性强,功能比原生约束更强大,缺点是会造成UI布局的依赖,比如自定义的view,放到其他app中,Masonry也要带进来。
因此,更好的布局选择要根据具体情况选择。业务型简单页面选用IB布局方式,业务型复杂页面像动态页面或定制视图选用Masonry第三方。自定义view选用原生约束。

对象关联(associated objects)

对象关联(associated objects)是Objective-C 2.0的一个特性,它允许开发者为已存在的类的扩展添加自定义属性,这几乎弥补了Objective-C的最大缺点。
常用的两个函数:

void objc_setAssociatedObject(id object, const void *key, id value, objc_AssociationPolicy policy)
id objc_getAssociatedObject(id object, const void *key)

object是关联的对象,key是属性关键字,value是属性内容,policy是关联对象的行为,比如强引用非原子化的OBJC_ASSOCIATION_RETAIN_NONATOMIC。_
举个例子,为UIButton添加一个扩展,实现block回调按钮点击事件:
.h文件
#import <UIKit/UIKit.h>
typedef void (^btnBlock)();

@interface UIButton (Block)
- (void)handelWithBlock:(btnBlock)block;
@end

.m文件
#import "UIButton+Block.h"
#import <objc/runtime.h>

static const char btnKey;

@implementation UIButton (Block)

- (void)handelWithBlock:(btnBlock)block
{
    if (block)
    {
        objc_setAssociatedObject(self, &btnKey, block, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
    }

    [self addTarget:self action:@selector(btnAction) forControlEvents:UIControlEventTouchUpInside];
}

- (void)btnAction
{
    btnBlock block = objc_getAssociatedObject(self, &btnKey);
    block();
}

@end

仿写DZNEmptyDataSet

下面我们自己练习仿写这个类库,以加深我们对它内部实现的理解和掌握。为了简单起见,我们只实现基本的功能,一些细节都忽略掉了。
ZCJTableDataSetView类,没有数据时的空白页,为了简化操作和细节,这里只添加三个属性。

@interface ZCJTableDataSetView : UIView

@property (nonatomic, strong) UILabel *titleLbl;
@property (nonatomic, strong) UILabel *detailLbl;
@property (nonatomic, strong) UIImageView *imgView;

@end

动态的内容页,使用原生约束布局,控制控件在垂直方向上的显示以及间距。

NSMutableString *format = [NSMutableString new];
    NSMutableArray *subviews = [NSMutableArray new];
    
    if (_imgView.image) [subviews addObject:@"[_imgView(100)]"];
    if (_titleLbl.attributedText.string.length > 0) [subviews addObject:@"[_titleLbl]"];
    if (_detailLbl.attributedText.string.length > 0) [subviews addObject:@"[_detailLbl]"];
    
    [subviews enumerateObjectsUsingBlock:^(id obj, NSUInteger idx, BOOL *stop) {
        [format appendString:obj];
        if (idx < subviews.count-1) {
            [format appendString:@"-11-"];
        }
    }];
    
    if (format.length > 0) {
        [_contentView addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:[NSString stringWithFormat:@"V:|%@|", format]
                                                                             options:0 metrics:metrics views:views]];
    }

UITableView+DataSet

创建一个数据源协议,用于向空白页提供配置数据。

@protocol ZCJTableViewDataSetDataSouce <NSObject>

- (NSAttributedString *)titleForTableViewDataSet:(UITableView *)tableView;

- (NSAttributedString *)detailForTableViewDataSet:(UITableView *)tableView;

- (UIImage *)imageForTableViewDataSet:(UITableView *)tableView;

@end

在协议对象设置时,添加对tableView的contentSize属性监控。
- (void)setDataSetSource:(id<ZCJTableViewDataSetDataSouce>)source
{
[self addObserver:self forKeyPath:kContentSize options:NSKeyValueObservingOptionNew|NSKeyValueObservingOptionOld|NSKeyValueObservingOptionPrior context:ZCJContentSizeCtx];

    objc_setAssociatedObject(self, kDataSetDataSource, source, OBJC_ASSOCIATION_ASSIGN);
}

在接收到contentSize变化时,重新加载页面,配置并将空白页显示出来。

-(void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSString *,id> *)change context:(void *)context {
    if (context == ZCJContentSizeCtx) {
        NSValue *new = [change objectForKey:@"new"];
        NSValue *old = [change objectForKey:@"old"];
        if (new && old && ![new isEqualToValue:old]) {
            if ([keyPath isEqualToString: kContentSize]) {
                [self reloadDataSet];
            }
        }
    }
    else {
        [super observeValueForKeyPath:keyPath ofObject:object change:change context:context];
    }
}

- (void)reloadDataSet {
    if (self.dataSource && [self totalRows] == 0) {
        
        [self.dataSetView updateConstraintsIfNeeded];
        
        
        self.dataSetView.titleLbl.attributedText = [self titleLableText];
        self.dataSetView.detailLbl.attributedText = [self detailLableText];
        self.dataSetView.imgView.image = [self image];
        
        self.dataSetView.hidden = NO;
        self.dataSetView.alpha = 1;
        
        [self.dataSetView updateConstraints];
        [self.dataSetView layoutIfNeeded];
    }
}

仿写的ZCJEmptyDataSet的项目地址:https://github.com/superzcj/ZCJEmptyDataSet

总结

ZCJEmptyDataSet实现还挺顺利的,只是在原生约束上花费了一些时间。原生约束不依赖上层,所以很多开源库都采用它进行UI布局,如MBProcessHUD、SWTableViewCell等。学习掌握原生约束还是很有必要的,至少我们用原生约束写自定义view不依赖其他库,封装性更好。
最后,大家有什么意见或建议,都可以给我留言或联系我。

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

推荐阅读更多精彩内容