源碼剖析--SVPullToRefresh

閱讀源碼之路終於開啓了, 小白一枚, 大神們要多多照顧啊, 有什麼建議能夠評論或私信, 在此多謝了!!!!css

原文: http://www.jianshu.com/p/05af...html


概要

文件結構

文章目錄
  • 前言
  • API說明
  • 原理解析
  • 總結

1.前言

做爲一個刷新框架, SVPullToRefresh以其簡潔, 通俗易懂爲你們所推崇. 對於剛開始讀源碼的我來講, 再合適不過了, 並且最近正在作一個刷新demo, 用到, 順便整理一下, 學習學習.
SV是個熟悉的前綴, 就算沒聽過SVPullToRefresh, 也聽過SVProgressHUD吧. 除了這些, 做者Sam還有其餘優秀的開源代碼, 你們感興趣能夠看看.git


2.API說明

2.1 下拉刷新

下拉刷新ScrollViewgithub

@class SVPullToRefreshView;
@interface UIScrollView (SVPullToRefresh)

typedef NS_ENUM(NSUInteger, SVPullToRefreshPosition) {
    SVPullToRefreshPositionTop = 0,
    SVPullToRefreshPositionBottom,
};

//默認添加方法, position爲top
- (void)addPullToRefreshWithActionHandler:(void (^)(void))actionHandler;

/*
  自定義添加下拉刷新的方法, 能夠改變刷新方式; 
  top爲下拉刷新, bottom爲上拉刷新;
*/
- (void)addPullToRefreshWithActionHandler:(void (^)(void))actionHandler position:(SVPullToRefreshPosition)position;

//觸發一次刷新, 會執行handler這個block裏面的方法
- (void)triggerPullToRefresh;

//下拉刷新視圖
@property (nonatomic, strong, readonly) SVPullToRefreshView *pullToRefreshView;

//是否展現下拉刷新視圖(須在addPullToRefreshWithActionHandler:方法後面)
@property (nonatomic, assign) BOOL showsPullToRefresh;

@end

下拉刷新Viewobjective-c

//只保留可更改選項

@interface SVPullToRefreshView : UIView
//下拉刷新箭頭顏色
@property (nonatomic, strong) UIColor *arrowColor;
//文本顏色
@property (nonatomic, strong) UIColor *textColor;
//指示器view顏色
@property (nonatomic, strong, readwrite) UIColor *activityIndicatorViewColor NS_AVAILABLE_IOS(5_0);
//指示器類型
@property (nonatomic, readwrite) UIActivityIndicatorViewStyle activityIndicatorViewStyle;

//根據刷新狀態設置標題
- (void)setTitle:(NSString *)title forState:(SVPullToRefreshState)state;
//根據刷新狀態設置副標題
- (void)setSubtitle:(NSString *)subtitle forState:(SVPullToRefreshState)state;
//根據刷新狀態設置自定義View
- (void)setCustomView:(UIView *)view forState:(SVPullToRefreshState)state;

//開始動畫
- (void)startAnimating;
//結束動畫
- (void)stopAnimating;

//最後更新日期(NSDate)
@property (nonatomic, strong) NSDate *lastUpdatedDate DEPRECATED_ATTRIBUTE;
//日期格式(NSDateFormatter)
@property (nonatomic, strong) NSDateFormatter *dateFormatter DEPRECATED_ATTRIBUTE;

@end

?是我本身測試的, 把全部屬性玩了一遍, 親測好用, O(∩_∩)O哈哈~
下拉測試效果圖數組

[self.tableView addPullToRefreshWithActionHandler:^{
       //下拉刷新數據
    }];
    self.tableView.pullToRefreshView.backgroundColor = RedColor;
    self.tableView.pullToRefreshView.arrowColor = [UIColor whiteColor];
    self.tableView.pullToRefreshView.textColor = [UIColor whiteColor];
    [self.tableView.pullToRefreshView setSubtitle:@"火之玉" forState:SVInfiniteScrollingStateLoading];
    [self.tableView.pullToRefreshView setTitle:@"正在加載..waiting.." forState:SVInfiniteScrollingStateLoading];

    self.tableView.pullToRefreshView.activityIndicatorViewColor = BlueColor;
    self.tableView.pullToRefreshView.activityIndicatorViewStyle = UIActivityIndicatorViewStyleWhiteLarge;
    
//    UIView *pullView = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 50, 50)];
//    pullView.backgroundColor = [UIColor cyanColor];
//    [self.tableView.pullToRefreshView setCustomView:pullView forState:SVPullToRefreshStateAll];

