iOS-继承UIControl 实现自定义下拉刷新控件的封装

demo地址
本文目的是了解下拉刷新控件的实现原理, 效果图如下:

自定义下拉刷新

  • 自定义下拉刷新控件分析

    • 使用什么自定义?
      • UIControl
    • 添加到谁身上?
      • 列表页上(UITableView 等)
    • 控件的Y 轴
      • 负的控件高度
    • 自定义刷新控件有三种状态
      • 正常中
      • 下拉中
      • 刷新中
    • 如何知道当前控件处于什么状态?(状态在滑动列表的时候就改变了)
    • 通过监听列表的偏移量, 来改变控件所处的状态
      • ContentOffset.Y
      • 在RefreshControl 中监听ContentOffset.Y 的变化
      • 也就是说在RefreshControl 监听UITableView 的ContentOffset.Y 变化, 实现手段:
        • 代理 -> 否定 (因为代理是一对一的, 多处同时需要使用RefreshControl 时, 就会出现混乱)
        • KVO -> 可以 (一对多)
  • 关于偏移量的问题分析 (越往下拉, ContentOffset.Y 越来越小, ContentOffset.Y 的绝对值越来越大)

    • 如果 y >= 负的(导航栏高度 + RefreshControl 自身的高度) -> 代表正常中
    • 如果 y < 负的(导航栏高度 + RefreshControl 自身的高度) -> 代表又继续下拉了 , 也就是下拉中, 此时
      • 用户松手了, 那就变成刷新中.
      • 用户没松手 -> 恢复成 下拉中 -> 正常中
    • 逻辑优化: 判断用户是都在拖动列表, 并且是否松手

      • 如果没有松手
        • 状态为 正常中 或者 下拉中
          • y >= 负的(导航栏高度 + RefreshControl 自身的高度) -> 正常状态
          • y < 负的(导航栏高度 + RefreshControl 自身的高度) -> 下拉状态
      • 如果松手
        • 如果状态为下拉中 -> 刷新中

根据逻辑优化, 逐步从代码上开始讲解分析

1. 创建继承自UIControl 的RefreshControl作为自定义刷新控件

import UIKit

// 抽取刷新控件的高度
private let RefreshControlHeight: CGFloat = 50
// 刷新控件当前的状态类型
enum RefreshControlType: String {
    case normal = "正常中"
    case pulling = "下拉中"
    case refreshing = "刷新中"
}

class RefreshControl: UIControl {

    // MARK: - 记录列表(superView)
    private var scrollView: UIScrollView?
    
    // MARK: - 实时记录刷新控件的状态
    private var refreshType: RefreshControlType = .normal

    override init(frame: CGRect) {
        // 设置自定义刷新控件的大小
        super.init(frame: CGRect(x: 0, y: -RefreshControlHeight, width: UIScreen.main.bounds.width, height: RefreshControlHeight))
        // 添加其他子控件
        setupUI()
    }

     required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

2. func willMove(toSuperview newSuperview: UIView?) 方法获取当前对象将要加载的父控件

    // MARK: 监听当前对象将要加载到父控件上
    override func willMove(toSuperview newSuperview: UIView?) {
        // 判断newSuperview 不为nil, 且能够滚动
        guard let scrollView = newSuperview as? UIScrollView else { return }
        
        // 赋值全局变量 -> 值就是以后要刷新的列表对象
        self.scrollView = scrollView
    }

3. 通过KVO 来监听可滚动列表的contentOffset 属性变化

        // KVO 监听scrollView 的contentOffset 属性变化
        // 1. 注册KVO - 监听新值(NSKeyValueObservingOptions.new)变化
        scrollView.addObserver(self, forKeyPath: "contentOffset", options: NSKeyValueObservingOptions.new, context: nil)

接下来实现观察者回调方法:(核心逻辑)

    // 2. 观察者中实现的方法
    override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
        
        // 这两个打印结果相同 , 也就表示change 中就是监听的结果
        // print(change?[NSKeyValueChangeKey(rawValue: "new")] as Any)
        // print(self.scrollView!.contentOffset.y)
        
        // 定义偏移临界值
        let criticalValue = -(RefreshControlHeight + CGFloat(NaviHeight))
        
        // 定义下拉偏移的大小
        let contentOffsetY = self.scrollView!.contentOffset.y
        
        // 判断用户是否在拖动中
        if self.scrollView!.isDragging {
            
            // 拖动中
            if contentOffsetY >= criticalValue && refreshType == .pulling {
                // 当 偏移量 >= 临界值, 代表向下拉的距离没超过临界值, 且当前状态为 下拉中, 这是要切换状态为 -> 正常中
                refreshType = .normal
            } else if contentOffsetY < criticalValue && refreshType == .normal {
                // 当 偏移量 < 临界值, 代表向下拉的距离更大, 且当前状态为 正常中, 这是要切换状态为 -> 下拉中
                refreshType = .pulling
            }
        } else{
            // 没有拖动, 也就是松手了
            // 只关心 刷新状态为下拉中时 松开手 , 此时切换状态为 刷新中
            if refreshType == .pulling {
                refreshType = .refreshing
            }
        }
    }

