MJRefresh 是一个功能强大的 iOS 上拉下拉刷新组件。有很多大厂App(抖音、快手、京东、喜马拉雅等)都在使用,所以是一个很值得学习的库。
原理
先简单说明上拉下拉刷新的原理再去详细分析 MJRefresh
框架结构以及具体实现。
首先 header/footer 是添加到 UIScrollView
上的,是它的子控件,比如 header 的高度是40,那么它的y值就是 -40
,footer的y值就是 contentSizeHeight
。
下拉/上拉悬停
上拉或者下拉到一定程度,松手后就会进入刷新状态,这时候 header/footer 都在显示在可见范围悬停,这是通过设置 UIScrollView 的 contentInset.top
和 contentInset.bottom
来实现,刷新结束后,再重置回来。
设计
上面介绍了一些上拉下拉的原理,其实很简单, 接下去具体看一下 MJRefresh
是如何做的。
结构设计
MJRefreshComponent
是header和footer 的基类,实际都是使用最后一层的类。接下来具体看每个类的具体职责和实现。
MJRefreshComponent
这个类作为基类,主要的职责就是:
1. 声明刷新控件的状态,刷新的回调,交给子类去实现的函数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| /** 刷新控件的状态 */ typedef NS_ENUM(NSInteger, MJRefreshState) { /** 普通闲置状态 */ MJRefreshStateIdle = 1, /** 松开就可以进行刷新的状态 */ MJRefreshStatePulling, /** 正在刷新中的状态 */ MJRefreshStateRefreshing, /** 即将刷新的状态 */ MJRefreshStateWillRefresh, /** 所有数据加载完毕,没有更多的数据了 */ MJRefreshStateNoMoreData };
/** 刷新用到的回调类型 */ typedef void (^MJRefreshComponentAction)(void);
#pragma mark - 交给子类们去实现 /** 初始化 */ - (void)prepare NS_REQUIRES_SUPER; /** 摆放子控件frame */ - (void)placeSubviews NS_REQUIRES_SUPER; /** 当scrollView的contentOffset发生改变的时候调用 */ - (void)scrollViewContentOffsetDidChange:(nullable NSDictionary *)change NS_REQUIRES_SUPER; /** 当scrollView的contentSize发生改变的时候调用 */ - (void)scrollViewContentSizeDidChange:(nullable NSDictionary *)change NS_REQUIRES_SUPER; /** 当scrollView的拖拽状态发生改变的时候调用 */ - (void)scrollViewPanStateDidChange:(nullable NSDictionary *)change NS_REQUIRES_SUPER;
|
2. 获取 UIScrollView,记录最开始的 contentInset
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| - (void)willMoveToSuperview:(UIView *)newSuperview { [super willMoveToSuperview:newSuperview]; if (newSuperview && ![newSuperview isKindOfClass:[UIScrollView class]]) return; [self removeObservers]; if (newSuperview) { _scrollView = (UIScrollView *)newSuperview; self.mj_w = _scrollView.mj_w; self.mj_x = -_scrollView.mj_insetL; _scrollView.alwaysBounceVertical = YES; _scrollViewOriginalInset = _scrollView.mj_inset; [self addObservers]; } }
|
记录 ContentInset
在 iOS11之后是获取 adjustedContentInset
属性。顺便看下设置 contentInset
的实现
1 2 3 4 5 6 7 8 9 10 11
| - (void)setMj_insetT:(CGFloat)mj_insetT { UIEdgeInsets inset = self.contentInset; inset.top = mj_insetT; #ifdef __IPHONE_11_0 if (respondsToAdjustedContentInset_) { inset.top -= (self.adjustedContentInset.top - self.contentInset.top); } #endif self.contentInset = inset; }
|
3. 添加监听
监听 contentOffset、contentSize、panGestureRecognizer的state。
1 2 3 4 5 6 7 8
| - (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]; }
|
监听实现,具体处理交给子类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| - (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{}
|
4. 开始刷新和结束刷新
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
| - (void)beginRefreshing { [UIView animateWithDuration:MJRefreshFastAnimationDuration animations:^{ self.alpha = 1.0; }]; self.pullingPercent = 1.0; if (self.window) { self.state = MJRefreshStateRefreshing; } else { if (self.state != MJRefreshStateRefreshing) { self.state = MJRefreshStateWillRefresh; [self setNeedsDisplay]; } } }
- (void)endRefreshing { MJRefreshDispatchAsyncOnMainQueue(self.state = MJRefreshStateIdle;) }
#pragma mark 是否正在刷新 - (BOOL)isRefreshing { return self.state == MJRefreshStateRefreshing || self.state == MJRefreshStateWillRefresh; }
|
MJRefreshHeader 是所有 header 的父类,它的职责是:
1. 初始化设置
1 2 3 4 5 6 7 8 9 10
| - (void)prepare { [super prepare]; self.lastUpdatedTimeKey = MJRefreshHeaderLastUpdatedTimeKey; self.mj_h = MJRefreshHeaderHeight; }
|
2. 设置y值
1 2 3 4 5 6 7 8
| - (void)placeSubviews { [super placeSubviews]; self.mj_y = - self.mj_h - self.ignoredScrollViewContentInsetTop; }
|
ignoredScrollViewContentInsetTop
一般很少用到
3. 监听到 contentOffset 改变的具体实现
主要是根据偏移量来决定松手时候可刷新,还有就是悬停操作
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43
| - (void)scrollViewContentOffsetDidChange:(NSDictionary *)change { [super scrollViewContentOffsetDidChange:change]; if (self.state == MJRefreshStateRefreshing) { [self resetInset]; return; } _scrollViewOriginalInset = self.scrollView.mj_inset; CGFloat offsetY = self.scrollView.mj_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; } }
|
这个方法最主要是记录临界点 CGFloat normal2pullingOffsetY = happenOffsetY - self.mj_h;
,用 isDragging
属性来判断用户是否已经松手,如果没松手,就根据 contentOffset
来设置状态(提示松手可刷新或者取消),如果收松开并且状态是 MJRefreshStatePulling
就调用 beginRefreshing
开始刷新。header 并没有重写 beginRefreshing
,所以是调用基类的。
还有一个主要功能是悬停操作:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| if (self.state == MJRefreshStateRefreshing) { [self resetInset]; return; }
- (void)resetInset { if (@available(iOS 11.0, *)) { } else { if (!self.window) { return; } } 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.insetTDelta = _scrollViewOriginalInset.top - insetT; if (self.scrollView.mj_insetT != insetT) { self.scrollView.mj_insetT = insetT; } }
|
和之前讲的一样,悬停的原理就是设置 contentInset.top
。
self.insetTDelta
这个属性可以理解为 header 的高度取反。
4. 状态切换
1 2 3 4 5 6 7 8 9 10 11
| - (void)setState:(MJRefreshState)state { MJRefreshCheckState if (state == MJRefreshStateIdle) { if (oldState != MJRefreshStateRefreshing) return; [self headerEndingAction]; } else if (state == MJRefreshStateRefreshing) { [self headerRefreshingAction]; }
|
headerEndingAction
和 headerRefreshingAction
是对 contentOffset
做动画
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| - (void)headerRefreshingAction { [UIView animateWithDuration:MJRefreshFastAnimationDuration animations:^{ if (self.scrollView.panGestureRecognizer.state != UIGestureRecognizerStateCancelled) { CGFloat top = self.scrollViewOriginalInset.top + self.mj_h; self.scrollView.mj_insetT = top; CGPoint offset = self.scrollView.contentOffset; offset.y = -top; [self.scrollView setContentOffset:offset animated:NO]; } } completion:^(BOOL finished) { [self executeRefreshingCallback]; }]; }
|
MJRefreshHeader
作为公共header,已经把原理篇的事情都做完了,设置y值、状态切换、悬停处理。
那么看看 MJRefreshStateHeader
的主要职责:
1. 初始化操作
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| - (void)prepare { [super prepare]; self.labelLeftInset = MJRefreshLabelLeftInset; [self setTitle:[NSBundle mj_localizedStringForKey:MJRefreshHeaderIdleText] forState:MJRefreshStateIdle]; [self setTitle:[NSBundle mj_localizedStringForKey:MJRefreshHeaderPullingText] forState:MJRefreshStatePulling]; [self setTitle:[NSBundle mj_localizedStringForKey:MJRefreshHeaderRefreshingText] forState:MJRefreshStateRefreshing]; }
|
初始化文字的原理就是用字典来存储每个状态对应的 title
。
2. 对 Label 进行布局
MJRefreshStateHeader 是负责状态Label和更新时间Label
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
| - (void)placeSubviews { [super placeSubviews]; if (self.stateLabel.hidden) return; BOOL noConstrainsOnStatusLabel = self.stateLabel.constraints.count == 0; if (self.lastUpdatedTimeLabel.hidden) { if (noConstrainsOnStatusLabel) self.stateLabel.frame = self.bounds; } else { CGFloat stateLabelH = self.mj_h * 0.5; if (noConstrainsOnStatusLabel) { self.stateLabel.mj_x = 0; self.stateLabel.mj_y = 0; self.stateLabel.mj_w = self.mj_w; self.stateLabel.mj_h = stateLabelH; } if (self.lastUpdatedTimeLabel.constraints.count == 0) { self.lastUpdatedTimeLabel.mj_x = 0; self.lastUpdatedTimeLabel.mj_y = stateLabelH; self.lastUpdatedTimeLabel.mj_w = self.mj_w; self.lastUpdatedTimeLabel.mj_h = self.mj_h - self.lastUpdatedTimeLabel.mj_y; } } }
|
3. 切换状态
1 2 3 4 5 6 7 8 9 10
| - (void)setState:(MJRefreshState)state { MJRefreshCheckState self.stateLabel.text = self.stateTitles[@(state)]; self.lastUpdatedTimeKey = self.lastUpdatedTimeKey; }
|
4. 设置上次刷新日期 Text
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46
| - (void)setLastUpdatedTimeKey:(NSString *)lastUpdatedTimeKey { [super setLastUpdatedTimeKey:lastUpdatedTimeKey]; if (self.lastUpdatedTimeLabel.hidden) return; NSDate *lastUpdatedTime = [[NSUserDefaults standardUserDefaults] objectForKey:lastUpdatedTimeKey]; if (self.lastUpdatedTimeText) { self.lastUpdatedTimeLabel.text = self.lastUpdatedTimeText(lastUpdatedTime); return; } if (lastUpdatedTime) { NSCalendar *calendar = [NSCalendar calendarWithIdentifier:NSCalendarIdentifierGregorian]; NSUInteger unitFlags = NSCalendarUnitYear| NSCalendarUnitMonth | NSCalendarUnitDay | NSCalendarUnitHour | NSCalendarUnitMinute; NSDateComponents *cmp1 = [calendar components:unitFlags fromDate:lastUpdatedTime]; NSDateComponents *cmp2 = [calendar components:unitFlags fromDate:[NSDate date]]; NSDateFormatter *formatter = [[NSDateFormatter alloc] init]; BOOL isToday = NO; if ([cmp1 day] == [cmp2 day]) { formatter.dateFormat = @" HH:mm"; isToday = YES; } else if ([cmp1 year] == [cmp2 year]) { formatter.dateFormat = @"MM-dd HH:mm"; } else { formatter.dateFormat = @"yyyy-MM-dd HH:mm"; } NSString *time = [formatter stringFromDate:lastUpdatedTime]; self.lastUpdatedTimeLabel.text = [NSString stringWithFormat:@"%@%@%@", [NSBundle mj_localizedStringForKey:MJRefreshHeaderLastTimeText], isToday ? [NSBundle mj_localizedStringForKey:MJRefreshHeaderDateTodayText] : @"", time]; } else { self.lastUpdatedTimeLabel.text = [NSString stringWithFormat:@"%@%@", [NSBundle mj_localizedStringForKey:MJRefreshHeaderLastTimeText], [NSBundle mj_localizedStringForKey:MJRefreshHeaderNoneLastDateText]]; } }
|
有一个 lastUpdatedTimeText
block 可以自定义内容。这个设置会在切换 state
的时候调用。
1. 初始化
刷新样式的初始化
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| - (void)prepare { [super prepare]; #if __IPHONE_OS_VERSION_MAX_ALLOWED >= 130000 if (@available(iOS 13.0, *)) { _activityIndicatorViewStyle = UIActivityIndicatorViewStyleMedium; return; } #endif _activityIndicatorViewStyle = UIActivityIndicatorViewStyleGray; }
|
2. 对控件进行布局
MJRefreshNormalHeader 主要是负责 箭头和刷新小菊花的控件。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
| - (void)placeSubviews { [super placeSubviews]; CGFloat arrowCenterX = self.mj_w * 0.5; if (!self.stateLabel.hidden) { CGFloat stateWidth = self.stateLabel.mj_textWidth; CGFloat timeWidth = 0.0; if (!self.lastUpdatedTimeLabel.hidden) { timeWidth = self.lastUpdatedTimeLabel.mj_textWidth; } CGFloat textWidth = MAX(stateWidth, timeWidth); arrowCenterX -= textWidth / 2 + self.labelLeftInset; } CGFloat arrowCenterY = self.mj_h * 0.5; CGPoint arrowCenter = CGPointMake(arrowCenterX, arrowCenterY); if (self.arrowView.constraints.count == 0) { self.arrowView.mj_size = self.arrowView.image.size; self.arrowView.center = arrowCenter; } if (self.loadingView.constraints.count == 0) { self.loadingView.center = arrowCenter; } self.arrowView.tintColor = self.stateLabel.textColor; }
|
3. 状态切换处理
根据状态对 arrowView
和 loadView
进行动画和显示隐藏
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38
| - (void)setState:(MJRefreshState)state { MJRefreshCheckState if (state == MJRefreshStateIdle) { if (oldState == MJRefreshStateRefreshing) { self.arrowView.transform = CGAffineTransformIdentity; [UIView animateWithDuration:MJRefreshSlowAnimationDuration animations:^{ self.loadingView.alpha = 0.0; } completion:^(BOOL finished) { if (self.state != MJRefreshStateIdle) return; self.loadingView.alpha = 1.0; [self.loadingView stopAnimating]; self.arrowView.hidden = NO; }]; } else { [self.loadingView stopAnimating]; self.arrowView.hidden = NO; [UIView animateWithDuration:MJRefreshFastAnimationDuration animations:^{ self.arrowView.transform = CGAffineTransformIdentity; }]; } } else if (state == MJRefreshStatePulling) { [self.loadingView stopAnimating]; self.arrowView.hidden = NO; [UIView animateWithDuration:MJRefreshFastAnimationDuration animations:^{ self.arrowView.transform = CGAffineTransformMakeRotation(0.000001 - M_PI); }]; } else if (state == MJRefreshStateRefreshing) { self.loadingView.alpha = 1.0; [self.loadingView startAnimating]; self.arrowView.hidden = YES; } }
|
MJRefreshGifHeader 和 MJRefreshNormalHeader 的作用一样,都是进行刷新控件的布局和操作的,只不过 normal 是常用的箭头和菊花。gif就是用gif图片
1. 设置图片数组
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| - (void)setImages:(NSArray *)images duration:(NSTimeInterval)duration forState:(MJRefreshState)state { if (images == nil) return; self.stateImages[@(state)] = images; self.stateDurations[@(state)] = @(duration); UIImage *image = [images firstObject]; if (image.size.height > self.mj_h) { self.mj_h = image.size.height; } }
- (void)setImages:(NSArray *)images forState:(MJRefreshState)state { [self setImages:images duration:images.count * 0.1 forState:state]; }
|
2. 对 gifView 进行布局
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| - (void)placeSubviews { [super placeSubviews]; if (self.gifView.constraints.count) return; self.gifView.frame = self.bounds; if (self.stateLabel.hidden && self.lastUpdatedTimeLabel.hidden) { self.gifView.contentMode = UIViewContentModeCenter; } else { self.gifView.contentMode = UIViewContentModeRight; CGFloat stateWidth = self.stateLabel.mj_textWidth; CGFloat timeWidth = 0.0; if (!self.lastUpdatedTimeLabel.hidden) { timeWidth = self.lastUpdatedTimeLabel.mj_textWidth; } CGFloat textWidth = MAX(stateWidth, timeWidth); self.gifView.mj_w = self.mj_w * 0.5 - textWidth * 0.5 - self.labelLeftInset; } }
|
3. 状态切换
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| - (void)setState:(MJRefreshState)state { MJRefreshCheckState if (state == MJRefreshStatePulling || state == MJRefreshStateRefreshing) { NSArray *images = self.stateImages[@(state)]; if (images.count == 0) return; [self.gifView stopAnimating]; if (images.count == 1) { self.gifView.image = [images lastObject]; } else { self.gifView.animationImages = images; self.gifView.animationDuration = [self.stateDurations[@(state)] doubleValue]; [self.gifView startAnimating]; } } else if (state == MJRefreshStateIdle) { [self.gifView stopAnimating]; } }
|
4. 根据拖拽的百分比来设置显示图片
1 2 3 4 5 6 7 8 9 10 11 12
| - (void)setPullingPercent:(CGFloat)pullingPercent { [super setPullingPercent:pullingPercent]; NSArray *images = self.stateImages[@(MJRefreshStateIdle)]; if (self.state != MJRefreshStateIdle || images.count == 0) return; [self.gifView stopAnimating]; NSUInteger index = images.count * pullingPercent; if (index >= images.count) index = images.count - 1; self.gifView.image = images[index]; }
|
Header 的部分已经结束,最核心的就是 MJRefreshComponent
和 MJRefreshHeader
,这两个类把上拉刷新的核心功能都做完了,剩下的类就是根据状态显示对应的UI。
所以如果要自定义下拉刷新的UI可以直接继承 MJRefreshHeader
。
在 prepare
方法进行初始化
在 placeSubviews
方法进行布局
处理状态切换
Footer 和 Header 的实现原理基本类似,就不多介绍了。总体来说上拉下拉刷新的原理还是比较简单的。通过看源码可以学到怎么分层,让每一个子类完成各自的事情,从而提高框架的可定制性。还有就是 UIScrollView
是比较复杂的控件,虽然原理简单但是实际上做出来的时候就是会出现不可思议的 bug, 可以学习如何追踪 bug 和解决 bug 的思路,MJRefresh
对 bug 的处理我没贴上去。感兴趣的可以自行学习。