实现效果
- 刷新控件会随着 tableView 的拖动而移动
- 拖动 tableView
- 顶部显示 下拉刷新,箭头朝下
- 拖动到一定程度的时候,顶部显示 释放更新,箭头朝上
- 松手:
- 到达一定程度松手,顶部显示 正在刷新…,隐藏箭头,显示菊花转
- 未到达一定程度,直接回到最初状态

实现思路
- 给
tableView添加一个自定义刷新控件(CZPullDownRefreshControl) - 这个刷新控件的
y值是 负 自己的高度,以让其放在tableView的顶部以及可以跟随tableView滑动 - 刷新控件有
3中状态,下拉刷新、释放更新、正在刷新… - 在刷新控件
内部监听tableView的滑动 - 当滑动到一定程度去改变刷新控件的状态
- 当用户松开手要刷新的时候,需要调整
tableView的contentInset的top值以让刷新控件显示出来 - 在刷新的时候调用外部提供的方法执行刷新的逻辑
实现
准备工作
- 创建工程
创建
QSRefreshController继承自UITableViewController@interface QSRefreshController : UITableViewController- 删除
Main.storyboard默认的控制器,拖入一个UITableViewController,并设置UITableViewController的Class为QSRefreshController,再将这个UITableViewController嵌入UINavigationController,导航控制器勾选Is Initial View Controller - 运行效果如下:

