实现效果

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

实现思路

  • tableView 添加一个自定义刷新控件(CZPullDownRefreshControl)
  • 这个刷新控件的 y 值是 自己的高度,以让其放在 tableView 的顶部以及可以跟随 tableView 滑动
  • 刷新控件有 3 中状态, 下拉刷新释放更新正在刷新…
  • 在刷新控件 内部 监听 tableView 的滑动
  • 当滑动到一定程度去改变刷新控件的状态
  • 当用户松开手要刷新的时候,需要调整 tableViewcontentInsettop 值以让刷新控件显示出来
  • 在刷新的时候调用外部提供的方法执行刷新的逻辑

实现

准备工作
  • 创建工程
  • 创建 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;
      
    • QSRefreshControllerviewDidLoad 调用 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],系统会调用到 QSPullDownRefreshControlinitWithFrame:,传入一个为 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: 方法里面是监听不到的.因为刚创建下拉刷新的时候,它还没有父控件.只有等下拉刷新控件有父控件的时候才能拿到父控件来监听.因此在 QSPullDownRefreshControlwillMoveToSuperview: 来监听,监听 tableViewcontentOffset 属性值的改变,因此使用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"];
      }
    
  • 运行: 在下拉刷新控件里面可以监听到 tableViewcontentOffset 的改变 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 ==> CZPullDownRefreshStatusNormalCZPullDownRefreshStatusPulling ==> 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 下拉刷新控件退到了导航栏后面被挡住了,让它停到导航栏下方,改动 tableViewcontentInset.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 时调用block

        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);
            }];
      
            // 调用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;
            }
        }
      
    • QSRefreshControllersetupPullDownRefreshControl 调用 beginRefreshing 让刷新控件主动进入刷新
    • 运行
  • 到此,一个自定义刷新控件就完成了.

results matching ""

    No results matching ""