MJRefresh原理探究

最近要做一個下拉特效,由于平時用的是MJRefresh,所以研究一下MJRefresh源碼,下面把研究的一些心得寫出來

總體結構

mj.png

上圖是MJRefresh在Github的圖,從上面可以看得出來,首先MJRefresh是基于一個component類的,然后是基礎的上下拉刷新控件,然后就是基于這兩個基礎類的擴展。這些基礎類在Base文件夾中定義了

mjbase.png

本文主要探究框架內部實現原理,所以主要主要講一下基類的實現

MJRefreshComponent類

這是一個抽象類,平時使用都是使用它的子類去實現,這個類主要實現了

  1. 初始化
  2. KVO監聽
  3. 定義公共方法,讓子類去實現

先看初始化部分

#pragma mark - 初始化
- (instancetype)initWithFrame:(CGRect)frame
{
   if (self = [super initWithFrame:frame]) {
       // 準備工作
       [self prepare];
       
       // 默認是普通狀態
       self.state = MJRefreshStateIdle;
   }
   return self;
}

- (void)prepare
{
   // 基本屬性
   self.autoresizingMask = UIViewAutoresizingFlexibleWidth;
   self.backgroundColor = [UIColor clearColor];
}

- (void)layoutSubviews
{
   [self placeSubviews];
   
   [super layoutSubviews];
}

- (void)placeSubviews{}

- (void)willMoveToSuperview:(UIView *)newSuperview
{
   [super willMoveToSuperview:newSuperview];
   
   // 如果不是UIScrollView,不做任何事情
   if (newSuperview && ![newSuperview isKindOfClass:[UIScrollView class]]) return;
   
   // 舊的父控件移除監聽
   [self removeObservers];
   
   if (newSuperview) { // 新的父控件
       // 設置寬度
       self.mj_w = newSuperview.mj_w;
       // 設置位置
       self.mj_x = 0;
       
       // 記錄UIScrollView
       _scrollView = (UIScrollView *)newSuperview;
       // 設置永遠支持垂直彈簧效果
       _scrollView.alwaysBounceVertical = YES;
       // 記錄UIScrollView最開始的contentInset
       _scrollViewOriginalInset = _scrollView.contentInset;
       
       // 添加監聽
       [self addObservers];
   }
}

- (void)drawRect:(CGRect)rect
{
   [super drawRect:rect];
   
   if (self.state == MJRefreshStateWillRefresh) {
       // 預防view還沒顯示出來就調用了beginRefreshing
       self.state = MJRefreshStateRefreshing;
   }
}

從上面代碼段我們可以看到

  • init方法調用了prepare方法,prepare方法在.h文件中暴露出來給子類實現的,主要用來初始化一些屬性(key、高度等)
  • placeSubviews方法主要用來對子視圖進行布局調整,也是給子類實現的,
  • willMoveToSuperview的注釋很詳細,主要用來判斷是不是UIScrollView類或其子類,是的話就添加監聽,所以只要是繼承UISCrollView的來都可以實現監聽
  • drawRect方法是做一些預防處理

接下來是實現的核心,KVO監聽過程

#pragma mark - KVO監聽
- (void)addObservers
{
    NSKeyValueObservingOptions options = NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld;
    [self.scrollView addObserver:self forKeyPath:MJRefreshKeyPathContentOffset options:options context:nil];
    [self.scrollView addObserver:self forKeyPath:MJRefreshKeyPathContentSize options:options context:nil];
    self.pan = self.scrollView.panGestureRecognizer;
    [self.pan addObserver:self forKeyPath:MJRefreshKeyPathPanState options:options context:nil];
}

- (void)removeObservers
{
    [self.superview removeObserver:self forKeyPath:MJRefreshKeyPathContentOffset];
    [self.superview removeObserver:self forKeyPath:MJRefreshKeyPathContentSize];;
    [self.pan removeObserver:self forKeyPath:MJRefreshKeyPathPanState];
    self.pan = nil;
}

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context
{
    // 遇到這些情況就直接返回
    if (!self.userInteractionEnabled) return;
    
    // 這個就算看不見也需要處理
    if ([keyPath isEqualToString:MJRefreshKeyPathContentSize]) {
        [self scrollViewContentSizeDidChange:change];
    }
    
    // 看不見
    if (self.hidden) return;
    if ([keyPath isEqualToString:MJRefreshKeyPathContentOffset]) {
        [self scrollViewContentOffsetDidChange:change];
    } else if ([keyPath isEqualToString:MJRefreshKeyPathPanState]) {
        [self scrollViewPanStateDidChange:change];
    }
}