预先加载数据
- 创建
data.json定义一个json数组,预定义一些数据 加载预定义数据,使用tableView显示出来
- 定义
CityModel模型 - 定义
initWithCityName:方法 实现
initWithCityName:方法- (instancetype)initWithCityName:(NSString *)cityName { if (self = [super init]) { _city = cityName; } return self; }在
QSRefreshController里面添加loadData:来加载data.json文件里面的数据- 获取data.json的路径
- 读取data.json文件的内容转成NSData
- 将data转成JSON数组,因为data.json文件里面的数据是一个JSON数组
- 创建可变的数组,保存模型
- 遍历数组获取数组中的每一个字典
- 获取字典中city对应的value
- 将字典转成模型
- 将模型添加到可变数组里面
or循环遍历完后所有的字典都转成模型,回调
/** * 模拟加载数据 * @param loadDataCallback 加载完数据的回调 */ - (void)loadData:(void(^)(NSArray *array))loadDataCallback { // 模拟异步加载数据 dispatch_async(dispatch_get_main_queue(), ^{ // 获取data.json的路径 NSString *file = [[NSBundle mainBundle] pathForResource:@"data.json" ofType:nil]; // 读取data.json文件的内容转成NSData NSData *data = [NSData dataWithContentsOfFile:file]; // 将data转成JSON数组,因为data.json文件里面的数据是一个JSON数组 NSArray *cities = [NSJSONSerialization JSONObjectWithData:data options:0 error:NULL]; // 创建可变的数组,保存模型 NSMutableArray *arrM = [NSMutableArray array]; // 遍历数组获取数组中的每一个字典 for (NSDictionary *dict in cities) { // 获取字典中city对应的value NSString * cityName = dict[@"city"]; cityName = [NSString stringWithFormat:@"%@ - %zd", cityName, arc4random_uniform(10000)]; // 将字典转成模型 CityModel *city = [[CityModel alloc] initWithCityName:cityName]; // 将模型添加到可变数组里面 [arrM addObject:city]; } // for循环遍历完后所有的字典都转成模型,回调 loadDataCallback(arrM); }); }
在
QSRefreshController里面添加 tableView 要显示的数据cities数组/** * tableView要显示的数据 */ @property(nonatomic, strong) NSArray *cities;在
QSRefreshController的viewDidLoad调用loadData:来加载数据[self loadData:^(NSArray *cities) { self.cities = cities; [self.tableView reloadData]; }];实现
tableView的数据源方法: 1.tableView:numberOfRowsInSection:, 2.tableView:cellForRowAtIndexPath:#pragma mark - tableView 数据源和代理方法 /** * 每组返回多少个cell * @param tableView 界面上的tableView * @param section tableView的组 * @return cell的数量 */ - (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section { return self.cities.count; } /** * 返回cell给tableView * @param tableView 界面上的tableView * @param indexPath cell的indexPath * @return cell */ - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:CellIdentifier forIndexPath:indexPath]; CityModel *city = self.cities[indexPath.row]; cell.textLabel.text = city.city; return cell; }在
Main.storyboard中设置tableViewCell的identifier为: "cell"- 在
QSRefreshController定义宏:#define CellIdentifier @"cell" - 运行:

- 定义
自定义下拉刷新控件
自定义
QSPullDownRefreshControl继承自UIView,实现initWithFrame:/** * 构造方法 * @param frame frame * @return instancetype */ - (instancetype)initWithFrame:(CGRect)frame { if (self = [super initWithFrame:frame]) { self.backgroundColor = [UIColor brownColor]; } return self; }在
QSRefreshController定义下拉刷新控件属性/** * 下拉刷新控件 */ @property(nonatomic, strong) QSPullDownRefreshControl *pullDownloadRefreshControl;pullDownloadRefreshControl 懒加载
#pragma mark - 懒加载 /** * 懒加载下拉刷新控件 * @return 下拉刷新控件 */ - (QSPullDownRefreshControl *)pullDownloadRefreshControl { if (_pullDownloadRefreshControl == nil) { _pullDownloadRefreshControl = [[QSPullDownRefreshControl alloc] init]; } return _pullDownloadRefreshControl; }在
QSRefreshController定义setupPullDownRefreshControl方法,设置下拉刷新控件/** * 设置下拉刷新控件 */ - (void)setupPullDownRefreshControl { [self.tableView addSubview:self.pullDownloadRefreshControl]; }运行,并没有看到下拉刷新控件: 因为创建刷新控件直接使用
[[QSPullDownRefreshControl alloc] init],系统会调用到QSPullDownRefreshControl的initWithFrame:,传入一个为CGRectZero的frame所以看不到,我们在initWithFrame:自己来创建一个frame,这样外部使用的人比较简便,刷新控件有一个固定大小/** * 刷新控件高度 */ #define CZRefreshControlHeight 60 /** * 刷新控件宽度 */ #define CZRefreshControlWidth [UIScreen mainScreen].bounds.size.width - (instancetype)initWithFrame:(CGRect)frame { CGRect newFrame = CGRectMake(0, -CZRefreshControlHeight, CZRefreshControlWidth, CZRefreshControlHeight); if (self = [super initWithFrame:newFrame]) { self.backgroundColor = [UIColor brownColor]; } return self; }- 运行:

状态切换
在
QSPullDownRefreshControl.m定义3中状态/// 刷新控件刷新状态 typedef enum { CZPullDownRefreshStatusNormal = 0, // 箭头向下 CZPullDownRefreshStatusPulling, // 箭头向上,松手就刷新 CZPullDownRefreshStatusRefreshing // 正在刷新 } CZPullDownRefreshStatus;使用类扩展
QSPullDownRefreshControl, 在interface QSPullDownRefreshControl定义currentStatus标识当前状态@interface QSPullDownRefreshControl () /// 当前状态 @property(nonatomic, assign) CZPullDownRefreshStatus currentStatus; @end监听
tableView的contentOffset,第一感觉我们会想到在QSRefreshController使用UIScrollView的代理方法scrollViewDidEndDecelerating:来监听contentOffset,但是这个控制器和下拉刷新控件耦合的太紧,最好是在下拉刷新控件里面来监听tableView的滚动. 下拉刷新控件是添加到tableView中的,所以是要监听下拉刷新控件的父控件的滚动,注意在initWithFrame:方法里面是监听不到的.因为刚创建下拉刷新的时候,它还没有父控件.只有等下拉刷新控件有父控件的时候才能拿到父控件来监听.因此在QSPullDownRefreshControl的willMoveToSuperview:来监听,监听tableView的contentOffset属性值的改变,因此使用KVO/// View即将添加到父控件上面 - (void)willMoveToSuperview:(UIView *)newSuperview { // 先调用父类的方法 [super willMoveToSuperview:newSuperview]; // 判断是否是 UIScrollView或子类 if ([newSuperview isKindOfClass:[UIScrollView class]]) { // 记录下这个控件,后续访问会使用到 _superScrollView = (UIScrollView *)newSuperview; // 使用KVO监听父控件的滚动 [_superScrollView addObserver:self forKeyPath:@"contentOffset" options:0 context:nil]; } }在对象销毁的时候移除对
tableView的 contentOffset 属性的监听/// 在对象销毁的时候移除监听 - (void)dealloc { [self.superScrollView removeObserver:self forKeyPath:@"contentOffset"]; }- 运行: 在下拉刷新控件里面可以监听到
tableView的contentOffset的改变tableView.contentOffset.y = -66.000000 在
observeValueForKeyPath:ofObject:change:context里面根据scrollView.contentOffset.y来切换状态.- 拖动
- CZPullDownRefreshStatusNormal ==> CZPullDownRefreshStatusPulling
- CZPullDownRefreshStatusPulling ==> CZPullDownRefreshStatusNormal
松手
CZPullDownRefreshStatusPulling ==> CZPullDownRefreshStatusRefreshing
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSString *,id> *)change context:(void *)context { /* 状态切换 1.用户拖动: CZPullDownRefreshStatusNormal ==> CZPullDownRefreshStatusPulling CZPullDownRefreshStatusPulling ==> CZPullDownRefreshStatusNormal 2.用户松手: CZPullDownRefreshStatusPulling ==> CZPullDownRefreshStatusRefreshing */ CGFloat NormalToPullingOffset = -self.superScrollView.contentInset.top - CZRefreshControlHeight; if (self.superScrollView.isDragging) { if (self.currentStatus == CZPullDownRefreshStatusPulling && self.superScrollView.contentOffset.y > NormalToPullingOffset) { self.currentStatus = CZPullDownRefreshStatusNormal; NSLog(@"切换状态: %@", @"Normal"); } else if (self.currentStatus == CZPullDownRefreshStatusNormal && self.superScrollView.contentOffset.y < NormalToPullingOffset) { self.currentStatus = CZPullDownRefreshStatusPulling; NSLog(@"切换状态: %@", @"Pulling"); } } else { if (self.currentStatus == CZPullDownRefreshStatusPulling) { self.currentStatus = CZPullDownRefreshStatusRefreshing; NSLog(@"切换状态: %@", @"Refreshing"); } } }
- 拖动
- 运行