2.2 上拉刷新

上拉刷新ScrollViewapp

@class SVInfiniteScrollingView;

@interface UIScrollView (SVInfiniteScrolling)
//默認添加上拉刷新視圖
- (void)addInfiniteScrollingWithActionHandler:(void (^)(void))actionHandler;
////觸發一次刷新, 會執行handler這個block裏面的方法
- (void)triggerInfiniteScrolling;
//上拉刷新視圖
@property (nonatomic, strong, readonly) SVInfiniteScrollingView *infiniteScrollingView;
//是否展現上拉刷新視圖
@property (nonatomic, assign) BOOL showsInfiniteScrolling;

@end

上拉刷新View框架

@interface SVInfiniteScrollingView : UIView
//指示器類型
@property (nonatomic, readwrite) UIActivityIndicatorViewStyle activityIndicatorViewStyle;
//刷新狀態
@property (nonatomic, readonly) SVInfiniteScrollingState state;
//是否取消上拉加載
@property (nonatomic, readwrite) BOOL enabled;
//根據刷新狀態設置自定義View
- (void)setCustomView:(UIView *)view forState:(SVInfiniteScrollingState)state;
//開始動畫
- (void)startAnimating;
//結束動畫
- (void)stopAnimating;

@end

附上測試效果:
上拉測試效果圖ide

// setup infinite scrolling
    [self.tableView addInfiniteScrollingWithActionHandler:^{
        //上拉刷新數據
    }];
    
    self.tableView.infiniteScrollingView.backgroundColor = BlueColor;
    self.tableView.infiniteScrollingView.activityIndicatorViewStyle = UIActivityIndicatorViewStyleWhite;

//    UIImageView *pullImageView = [[UIImageView alloc] initWithFrame:CGRectMake(0, 0, 50, 50)];
//    pullImageView.image = [UIImage imageNamed:@"avatar"];
//    pullImageView.layer.cornerRadius = 25;
//    pullImageView.layer.masksToBounds = YES;
//    [self.tableView.infiniteScrollingView setCustomView:pullImageView forState:SVPullToRefreshStateAll];
//
//    self.tableView.infiniteScrollingView.enabled = NO;

3.原理解析

3.1 下拉刷新

下拉刷新流程圖

以上是下拉刷新的主要流程圖, 接下來咱們就來扣扣細節;post

3.1.1 -(void)triggerPullToRefresh 觸發了一次刷新:

如下是方法的內部實現;

- (void)triggerPullToRefresh {
    self.pullToRefreshView.state = SVPullToRefreshStateTriggered;
    [self.pullToRefreshView startAnimating];
}

看了一眼, 當時就懵了; 怎麼就這點兒代碼, 徹底看不出來啊, 彆着急, 接着一個個點進去看. 發現state屬性的setter方法裏面作了處理;

- (void)setState:(SVPullToRefreshState)newState {
    
    if(_state == newState)
        return;
    
    SVPullToRefreshState previousState = _state;
    _state = newState;
    
    [self setNeedsLayout];
    [self layoutIfNeeded];
    
    switch (newState) {
        case SVPullToRefreshStateAll:
        case SVPullToRefreshStateStopped:
            [self resetScrollViewContentInset];
            break;
            
        case SVPullToRefreshStateTriggered:
            break;
            
        case SVPullToRefreshStateLoading:
            [self setScrollViewContentInsetForLoading];
            
            if(previousState == SVPullToRefreshStateTriggered && pullToRefreshActionHandler)
                pullToRefreshActionHandler();
            break;
    }
}

接下來拆分一下:

self.pullToRefreshView.state = SVPullToRefreshStateTriggered;

執行完這步代碼, 執行一次-(void)setState:, 以後break跳出;

[self.pullToRefreshView startAnimating];

這步pullToRefreshView執行-(void)startAnimating, 方法內部實現以下:

- (void)startAnimating{
    ...
    self.state = SVPullToRefreshStateLoading;
}

能夠看出以後又執行了一次-(void)setState:, 這時previousState == SVPullToRefreshStateTriggered 條件知足, 執行infiniteScrollingHandler(), 也就執行了block裏面刷新數據的方法;

3.1.2 利用runtime+KVO添加成員變量

代碼以下:

static char UIScrollViewPullToRefreshView;
- (void)setPullToRefreshView:(SVPullToRefreshView *)pullToRefreshView {
    [self willChangeValueForKey:@"SVPullToRefreshView"];
    objc_setAssociatedObject(self, &UIScrollViewPullToRefreshView,
                             pullToRefreshView,
                             OBJC_ASSOCIATION_ASSIGN);
    [self didChangeValueForKey:@"SVPullToRefreshView"];
}