- (void)scrollViewContentOffsetDidChange:(NSDictionary *)change{}
- (void)scrollViewContentSizeDidChange:(NSDictionary *)change{}
- (void)scrollViewPanStateDidChange:(NSDictionary *)change{}

從代碼段可以看到,對于scrollView的contentOffsetcontentSize和手勢進行了監聽。當監聽的內容發生變化,就調用相應的方法;這些方法都是空的,由子類實現,上下拉根據不同需要實現不同內容。

這部分是刷新實現的核心,其本質就是通過KVO監聽scrollView的相關屬性來進行不同調用實現的。

MJRefreshHeader類

#pragma mark - 覆蓋父類的方法
- (void)prepare
{
    [super prepare];
    
    // 設置key
    self.lastUpdatedTimeKey = MJRefreshHeaderLastUpdatedTimeKey;
    
    // 設置高度
    self.mj_h = MJRefreshHeaderHeight;
}

- (void)placeSubviews
{
    [super placeSubviews];
    
    // 設置y值(當自己的高度發生改變了,肯定要重新調整Y值,所以放到placeSubviews方法中設置y值)
    self.mj_y = - self.mj_h - self.ignoredScrollViewContentInsetTop;
}

從代碼可以看出,初始化主要是要設置key、高度和y坐標值,以(0,-高度)加入到scrollView中

- (void)scrollViewContentOffsetDidChange:(NSDictionary *)change
{
    [super scrollViewContentOffsetDidChange:change];
    
    // 在刷新的refreshing狀態
    if (self.state == MJRefreshStateRefreshing) {
        if (self.window == nil) return;
        
        // sectionheader停留解決
        CGFloat insetT = - self.scrollView.mj_offsetY > _scrollViewOriginalInset.top ? - self.scrollView.mj_offsetY : _scrollViewOriginalInset.top;
        insetT = insetT > self.mj_h + _scrollViewOriginalInset.top ? self.mj_h + _scrollViewOriginalInset.top : insetT;
        self.scrollView.mj_insetT = insetT;
        
        self.insetTDelta = _scrollViewOriginalInset.top - insetT;
        return;
    }
    
    // 跳轉到下一個控制器時,contentInset可能會變
     _scrollViewOriginalInset = self.scrollView.contentInset;
    
    // 當前的contentOffset
    CGFloat offsetY = self.scrollView.mj_offsetY;
    // 頭部控件剛好出現的offsetY
    CGFloat happenOffsetY = - self.scrollViewOriginalInset.top;
    
    // 如果是向上滾動到看不見頭部控件,直接返回
    // >= -> >
    if (offsetY > happenOffsetY) return;
    
    // 普通 和 即將刷新 的臨界點
    CGFloat normal2pullingOffsetY = happenOffsetY - self.mj_h;
    CGFloat pullingPercent = (happenOffsetY - offsetY) / self.mj_h;
    
    if (self.scrollView.isDragging) { // 如果正在拖拽
        self.pullingPercent = pullingPercent;
        if (self.state == MJRefreshStateIdle && offsetY < normal2pullingOffsetY) {
            // 轉為即將刷新狀態
            self.state = MJRefreshStatePulling;
        } else if (self.state == MJRefreshStatePulling && offsetY >= normal2pullingOffsetY) {
            // 轉為普通狀態
            self.state = MJRefreshStateIdle;
        }
    } else if (self.state == MJRefreshStatePulling) {// 即將刷新 && 手松開
        // 開始刷新
        [self beginRefreshing];
    } else if (pullingPercent < 1) {
        self.pullingPercent = pullingPercent;
    }
}

這段代碼有點多,首先if代碼段是為了解決在正在進行刷新的時候 tableView 中 sectionHeader 停留問題的;然后接下來的代碼主要就是對當前contentOffsetoffsetY和頭部剛好出現的offsetYhappenOffsetY的計算出刷新時所處對應的狀態,設置下拉百分比。

接下來看看狀態的set方法