- 目前
CZPullDownRefreshStatusNormal ==> CZPullDownRefreshStatusPulling,CZPullDownRefreshStatusPulling ==> CZPullDownRefreshStatusNormal和CZPullDownRefreshStatusPulling ==> CZPullDownRefreshStatusRefreshing之间的切换就好了,还差CZPullDownRefreshStatusRefreshing ===> CZPullDownRefreshStatusNormal,这个状态不是下拉刷新控件自己来决定的. 接下来添加刷新控件需要的界面元素
3中状态的文字
#pragma mark - 3种状态文字 #define NormalText @"下拉刷新" #define PullingText @"释放刷新" #define RefreshingText @"正在刷新..."3中状态的图片
#pragma mark - 2种状态图片 /** 正常状态图片 */ #define NormalImage [UIImage imageNamed:@"dropdown_anim__0001"] /** 释放刷新图片 */ #define PullingImage [UIImage imageNamed:@"dropdown_anim__00060"] #pragma mark - 懒加载 /** * 返回刷新时的图片数组 * @return 刷新时的图片数组 */ - (NSArray *)refreshingImages { if (_refreshingImages == nil) { NSMutableArray *images = [NSMutableArray array]; for (int i = 1; i <= 3; i++) { NSString *name = [NSString stringWithFormat:@"dropdown_loading_0%zd", i]; UIImage *image = [UIImage imageNamed:name]; [images addObject:image]; } _refreshingImages = images; } return _refreshingImages; }懒加载UI:
显示图片的imageView和显示文本的label// MARK: - UI /// 显示图片的imageView @property (nonatomic, strong) UIImageView *animView; /// 显示文本的label @property (nonatomic, strong) UILabel *label; /** * 懒加载显示图片的imageView * @return 显示图片的imageView */ - (UIImageView *)animView { if (_animView == nil) { _animView = [[UIImageView alloc] initWithImage:NormalImage]; } return _animView; } /** * 懒加载显示文字的label * @return 显示文字的label */ - (UILabel *)label { if (_label == nil) { _label = [[UILabel alloc] init]; _label.textColor = [UIColor darkGrayColor]; _label.font = [UIFont systemFontOfSize:16]; _label.text = NormalText; [_label sizeToFit]; } return _label; }
定义
setupUI方法,添加子控件和约束/// 设置UI - (void)setupUI { // 添加子控件 [self addSubview:self.animView]; [self addSubview:self.label]; self.animView.translatesAutoresizingMaskIntoConstraints = NO; self.label.translatesAutoresizingMaskIntoConstraints = NO; // 添加约束 /// 图片 NSLayoutConstraint *animViewCenterX = [NSLayoutConstraint constraintWithItem:self.animView attribute:NSLayoutAttributeCenterX relatedBy:NSLayoutRelationEqual toItem:self attribute:NSLayoutAttributeCenterX multiplier:1 constant:-30]; [self addConstraint:animViewCenterX]; NSLayoutConstraint *animViewCenterY = [NSLayoutConstraint constraintWithItem:self.animView attribute:NSLayoutAttributeCenterY relatedBy:NSLayoutRelationEqual toItem:self attribute:NSLayoutAttributeCenterY multiplier:1 constant:0]; [self addConstraint:animViewCenterY]; // label NSLayoutConstraint *labelViewCenterX = [NSLayoutConstraint constraintWithItem:self.label attribute:NSLayoutAttributeLeading relatedBy:NSLayoutRelationEqual toItem:self.animView attribute:NSLayoutAttributeTrailing multiplier:1 constant:15]; [self addConstraint:labelViewCenterX]; NSLayoutConstraint *labelViewCenterY = [NSLayoutConstraint constraintWithItem:self.label attribute:NSLayoutAttributeCenterY relatedBy:NSLayoutRelationEqual toItem:self attribute:NSLayoutAttributeCenterY multiplier:1 constant:0]; [self addConstraint:labelViewCenterY]; }- 在
initWithFrame:调用setupUI 在
currentStatus属性的set方法中来根据当前状态设置对应的UI界面/** * 设置当前状态 * @param currentStatus 当前状态 */ - (void)setCurrentStatus:(CZPullDownRefreshStatus)currentStatus { // 设置属性 _currentStatus = currentStatus; // 设置完属性,判断当前状态 switch (_currentStatus) { case CZPullDownRefreshStatusNormal: { self.label.text = NormalText; self.animView.image = NormalImage; break; } case CZPullDownRefreshStatusPulling: { self.label.text = PullingText; self.animView.image = PullingImage; break; } case CZPullDownRefreshStatusRefreshing: { break; } } }- 拖入需要的图片素材
- 运行

设置
CZPullDownRefreshStatusRefreshing状态View的显示case CZPullDownRefreshStatusRefreshing: { self.label.text = RefreshingText; self.animView.animationImages = self.refreshingImages; self.animView.animationDuration = self.refreshingImages.count * 0.1; [self.animView startAnimating]; break; }发现
CZPullDownRefreshStatusRefreshing下拉刷新控件退到了导航栏后面被挡住了,让它停到导航栏下方,改动tableView的contentInset.top,contentInset.top在当前基础上添加 下拉刷新控件的高度case CZPullDownRefreshStatusRefreshing: { self.label.text = RefreshingText; self.animView.animationImages = self.refreshingImages; self.animView.animationDuration = self.refreshingImages.count * 0.1; [self.animView startAnimating]; // 停到导航栏下方,`contentInset.top` 在当前基础上添加 下拉刷新控件的高度 [UIView animateWithDuration:0.25 animations:^{ UIEdgeInsets insets = self.superScrollView.contentInset; self.superScrollView.contentInset = UIEdgeInsetsMake(insets.top + CZRefreshControlHeight, insets.left, insets.bottom, insets.right); }]; break; }- 运行:

当处于正在刷新状态时,需要让控制器去加载数据,通过block回调形式让控制器去加载数据
在
QSPullDownRefreshControl.h定义回调的block属性/** * 回调block */ @property (nonatomic, copy) void(^refreshingHandler)();在 切换到
CZPullDownRefreshStatusRefreshing时调用blockcase CZPullDownRefreshStatusRefreshing: { self.label.text = RefreshingText; self.animView.animationImages = self.refreshingImages; self.animView.animationDuration = self.refreshingImages.count * 0.1; [self.animView startAnimating]; // 停到导航栏下方,`contentInset.top` 在当前基础上添加 下拉刷新控件的高度 [UIView animateWithDuration:0.25 animations:^{ UIEdgeInsets insets = self.superScrollView.contentInset; self.superScrollView.contentInset = UIEdgeInsetsMake(insets.top + CZRefreshControlHeight, insets.left, insets.bottom, insets.right); }]; // 调用block if (self.refreshingHandler) { self.refreshingHandler(); } break; }在
QSRefreshController控制器定义一个block并传入给下拉刷新控件/** * 设置下拉刷新控件 */ - (void)setupPullDownRefreshControl { [self.tableView addSubview:self.pullDownloadRefreshControl]; __weak typeof(self) weakSelf = self; // 设置下拉刷新的block self.pullDownloadRefreshControl.refreshingHandler = ^() { // 模拟耗时操作 dispatch_async(dispatch_get_global_queue(0, 0), ^{ // 睡2秒 [NSThread sleepForTimeInterval:2]; [weakSelf loadData:^(NSArray *cities) { NSMutableArray *newCities = [NSMutableArray arrayWithArray:cities]; [newCities addObjectsFromArray:weakSelf.cities]; // 回到主线程 dispatch_async(dispatch_get_main_queue(), ^{ weakSelf.cities = newCities; [weakSelf.tableView reloadData]; }); }]; }); }; }- 运行

可以加载到新数据,但是刷新控件并没有回到正常状态,而是一直停在了 刷新状态,当加载到数据需要让下拉刷新控件切花到正常状态,最好不要在控制器里面直接去设置下拉刷新控件的状态.应该让下拉刷新控件提供一个结束刷新的方法
/** * 结束刷新 */ - (void)endRefreshing { // 从刷新状态切换到正常状态 if (self.currentStatus == CZPullDownRefreshStatusRefreshing) { self.currentStatus = CZPullDownRefreshStatusNormal; } }在控制器加载到数据的时候结束刷新
// 回到主线程 dispatch_async(dispatch_get_main_queue(), ^{ // 刷新控件结束刷新 [weakSelf.pullDownloadRefreshControl endRefreshing]; weakSelf.cities = newCities; [weakSelf.tableView reloadData]; });- 运行

发现从
正在刷新状态切换到正常状态,需要停止动画,下拉刷新控件要退回到导航栏下面在正常状态时停止动画
case CZPullDownRefreshStatusNormal: { // 从正在刷新状态切换回来.停止动画,清除动画图片 [self.animView stopAnimating]; self.animView.animationImages = nil; self.label.text = NormalText; self.animView.image = NormalImage; break; }在
endRefreshing设置tableView往上走下拉刷新控件的高度,让下拉刷新控件在导航栏下面- (void)endRefreshing { // 从刷新状态切换到正常状态 if (self.currentStatus == CZPullDownRefreshStatusRefreshing) { self.currentStatus = CZPullDownRefreshStatusNormal; // 正在刷新切换到正常状态,tableView往上走下拉刷新控件的高度,让下拉刷新控件在导航栏下面 [UIView animateWithDuration:0.25 animations:^{ UIEdgeInsets newInsets = self.superScrollView.contentInset; self.superScrollView.contentInset = UIEdgeInsetsMake(newInsets.top - CZRefreshControlHeight, newInsets.left, newInsets.bottom, newInsets.right); }]; } }- 运行

- 到目前下拉刷新功能已经实现了.但是每次进入到程序第一次加载数据的时候没有显示下拉刷新动作,一进入程序就下拉刷新
给下拉刷新控件添加主动进入刷新的方法,不用用户用手拖拽也能进入刷新
在
QSPullDownRefreshControl.h添加开始刷新方法/** * 开始刷新 */ - (void)beginRefreshing;实现
beginRefreshing/** * 开始刷新 */ - (void)beginRefreshing { if (self.currentStatus == CZPullDownRefreshStatusNormal) { self.currentStatus = CZPullDownRefreshStatusRefreshing; } }- 在
QSRefreshController的setupPullDownRefreshControl调用beginRefreshing让刷新控件主动进入刷新 - 运行

- 到此,一个自定义刷新控件就完成了.