MJRefresh是流行的下拉刷新控件隙券,前段時間為了修復(fù)一個BUG,讀了它的源碼闹司,本文總結(jié)一下實現(xiàn)的原理
下拉刷新的基本原理大部分的下拉刷新控件娱仔,都是用contentInset實現(xiàn)的。默認(rèn)情況下游桩,如果一個UIScrollView的左上角在導(dǎo)航欄的正下方牲迫,那么它的contentInset是64耐朴,而contentOffset是-64。繼續(xù)下拉的話盹憎,contentOffset就會越來越小筛峭,如果上滑,contentOffset就會增大陪每,直到左上角達(dá)到屏幕的左上角時影晓,contentOffset剛好為0
默認(rèn)情況下,如果下拉一個UIScrollView奶稠,在松手之后俯艰,會彈回初始的位置(導(dǎo)航欄下方)。而大部分的下拉刷新控件锌订,都是將自己放在UIScrollView的上方竹握,起始y設(shè)置成負(fù)數(shù),所以平時不會顯示出來辆飘,只有下拉的時候才會出現(xiàn)啦辐,放開又會彈回去。然后在loading的時候蜈项,臨時把contentInset增大芹关,相當(dāng)于把UIScrollView往下擠,于是下拉刷新的控件就會顯示出來紧卒,然后刷新完成之后侥衬,再把contentInset改回原來的值,實現(xiàn)回彈的效果
基本上跑芳,MJRefresh也是這么實現(xiàn)的
創(chuàng)建下拉刷新控件實例
從創(chuàng)建實例的代碼開始:
MJRefreshNormalHeader *header = [MJRefreshNormalHeader headerWithRefreshingBlock:^{ ? ?[myController loadCollectionDataNeedReset:YES withBlock:^{ ? ? ? ?[self.header endRefreshing]; ? ? ? ?[self reloadData]; ? ?}];}];
調(diào)用的是一個工廠方法headerWithRefreshingBlock轴总,這個方法定義在各種header控件的基類MJRefreshHeader里:
+ (instancetype)headerWithRefreshingBlock:(MJRefreshComponentRefreshingBlock)refreshingBlock{ ? ?MJRefreshHeader *cmp = [[self alloc] init]; ? ?cmp.refreshingBlock = refreshingBlock; ? ?return cmp;}
然后會調(diào)用init方法,由于MJRefreshHeader里并沒有定義init方法博个,而它的基類MJRefreshComponent里定義了怀樟,所以會進入到基類的初始化方法里:
- (instancetype)initWithFrame:(CGRect)frame{ ? ?if (self = [super initWithFrame:frame]) { ? ? ? ?// 準(zhǔn)備工作 ? ? ? ?[self prepare]; ? ? ? ?// 默認(rèn)是普通狀態(tài) ? ? ? ?self.state = MJRefreshStateIdle; ? ?} ? ?return self;}
這里的關(guān)鍵是prepare方法,這個方法是第一個擴展點盆佣,具體的header(包括庫提供的原生header往堡,和用戶自定義的header)有哪些屬性,樣式是怎么樣共耍,都是在這個方法里實現(xiàn)的虑灰。每個子類的prepare方法,都會調(diào)用父類的prepare方法痹兜。所以在擴展的時候穆咐,公共的屬性寫在父類的prepare方法里,特有的屬性寫在子類的prepare方法里佃蚜。比如庸娱,我們看一下MJRefreshStateHeader的:
- (void)prepare{ ? ?[super prepare]; ? ?// 初始化文字 ? ?[self setTitle:MJRefreshHeaderIdleText forState:MJRefreshStateIdle]; ? ?[self setTitle:MJRefreshHeaderPullingText forState:MJRefreshStatePulling]; ? ?[self setTitle:MJRefreshHeaderRefreshingText forState:MJRefreshStateRefreshing];}
總之,調(diào)用headerWithRefreshingBlock方法以后谐算,就得到了一個UIView的實例熟尉,也就是下拉刷新的控件。但是現(xiàn)在它還沒有掛到任何superview上洲脂,也沒有任何行為
將下拉刷新控件斤儿,掛到UIScrollView上
接下來的調(diào)用:
self.header = header;
這是利用了UIScrollView+MJRefresh里的一個category,為UIScrollView增加了屬性header和footer恐锦。這里用到了關(guān)聯(lián)對象的技巧(AssociatedObject)往果,因為category通常情況下是不能直接添加實例變量的
- (void)setHeader:(MJRefreshHeader *)header{ ? ?if (header != self.header) { ? ? ? ?// 刪除舊的,添加新的 ? ? ? ?[self.header removeFromSuperview]; ? ? ? ?[self addSubview:header]; ? ? ? ?// 存儲新的 ? ? ? ?[self willChangeValueForKey:@"header"]; // KVO ? ? ? ?objc_setAssociatedObject(self, &MJRefreshHeaderKey, ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? header, OBJC_ASSOCIATION_ASSIGN); ? ? ? ?[self didChangeValueForKey:@"header"]; // KVO ? ?}}
通過上面的代碼挠进,把header添加到了UIScrollView的subviews里相艇,并保留了一個引用城看。但是這個header的frame還沒有確定,也沒有任何行為
設(shè)置header的位置和偵聽行為
由于上面執(zhí)行了addSubview肮之,接下來就會進入header的生命周期方法willMoveToSuperview,這個方法是在公共的基類MJRefreshComponent里實現(xiàn)的卜录。因為這是基礎(chǔ)的行為戈擒,所以寫在公共的基類里,所有的子類都能共享:
- (void)willMoveToSuperview:(UIView *)newSuperview{ ? ?[super willMoveToSuperview:newSuperview]; ? ?// 如果不是UIScrollView艰毒,不做任何事情 ? ?if (newSuperview && ![newSuperview isKindOfClass:[UIScrollView class]]) return; ? ?// 舊的父控件移除監(jiān)聽 ? ?[self removeObservers]; ? ?if (newSuperview) { // 新的父控件 ? ? ? ?// 設(shè)置寬度 ? ? ? ?self.mj_w = newSuperview.mj_w; ? ? ? ?// 設(shè)置位置 ? ? ? ?self.mj_x = 0; ? ? ? ?// 記錄UIScrollView ? ? ? ?_scrollView = (UIScrollView *)newSuperview; ? ? ? ?// 設(shè)置永遠(yuǎn)支持垂直彈簧效果 ? ? ? ?_scrollView.alwaysBounceVertical = YES; ? ? ? ?// 記錄UIScrollView最開始的contentInset ? ? ? ?_scrollViewOriginalInset = self.scrollView.contentInset; ? ? ? ?// 添加監(jiān)聽 ? ? ? ?[self addObservers]; ? ?}}
這里關(guān)鍵是設(shè)置了alwaysBounceVertical筐高,這樣才能確保UIScrollView可以下拉,否則需要處理contentSize才能拉得動丑瞧,就麻煩了很多柑土。此外這里令header也持有UIScrollView的引用,后續(xù)可以從上面取到各種屬性
然后是添加監(jiān)聽的方法addObservers嗦篱,這里主要是用了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)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context{ ? ?// 遇到這些情況就直接返回 ? ?if (!self.userInteractionEnabled || self.hidden) return; ? ?if ([keyPath isEqualToString:MJRefreshKeyPathContentOffset]) { ? ? ? ?[self scrollViewContentOffsetDidChange:change]; ? ?} else if ([keyPath isEqualToString:MJRefreshKeyPathContentSize]) { ? ? ? ?[self scrollViewContentSizeDidChange:change]; ? ?} else if ([keyPath isEqualToString:MJRefreshKeyPathContentInset]) { ? ? ? ?[self scrollViewContentInsetDidChange:change]; ? ?} else if ([keyPath isEqualToString:MJRefreshKeyPathPanState]) { ? ? ? ?[self scrollViewPanStateDidChange:change]; ? ?}}
這里偵聽了3個key的變化冰单,UIScrollView的contentOffset和contentSize,以及滑動手勢的狀態(tài)灸促。然后在每個value發(fā)生變化的時候诫欠,調(diào)用幾個didChange方法。這些didChange方法都是hook浴栽,是第二個擴展點荒叼,實際上都是由子類來實現(xiàn)的
接下來會進入生命周期方法layoutSubviews:
- (void)layoutSubviews{ ? ?[super layoutSubviews]; ? ?[self placeSubviews];}
這里的placeSubviews就是header應(yīng)該怎么擺,是第三個擴展點典鸡,把header的origin.y設(shè)置成負(fù)值被廓,就是在MJRefreshHeader的這個方法里實現(xiàn)的:
- (void)placeSubviews{ ? ?[super placeSubviews]; ? ?// 設(shè)置y值(當(dāng)自己的高度發(fā)生改變了,肯定要重新調(diào)整Y值萝玷,所以放到placeSubviews方法中設(shè)置y值) ? ?self.mj_y = - self.mj_h;}
每個子類的placeSubviews方法嫁乘,都應(yīng)該先調(diào)用父類的這個方法
通過上述的代碼昆婿,確定了下拉刷新控件的位置,以及其中每個subview的位置蜓斧。并且偵聽了UIScrollView的contentOffset和contentSize變化
下拉時的實際行為
下拉會導(dǎo)致contentOffset變化仓蛆,由于前面已經(jīng)添加了KVO偵聽,所以會執(zhí)行scrollViewContentOffsetDidChange方法:
- (void)scrollViewContentOffsetDidChange:(NSDictionary *)change{ ? ?[super scrollViewContentOffsetDidChange:change]; ? ?// 在刷新的refreshing狀態(tài) ? ?if (self.state == MJRefreshStateRefreshing) { ? ? ? ?// sectionheader停留解決 ? ? ? ?return; ? ?} ? ?// 跳轉(zhuǎn)到下一個控制器時挎春,contentInset可能會變 ? ?_scrollViewOriginalInset = self.scrollView.contentInset; ? ?// 當(dāng)前的contentOffset ? ?CGFloat offsetY = self.scrollView.mj_offsetY; ? ?// 頭部控件剛好出現(xiàn)的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) { ? ? ? ? ? ?// 轉(zhuǎn)為即將刷新狀態(tài) ? ? ? ? ? ?self.state = MJRefreshStatePulling; ? ? ? ?} else if (self.state == MJRefreshStatePulling && offsetY >= normal2pullingOffsetY) { ? ? ? ? ? ?// 轉(zhuǎn)為普通狀態(tài) ? ? ? ? ? ?self.state = MJRefreshStateIdle; ? ? ? ?} ? ?} else if (self.state == MJRefreshStatePulling) {// 即將刷新 && 手松開 ? ? ? ?// 開始刷新 ? ? ? ?[self beginRefreshing]; ? ?} else if (pullingPercent < 1) { ? ? ? ?self.pullingPercent = pullingPercent; ? ?}}
這段代碼比較長,主要是判斷offset變化是否達(dá)到了臨界值直奋,以及當(dāng)前的手勢能庆,切換header的state狀態(tài),然后根據(jù)state狀態(tài)變化脚线,驅(qū)動不同的行為:
- (void)setState:(MJRefreshState)state{ ? ?MJRefreshCheckState ? ?// 根據(jù)狀態(tài)做事情 ? ?if (state == MJRefreshStateIdle) { ? ? ? ?if (oldState != MJRefreshStateRefreshing) return; ? ? ? ?// 保存刷新時間 ? ? ? ?[[NSUserDefaults standardUserDefaults] setObject:[NSDate date] forKey:self.lastUpdatedTimeKey]; ? ? ? ?[[NSUserDefaults standardUserDefaults] synchronize]; ? ? ? ?// 恢復(fù)inset和offset ? ? ? ?[UIView animateWithDuration:MJRefreshSlowAnimationDuration animations:^{ ? ? ? ? ? ?self.scrollView.mj_insetT -= self.mj_h; ? ? ? ? ? ?// 自動調(diào)整透明度 ? ? ? ? ? ?if (self.isAutoChangeAlpha) self.alpha = 0.0; ? ? ? ?} completion:^(BOOL finished) { ? ? ? ? ? ?self.pullingPercent = 0.0; ? ? ? ?}]; ? ?} else if (state == MJRefreshStateRefreshing) { ? ? ? ?[UIView animateWithDuration:MJRefreshFastAnimationDuration animations:^{ ? ? ? ? ? ?// 增加滾動區(qū)域 ? ? ? ? ? ?CGFloat top = self.scrollViewOriginalInset.top + self.mj_h; ? ? ? ? ? ?self.scrollView.mj_insetT = top; ? ? ? ? ? ?// 設(shè)置滾動位置 ? ? ? ? ? ?self.scrollView.mj_offsetY = - top; ? ? ? ?} completion:^(BOOL finished) { ? ? ? ? ? ?[self executeRefreshingCallback]; ? ? ? ?}]; ? ?}}
setState方法是第四個擴展點搁胆,這里的MJRefreshCheckState是個宏,也調(diào)用了父類的setState的方法邮绿。下拉的時候臨時增大contentInset丰涉,令header保留在屏幕上,然后調(diào)用callback block斯碌;結(jié)束之后還原contentInset