- (SVPullToRefreshView *)pullToRefreshView {
    return objc_getAssociatedObject(self, &UIScrollViewPullToRefreshView);
}

關於KVO:
從代碼中能夠看出willChangeValueForKey :didChangeValueForKey :是KVO的一部分, 源文件的代碼爲:

@interface NSObject(NSKeyValueObserverNotification)
- (void)willChangeValueForKey:(NSString *)key;
- (void)didChangeValueForKey:(NSString *)key;
@end

用了這兩個方法也就表明手動觸發了KVO, 這也爲了控制回調的調用時機, 在setPullToRefreshView:中觸發. 而手動觸發的場景通常是不使用屬性,或重寫了setter,須要手動通知系統.
通常咱們是不須要用的, 好比@property 寫一個屬性, 系統會以某種方式在中間插入 wilChangeValueForKey: 、 didChangeValueForKey: 和 observeValueForKeyPath:ofObject:change:context: 的調用.
想要了解更多, 能夠看一下
KVO Programming Guide - Apple官方文檔

關於runtime:
這裏要知道這兩個方法:

//set
objc_setAssociatedObject(id _Nonnull object, const void * _Nonnull key,
                         id _Nullable value, objc_AssociationPolicy policy)
//get
objc_getAssociatedObject(id _Nonnull object, const void * _Nonnull key)

//objc_AssociationPolicy類型說明
//關聯時採用的協議,有assign,retain,copy等協議,通常使用OBJC_ASSOCIATION_RETAIN_NONATOMIC
typedef OBJC_ENUM(uintptr_t, objc_AssociationPolicy) {
    OBJC_ASSOCIATION_ASSIGN = 0,           /**< Specifies a weak reference to the associated object. */
    OBJC_ASSOCIATION_RETAIN_NONATOMIC = 1, /**< Specifies a strong reference to the associated object. 
                                            *   The association is not made atomically. */
    OBJC_ASSOCIATION_COPY_NONATOMIC = 3,   /**< Specifies that the associated object is copied. 
                                            *   The association is not made atomically. */
    OBJC_ASSOCIATION_RETAIN = 01401,       /**< Specifies a strong reference to the associated object.
                                            *   The association is made atomically. */
    OBJC_ASSOCIATION_COPY = 01403          /**< Specifies that the associated object is copied.
                                            *   The association is made atomically. */
};

看完全部API, 其實也瞭解的差很少, 這樣就給scrollView增長了一個SVPullToRefreshView類型的屬性;

3.1.3 監聽探究

這裏主要涉及scrollView的三個監聽, contentOffset, contentSize, frame;
frame就不用說了, 說一下另外兩個;
contentOffset是scrollview當前顯示區域頂點相對於frame頂點的偏移量。能夠理解爲contentview的頂點相對於scrollerVIew的frame的偏移量;
contentSize是scrollview當前全部內容區域的大小;
順便提下contentInset, 下面用到, 表示contentView.frame與scrollerView.frame的關係, 能夠類比於css裏的padding.
例如:

testScrollView.contentInset = UIEdgeInsetsMake(10, 10, 10, 10);

則testScrollView的top, left, bottom, right爲10;

好了, 如今到重頭戲了, 監聽如何執行的, 代碼以下:

#pragma mark - Observing
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context {
    if([keyPath isEqualToString:@"contentOffset"])
        [self scrollViewDidScroll:[[change valueForKey:NSKeyValueChangeNewKey] CGPointValue]];
    else if([keyPath isEqualToString:@"contentSize"]) {
        [self layoutSubviews];
        CGFloat yOrigin;
        ...
        self.frame = CGRectMake(0, yOrigin, self.bounds.size.width, SVPullToRefreshViewHeight);
    }
    else if([keyPath isEqualToString:@"frame"])
        [self layoutSubviews];

}

能夠理解爲當監聽contentOffset 改變時, scrollView滾動, 此時執行scrollViewDidScroll:方法獲得此時的滾動state; 當爲contentSize frame時, scrollView視圖發生變化, 此時執行layoutSubviews從新加載視圖, 包括根據狀態改變視圖樣式, 都在這裏面執行;
scrollViewDidScroll:方法:

