DZNEmptyDataSet-源碼分析與仿寫(二)

前言

閱讀優(yōu)秀的開源項目是提高編程能力的有效手段,我們能夠從中開拓思維豆村、拓寬視野液兽,學習到很多不同的設計思想以及最佳實踐。閱讀他人代碼很重要掌动,但動手仿寫四啰、練習卻也是很有必要的,它能進一步加深我們對項目的理解粗恢,將這些東西內(nèi)化為自己的知識和能力柑晒。然而真正做起來卻很不容易,開源項目閱讀起來還是比較困難眷射,需要一些技術基礎和耐心匙赞。
本系列將對一些著名的iOS開源類庫進行深入閱讀及分析,并仿寫這些類庫的基本實現(xiàn)妖碉,加深我們對底層實現(xiàn)的理解和認識罚屋,提升我們iOS開發(fā)的編程技能。

DZNEmptyDataSet

DZNEmptyDataSet是UITableView/UICollectionView父類的擴展嗅绸,當視圖沒有內(nèi)容時用來顯示自定義的空白頁脾猛。它的效果如下:


DZNEmptyDataSet地址:https://github.com/dzenbot/DZNEmptyDataSet,這里我們選取了它早期的v1.0版鱼鸠,講一下它的內(nèi)部實現(xiàn)原理和實現(xiàn)過程猛拴。

實現(xiàn)原理

DZNEmptyDataSet通過KVO監(jiān)控列表頁的內(nèi)容變化,當頁面沒有數(shù)據(jù)時蚀狰,顯示自定義的空白頁愉昆。
DZNEmptyDataSet像UITableView一樣提供數(shù)據(jù)源DataSource協(xié)議,讓使用者能夠完全配置空白頁的顯示內(nèi)容和樣式麻蹋。

實現(xiàn)過程

DZNTableDataSetView

DZNTableDataSetView類是頁面沒內(nèi)容時顯示的空白頁跛溉,它提供的接口屬性如下,都是空白頁顯示的內(nèi)容項扮授。

@interface DZNTableDataSetView : UIView

//單行標題
@property (nonatomic, strong, readonly) UILabel *titleLabel;
//多行詳細內(nèi)容標簽
@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布局,更詳細的介紹在后面刹勃。
這個頁面的內(nèi)容根據(jù)使用者的配置動態(tài)變化堪侯。比如使用者只選擇了標題和詳情,那么圖片和按鈕就要隱藏荔仁。在約束中伍宦,找出要顯示的控件芽死,調(diào)整間距,達到動態(tài)布局的目的次洼。
- (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層的組織和調(diào)用方案坪哄,還有一個開源項目,也是用這種風格寫的代碼势篡,作者是大神@ZeroJ翩肌,項目地址:https://github.com/jasnig/DouYuTVMutate,有興趣可以去學習一下禁悠。

UITableView+DataSet

通過監(jiān)控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];
    }
}

在使用者設置數(shù)據(jù)源委托時碍侦,向頁面添加contentSize屬性監(jiān)控眷细。
- (void)setDataSetSource:(id<ZCJTableViewDataSetDataSouce>)source
{
[self addObserver:self forKeyPath:kContentSize options:NSKeyValueObservingOptionNew|NSKeyValueObservingOptionOld|NSKeyValueObservingOptionPrior context:ZCJContentSizeCtx];

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

這里使用對象關聯(lián)技術往堡,為這個UITableView的擴展類添加屬性抑堡。關于對象關聯(lián)按摘,詳細介紹在后面。
從datasource中取得使用者填寫的數(shù)據(jù)濒旦,像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];
    }
}