不要忘记移除KVO

    deinit {
        // 3. 移除KVO
        self.scrollView!.removeObserver(self, forKeyPath: "contentOffset")
    }

4. 使用

    override func viewDidLoad() {
        super.viewDidLoad()
        // 添加刷新控件
        tableView.addSubview(refreshControl)
        // 监听刷新事件
        refreshControl.addTarget(self, action: #selector(refreshAction), for: UIControl.Event.valueChanged)
    }
    
    @objc private func refreshAction() {
        // 模仿网络请求数据
        DispatchQueue.global().asyncAfter(deadline: DispatchTime.now() + 3) {
            // 数据请求结束, 结束刷新动画
            self.refreshControl.endRefreshing()
        }
    }

5. 最后附上RefreshControl.swift 的完整代码

import UIKit

// 抽取刷新控件的高度
private let RefreshControlHeight: CGFloat = 50
// 刷新控件当前的状态类型
enum RefreshControlType: String {
    case normal = "正常中"
    case pulling = "下拉中"
    case refreshing = "刷新中"
}

class RefreshControl: UIControl {
    
    // MARK: - 提供给外界调用, 结束刷新动画
    func endRefreshing() {
        // 修改刷新状态 为 正常中
        refreshType = .normal
    }
    
    // MARK: - 记录列表(superView)
    private var scrollView: UIScrollView?
    
    // MARK: - 实时记录刷新控件的状态
    private var refreshType: RefreshControlType = .normal{
        didSet{
            
            DispatchQueue.main.async {
                
                // MARK: 通过枚举名称获得枚举值
                self.tipsLabel.text = self.refreshType.rawValue
                
                switch self.refreshType {
                case .normal:
                    // print("正常中")
                    // 修改下拉箭头朝向 -> 恢复原状
                     UIView.animate(withDuration: 0.25, animations: {
                        self.arrowImageView.transform = CGAffineTransform.identity
                     }) { (_) in
                         
                     }
                    // 判断refreshType 上一个状态是否为refreshing
                    if oldValue == .refreshing {
                        
                        // 停止loading动画, 显示箭头
                        self.indicatorView.stopAnimating()
                        self.arrowImageView.isHidden = false
                        
                        UIView.animate(withDuration: 0.25, animations: {
                            self.scrollView!.contentInset.top = self.scrollView!.contentInset.top - RefreshControlHeight
                        }) { (_) in
                            
                        }
                    }
                case .pulling:
                    // print("下拉中")
                    // 修改下拉箭头朝向 -> 由下朝上
                    UIView.animate(withDuration: 0.25, animations: {
                        self.arrowImageView.transform = CGAffineTransform(rotationAngle: CGFloat(Double.pi))
                    }) { (_) in
                        
                    }
                case .refreshing:
                    // print("刷新中")
                    
                    // 隐藏箭头, 开启loading动画
                    self.arrowImageView.isHidden = true
                    self.indicatorView.startAnimating()
                    
                    // 在动画中设置顶部inset 否则会特别生硬, 注释掉看效果即可.
                    UIView.animate(withDuration: 0.25, animations: {
                        self.scrollView!.contentInset.top = self.scrollView!.contentInset.top + RefreshControlHeight
                    }) { (_) in
                        // 动画结束, 告知外界开始刷新数据 (UIControl 的方法, 外界注册addTarget, Event 相同就能获取到事件)
                        self.sendActions(for: UIControl.Event.valueChanged)
                    }
                }
            }
        }
    }
    
    override init(frame: CGRect) {
        super.init(frame: CGRect(x: 0, y: -RefreshControlHeight, width: UIScreen.main.bounds.width, height: RefreshControlHeight))
        setupUI()
    }
    
    // MARK: 监听当前对象将要加载到父控件上
    override func willMove(toSuperview newSuperview: UIView?) {
        // 判断newSuperview 不为nil, 且能够滚动
        guard let scrollView = newSuperview as? UIScrollView else { return }
        
        // 赋值全局变量
        self.scrollView = scrollView
        
        // KVO 监听scrollView 的contentOffset 属性变化
        // 1. 注册KVO - 监听新值(NSKeyValueObservingOptions.new)变化
        scrollView.addObserver(self, forKeyPath: "contentOffset", options: NSKeyValueObservingOptions.new, context: nil)
    }
    
    // 2. 观察者中实现的方法
    override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
        
        // 这两个打印结果相同 , 也就表示change 中就是监听的结果
        // print(change?[NSKeyValueChangeKey(rawValue: "new")] as Any)
        // print(self.scrollView!.contentOffset.y)
        
        // 定义偏移临界值
        let criticalValue = -(RefreshControlHeight + CGFloat(NaviHeight))
        
        // 定义下拉偏移的大小
        let contentOffsetY = self.scrollView!.contentOffset.y
        
        // 判断用户是否在拖动中
        if self.scrollView!.isDragging {
            
            // 拖动中
            if contentOffsetY >= criticalValue && refreshType == .pulling {
                // 当 偏移量 >= 临界值, 代表向下拉的距离没超过临界值, 且当前状态为 下拉中, 这是要切换状态为 -> 正常中
                refreshType = .normal
            } else if contentOffsetY < criticalValue && refreshType == .normal {
                // 当 偏移量 < 临界值, 代表向下拉的距离更大, 且当前状态为 正常中, 这是要切换状态为 -> 下拉中
                refreshType = .pulling
            }
        } else{
            // 没有拖动, 也就是松手了
            // 只关心 刷新状态为下拉中时 松开手 , 此时切换状态为 刷新中
            if refreshType == .pulling {
                refreshType = .refreshing
            }
        }
    }
    
    private func setupUI() {
        backgroundColor = .orange
        
        // 添加控件
        addSubview(tipsLabel)
        addSubview(arrowImageView)
        addSubview(indicatorView)
        
        // 设置约束 (注: 原生约束千万要加 translatesAutoresizingMaskIntoConstraints, 否则会有autoresize 生成的constraints , 导致冲突, 也就是代码设置的约束不管用了.)
        tipsLabel.translatesAutoresizingMaskIntoConstraints = false
        addConstraint(NSLayoutConstraint(item: tipsLabel, attribute: NSLayoutConstraint.Attribute.centerX, relatedBy: .equal, toItem: self, attribute: NSLayoutConstraint.Attribute.centerX, multiplier: 1, constant: 0))
        addConstraint(NSLayoutConstraint(item: tipsLabel, attribute: NSLayoutConstraint.Attribute.centerY, relatedBy: .equal, toItem: self, attribute: NSLayoutConstraint.Attribute.centerY, multiplier: 1, constant: 0))
        
        arrowImageView.translatesAutoresizingMaskIntoConstraints = false
        addConstraint(NSLayoutConstraint(item: arrowImageView, attribute: NSLayoutConstraint.Attribute.centerX, relatedBy: .equal, toItem: self, attribute: NSLayoutConstraint.Attribute.centerX, multiplier: 1, constant: -35))
        addConstraint(NSLayoutConstraint(item: arrowImageView, attribute: NSLayoutConstraint.Attribute.centerY, relatedBy: .equal, toItem: self, attribute: NSLayoutConstraint.Attribute.centerY, multiplier: 1, constant: 0))
        
        indicatorView.translatesAutoresizingMaskIntoConstraints = false
         addConstraint(NSLayoutConstraint(item: indicatorView, attribute: NSLayoutConstraint.Attribute.centerX, relatedBy: .equal, toItem: self, attribute: NSLayoutConstraint.Attribute.centerX, multiplier: 1, constant: -35))
         addConstraint(NSLayoutConstraint(item: indicatorView, attribute: NSLayoutConstraint.Attribute.centerY, relatedBy: .equal, toItem: self, attribute: NSLayoutConstraint.Attribute.centerY, multiplier: 1, constant: 0))
    }
    
    // MARK: 懒加载控件
    // 提示label
    private lazy var tipsLabel: UILabel = {
        let lab = UILabel()
        lab.textColor = .white
        lab.font = UIFont.systemFont(ofSize: 14)
        lab.textAlignment = .center
        lab.text = "正常中"
        return lab
    }()
    
    // 上下拉 箭头ImageView
    private lazy var arrowImageView: UIImageView = UIImageView(image: UIImage(named: "tableview_pull_refresh"))
    
    // 刷新时的 loading
    private lazy var indicatorView: UIActivityIndicatorView = UIActivityIndicatorView(style: .medium)
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    deinit {
        // 3. 移除KVO
        self.scrollView!.removeObserver(self, forKeyPath: "contentOffset")
    }
}

.End

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 222,252评论 6 516
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 94,886评论 3 399
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 168,814评论 0 361
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 59,869评论 1 299
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 68,888评论 6 398
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 52,475评论 1 312
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 41,010评论 3 422
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,924评论 0 277
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 46,469评论 1 319
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 38,552评论 3 342
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 40,680评论 1 353
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 36,362评论 5 351
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 42,037评论 3 335
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 32,519评论 0 25
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,621评论 1 274
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 49,099评论 3 378
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 45,691评论 2 361

推荐阅读更多精彩内容