- (void)scrollViewDidScroll:(CGPoint)contentOffset {
    if(self.state != SVPullToRefreshStateLoading) {
        CGFloat scrollOffsetThreshold = 0;
        switch (self.position) {
            case SVPullToRefreshPositionTop:
                scrollOffsetThreshold = self.frame.origin.y - self.originalTopInset;
                break;
            case SVPullToRefreshPositionBottom:
                scrollOffsetThreshold = MAX(self.scrollView.contentSize.height - self.scrollView.bounds.size.height, 0.0f) + self.bounds.size.height + self.originalBottomInset;
                break;
        }
        
        if(!self.scrollView.isDragging && self.state == SVPullToRefreshStateTriggered)
            self.state = SVPullToRefreshStateLoading;
        else if(contentOffset.y < scrollOffsetThreshold && self.scrollView.isDragging && self.state == SVPullToRefreshStateStopped && self.position == SVPullToRefreshPositionTop)
            self.state = SVPullToRefreshStateTriggered;
        else if(contentOffset.y >= scrollOffsetThreshold && self.state != SVPullToRefreshStateStopped && self.position == SVPullToRefreshPositionTop)
            self.state = SVPullToRefreshStateStopped;
        else if(contentOffset.y > scrollOffsetThreshold && self.scrollView.isDragging && self.state == SVPullToRefreshStateStopped && self.position == SVPullToRefreshPositionBottom)
            self.state = SVPullToRefreshStateTriggered;
        else if(contentOffset.y <= scrollOffsetThreshold && self.state != SVPullToRefreshStateStopped && self.position == SVPullToRefreshPositionBottom)
            self.state = SVPullToRefreshStateStopped;
    } else {
        CGFloat offset;
        UIEdgeInsets contentInset;
        switch (self.position) {
            case SVPullToRefreshPositionTop:
                ...
                self.scrollView.contentInset = UIEdgeInsetsMake(offset, contentInset.left, contentInset.bottom, contentInset.right);
                break;
            case SVPullToRefreshPositionBottom:
                if (self.scrollView.contentSize.height >= self.scrollView.bounds.size.height) {
                    ...
                    self.scrollView.contentInset = UIEdgeInsetsMake(contentInset.top, contentInset.left, offset, contentInset.right);
                } else if (self.wasTriggeredByUser) {
                    ...
                    self.scrollView.contentInset = UIEdgeInsetsMake(-offset, contentInset.left, contentInset.bottom, contentInset.right);
                }
                break;
        }
    }
}

能夠看出根據postion位置, state是SVPullToRefreshStateLoading狀態的時候, 改變scrollView的contentInset;非該狀態的時候, 根據contentOffset和postion設置state;

下拉刷新後, scrollView的內容高度下移60; 固然只是內容高度, 整個scrollView仍是全屏的, 對比css中padding理解一下;
注:
SVPullToRefreshView向右移動一點兒距離, 方便看視圖層級;

layoutSubviews方法:

- (void)layoutSubviews {
    
    for(id otherView in self.viewForState) {
        if([otherView isKindOfClass:[UIView class]])
           //從父視圖剝離
            [otherView removeFromSuperview];
    }
    
    id customView = [self.viewForState objectAtIndex:self.state];
    BOOL hasCustomView = [customView isKindOfClass:[UIView class]];
    
    self.titleLabel.hidden = hasCustomView;
    self.subtitleLabel.hidden = hasCustomView;
    self.arrow.hidden = hasCustomView;
    
    if(hasCustomView) {
      //添加customView
        [self addSubview:customView];
        ...
        [customView setFrame:CGRectMake(origin.x, origin.y, viewBounds.size.width, viewBounds.size.height)];
    }
    else {
   //根據state旋轉arrowView
        switch (self.state) {
            case SVPullToRefreshStateAll:
            case SVPullToRefreshStateStopped:
                self.arrow.alpha = 1;
                [self.activityIndicatorView stopAnimating];
                switch (self.position) {
                    case SVPullToRefreshPositionTop:
                        [self rotateArrow:0 hide:NO];
                        break;
                    case SVPullToRefreshPositionBottom:
                        [self rotateArrow:(float)M_PI hide:NO];
                        break;
                }
                break;
                
            ...
        }
        
        CGFloat leftViewWidth = MAX(self.arrow.bounds.size.width,self.activityIndicatorView.bounds.size.width);
        
        ...
        CGFloat labelX = (self.bounds.size.width / 2) - (totalMaxWidth / 2) + leftViewWidth + margin;
        
        if(subtitleSize.height > 0){
            ...
            self.titleLabel.frame = CGRectIntegral(CGRectMake(labelX, titleY, titleSize.width, titleSize.height));
            self.subtitleLabel.frame = CGRectIntegral(CGRectMake(labelX, titleY + titleSize.height + marginY, subtitleSize.width, subtitleSize.height));
        }else{
            ...
            self.titleLabel.frame = CGRectIntegral(CGRectMake(labelX, titleY, titleSize.width, titleSize.height));
            self.subtitleLabel.frame = CGRectIntegral(CGRectMake(labelX, titleY + titleSize.height + marginY, subtitleSize.width, subtitleSize.height));
        }
        
        CGFloat arrowX = (self.bounds.size.width / 2) - (totalMaxWidth / 2) + (leftViewWidth - self.arrow.bounds.size.width) / 2;
        self.arrow.frame = CGRectMake(arrowX,
                                      (self.bounds.size.height / 2) - (self.arrow.bounds.size.height / 2),
                                      self.arrow.bounds.size.width,
                                      self.arrow.bounds.size.height);
        self.activityIndicatorView.center = self.arrow.center;
    }
}