比如空白頁的標題晾剖,取得由使用者配置的數(shù)據(jù)源
- (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布局能夠直觀、方便地調(diào)整界面元素的關系炎疆,開發(fā)效率比較高卡骂。但對于一些動態(tài)展示、定制的頁面磷雇,代碼邏輯不夠清晰偿警。
原生約束布局是用NSLayoutConstraint控制UI。優(yōu)點是:靈活唯笙,不依賴上層螟蒸。缺點是不夠直觀,不方便崩掘,代碼量大七嫌,不易維護。
Masonry等第三方框架布局苞慢,優(yōu)點是代碼優(yōu)雅诵原,可讀性強,功能比原生約束更強大挽放,缺點是會造成UI布局的依賴绍赛,比如自定義的view,放到其他app中辑畦,Masonry也要帶進來吗蚌。
因此,更好的布局選擇要根據(jù)具體情況選擇纯出。業(yè)務型簡單頁面選用IB布局方式蚯妇,業(yè)務型復雜頁面像動態(tài)頁面或定制視圖選用Masonry第三方。自定義view選用原生約束暂筝。

對象關聯(lián)(associated objects)

對象關聯(lián)(associated objects)是Objective-C 2.0的一個特性箩言,它允許開發(fā)者為已存在的類的擴展添加自定義屬性,這幾乎彌補了Objective-C的最大缺點焕襟。
常用的兩個函數(shù):

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

object是關聯(lián)的對象陨收,key是屬性關鍵字,value是屬性內(nèi)容鸵赖,policy是關聯(lián)對象的行為畏吓,比如強引用非原子化的OBJC_ASSOCIATION_RETAIN_NONATOMIC。_
舉個例子卫漫,為UIButton添加一個擴展菲饼,實現(xiàn)block回調(diào)按鈕點擊事件:
.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

下面我們自己練習仿寫這個類庫,以加深我們對它內(nèi)部實現(xiàn)的理解和掌握列赎。為了簡單起見宏悦,我們只實現(xiàn)基本的功能,一些細節(jié)都忽略掉了包吝。
ZCJTableDataSetView類饼煞,沒有數(shù)據(jù)時的空白頁,為了簡化操作和細節(jié)诗越,這里只添加三個屬性砖瞧。

@interface ZCJTableDataSetView : UIView

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

@end

動態(tài)的內(nèi)容頁,使用原生約束布局嚷狞,控制控件在垂直方向上的顯示以及間距块促。

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

創(chuàng)建一個數(shù)據(jù)源協(xié)議荣堰,用于向空白頁提供配置數(shù)據(jù)。

@protocol ZCJTableViewDataSetDataSouce <NSObject>

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

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

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

@end

在協(xié)議對象設置時竭翠,添加對tableView的contentSize屬性監(jiān)控振坚。
- (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實現(xiàn)還挺順利的,只是在原生約束上花費了一些時間传货。原生約束不依賴上層屎鳍,所以很多開源庫都采用它進行UI布局,如MBProcessHUD问裕、SWTableViewCell等逮壁。學習掌握原生約束還是很有必要的,至少我們用原生約束寫自定義view不依賴其他庫僻澎,封裝性更好貌踏。
最后,大家有什么意見或建議窟勃,都可以給我留言或聯(lián)系我祖乳。

最后編輯于
?著作權歸作者所有,轉載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市秉氧,隨后出現(xiàn)的幾起案子眷昆,更是在濱河造成了極大的恐慌,老刑警劉巖汁咏,帶你破解...
    沈念sama閱讀 221,635評論 6 515
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件亚斋,死亡現(xiàn)場離奇詭異,居然都是意外死亡攘滩,警方通過查閱死者的電腦和手機帅刊,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 94,543評論 3 399
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來漂问,“玉大人赖瞒,你說我怎么就攤上這事≡榧伲” “怎么了栏饮?”我有些...
    開封第一講書人閱讀 168,083評論 0 360
  • 文/不壞的土叔 我叫張陵,是天一觀的道長磷仰。 經(jīng)常有香客問我袍嬉,道長,這世上最難降的妖魔是什么灶平? 我笑而不...
    開封第一講書人閱讀 59,640評論 1 296
  • 正文 為了忘掉前任伺通,我火速辦了婚禮箍土,結果婚禮上,老公的妹妹穿的比我還像新娘泵殴。我一直安慰自己涮帘,他們只是感情好拼苍,可當我...
    茶點故事閱讀 68,640評論 6 397
  • 文/花漫 我一把揭開白布笑诅。 她就那樣靜靜地躺著,像睡著了一般疮鲫。 火紅的嫁衣襯著肌膚如雪吆你。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 52,262評論 1 308
  • 那天俊犯,我揣著相機與錄音妇多,去河邊找鬼。 笑死燕侠,一個胖子當著我的面吹牛者祖,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播绢彤,決...
    沈念sama閱讀 40,833評論 3 421
  • 文/蒼蘭香墨 我猛地睜開眼七问,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了茫舶?” 一聲冷哼從身側響起械巡,我...
    開封第一講書人閱讀 39,736評論 0 276
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎饶氏,沒想到半個月后讥耗,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 46,280評論 1 319
  • 正文 獨居荒郊野嶺守林人離奇死亡疹启,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 38,369評論 3 340
  • 正文 我和宋清朗相戀三年古程,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片喊崖。...
    茶點故事閱讀 40,503評論 1 352
  • 序言:一個原本活蹦亂跳的男人離奇死亡挣磨,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出贷祈,到底是詐尸還是另有隱情趋急,我是刑警寧澤,帶...
    沈念sama閱讀 36,185評論 5 350
  • 正文 年R本政府宣布势誊,位于F島的核電站呜达,受9級特大地震影響,放射性物質發(fā)生泄漏粟耻。R本人自食惡果不足惜查近,卻給世界環(huán)境...
    茶點故事閱讀 41,870評論 3 333
  • 文/蒙蒙 一眉踱、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧霜威,春花似錦谈喳、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,340評論 0 24
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至大猛,卻和暖如春扭倾,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背挽绩。 一陣腳步聲響...
    開封第一講書人閱讀 33,460評論 1 272
  • 我被黑心中介騙來泰國打工膛壹, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人唉堪。 一個月前我還...
    沈念sama閱讀 48,909評論 3 376
  • 正文 我出身青樓模聋,卻偏偏與公主長得像,于是被迫代替她去往敵國和親唠亚。 傳聞我的和親對象是個殘疾皇子链方,可洞房花燭夜當晚...
    茶點故事閱讀 45,512評論 2 359

推薦閱讀更多精彩內(nèi)容