- (void)setState:(MJRefreshState)state
{
    MJRefreshCheckState
    
    // 根據狀態做事情
    if (state == MJRefreshStateIdle) {
        if (oldState != MJRefreshStateRefreshing) return;
        
        // 保存刷新時間
        [[NSUserDefaults standardUserDefaults] setObject:[NSDate date] forKey:self.lastUpdatedTimeKey];
        [[NSUserDefaults standardUserDefaults] synchronize];
        
        // 恢復inset和offset
        [UIView animateWithDuration:MJRefreshSlowAnimationDuration animations:^{
            self.scrollView.mj_insetT += self.insetTDelta;
            
            // 自動調整透明度
            if (self.isAutomaticallyChangeAlpha) self.alpha = 0.0;
        } completion:^(BOOL finished) {
            self.pullingPercent = 0.0;
            
            if (self.endRefreshingCompletionBlock) {
                self.endRefreshingCompletionBlock();
            }
        }];
    } else if (state == MJRefreshStateRefreshing) {
         dispatch_async(dispatch_get_main_queue(), ^{
            [UIView animateWithDuration:MJRefreshFastAnimationDuration animations:^{
                CGFloat top = self.scrollViewOriginalInset.top + self.mj_h;
                // 增加滾動區域top
                self.scrollView.mj_insetT = top;
                // 設置滾動位置
                [self.scrollView setContentOffset:CGPointMake(0, -top) animated:NO];
            } completion:^(BOOL finished) {
                [self executeRefreshingCallback];
            }];
         });
    }
}

從代碼可以看到,這個函數主要用于在更換狀態的時候顯示每個狀態所對應的界面,MJRefresh是通過scrollView的contentInset來顯示數顯用的header,就是在刷險狀態的時候可以讓header停留在頂部,刷新完成后設置回原來的contentInset

一開始就有這么一個宏MJRefreshCheckState,其實現如下

// 狀態檢查
#define MJRefreshCheckState \
MJRefreshState oldState = self.state; \
if (state == oldState) return; \
[super setState:state];

這個宏主要用來做狀態檢查的,在相同狀態下,根據上一次狀態來作不同的處理。例如剛開始往下拉處于空閑狀態要顯示一個arrow,但是下拉完成后回到空閑狀態要把arrow隱藏。

Footer實現

其實Footer的實現類似,不過具體上拉刷新有多種樣式所以需要在MJRefreshAutoFooter MJRefreshBackFooter.h做不同的處理,這里就不多說了

小結

整個框架在基類實現了最基本的流程,把握整個框架把這部分弄懂了就基本可以了,每個子類只是做了不同的邏輯處理而已。

總的來說,上下拉刷新的原理就是先把刷新控件添加到scrollView的頭部或者底部,然后通過KVO監聽到scrollView的滾動進度(底部刷新還需要監控scrollView的內容的改變,每次改變后再次將控件調整到scrollView的底部),根據不同的進度來設置控件的相應的文字和圖片動畫等。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 229,963評論 6 542
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,348評論 3 429
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事?!?“怎么了?”我有些...
    開封第一講書人閱讀 178,083評論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,706評論 1 317
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,442評論 6 412
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,802評論 1 328
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,795評論 3 446
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,983評論 0 290
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,542評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,287評論 3 358
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,486評論 1 374
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,030評論 5 363
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,710評論 3 348
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,116評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,412評論 1 294
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,224評論 3 398
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,462評論 2 378

推薦閱讀更多精彩內容

  • 發現 關注 消息 iOS 第三方庫、插件、知名博客總結 作者大灰狼的小綿羊哥哥關注 2017.06.26 09:4...
    肇東周閱讀 12,170評論 4 61
  • Android 自定義View的各種姿勢1 Activity的顯示之ViewRootImpl詳解 Activity...
    passiontim閱讀 172,721評論 25 708
  • 今天報了30天跑團,可是下班回家路上冷颼颼的風就讓我打起了退堂鼓。 回家呼嚕嚕吃了一堆零食,然后陷入深深的糾結中 ...
    貓小埋閱讀 216評論 0 0
  • 01 凜冬已至,王昭君出現在了峽谷。 李白正在對面的野區刷怪,瞧著敵方陣營的韓信身影閃了一下,卻沒有來找他,反而去...
    納蘭榴蓮閱讀 17,967評論 9 42
  • 雖然不過半個多小時的車程,但由于親人們的逐漸疏離,自己往往是難得回一次老家。前幾天,老家的表叔打電話來邀請我們一家...
    蔚藍楓葉1970閱讀 362評論 2 1