self.viewForState爲一個可變數組, 裏面是並且根據狀態裝入相應state的customView, 首先從經過- (void)setCustomView:forState:方法添加後removeFromSuperview 從父視圖剝離, 以後根據是否傳入了customView決定是否添加自定義視圖; 若是沒有customView則改變裏面arrowView的角度;最後都得改變titleLabel, subtitleLabel, arrow, activityIndicatorView的尺寸或位置;
值得注意的是裏面的一個方法暴露了demo的年紀, O(∩_∩)O哈哈~

- (CGSize)sizeWithFont:(UIFont *)font constrainedToSize:(CGSize)size lineBreakMode:(NSLineBreakMode)lineBreakMode NS_DEPRECATED_IOS(2_0, 7_0, "Use -boundingRectWithSize:options:attributes:context:") __TVOS_PROHIBITED;

7.0以後已取消, 難怪我感受沒見過這個方法. 哎, 仍是太年輕~

3.2 上拉刷新

話很少說, 先看東西...(此話出自老羅語錄?)
上拉刷新流程圖

- (void)setState:(SVInfiniteScrollingState)newState {
    
    if(_state == newState)
        return;
    
    SVInfiniteScrollingState previousState = _state;
    _state = newState;
    
    for(id otherView in self.viewForState) {
        if([otherView isKindOfClass:[UIView class]])
            [otherView removeFromSuperview];
    }
    
    id customView = [self.viewForState objectAtIndex:newState];
    BOOL hasCustomView = [customView isKindOfClass:[UIView class]];
    
    if(hasCustomView) {
        [self addSubview:customView];
        ...
        [customView setFrame:CGRectMake(origin.x, origin.y, viewBounds.size.width, viewBounds.size.height)];
    }
    else {
        ...
        [self.activityIndicatorView setFrame:CGRectMake(origin.x, origin.y, viewBounds.size.width, viewBounds.size.height)];
        //根據狀態設置activityIndicatorView是否動畫
        switch (newState) {
            case SVInfiniteScrollingStateStopped:
                [self.activityIndicatorView stopAnimating];
                break;
            ...
        }
    }
    
    if(previousState == SVInfiniteScrollingStateTriggered && newState == SVInfiniteScrollingStateLoading && self.infiniteScrollingHandler && self.enabled)
        self.infiniteScrollingHandler();
}

上拉刷新相對於下拉, 少了不少東西, 也就簡單了一些, 重複性的就很少說了.值得注意的是, 對比下拉, 上拉把layoutSubviews裏面東西放到setState:裏面. 也是, 畢竟沒多少東西, 對比着下拉刷新來看;

4.總結

此次閱讀源碼,能夠說收穫滿滿啊。之前以爲源碼閱讀是個比較枯燥的過程,但是當我把一個個問題解決了以後,成就感也慢慢累積,感受就是越讀越來勁兒。並且發現讀一遍是遠遠不夠的,每看了一遍都多少會有些收穫。慢慢的從(這個方法是幹什麼的)-->(爲何寫這個方法)-->(爲何寫在這裏),等等一些思考。在對做者稱讚?的同時也爲本身認識了這種方法而感到高興。
再接再礪!!!
加油?2017!!!

想要了解更多的內容, 能夠關注一下個人我的公衆號。該公衆號每一個工做日會有新聞推送,每週技術分享,博客更新會實時推送。作個有態度的iOS開發者,就從身邊的一件件小事兒作起。滿滿的正能量,有沒有?Do you get it?

相關文章
相關標籤/搜索