demo地址
本文目的是了解下拉刷新控件的實(shí)現(xiàn)原理, 效果圖如下:
-
自定義下拉刷新控件分析
-
使用什么自定義?
- UIControl
-
添加到誰身上?
- 列表頁上(UITableView 等)
-
控件的Y 軸
- 負(fù)的控件高度
-
自定義刷新控件有三種狀態(tài)
- 正常中
- 下拉中
- 刷新中
-
如何知道當(dāng)前控件處于什么狀態(tài)?(狀態(tài)在滑動(dòng)列表的時(shí)候就改變了)
-
通過監(jiān)聽列表的偏移量, 來改變控件所處的狀態(tài)
- ContentOffset.Y
- 在RefreshControl 中監(jiān)聽ContentOffset.Y 的變化
- 也就是說在RefreshControl 監(jiān)聽UITableView 的ContentOffset.Y 變化, 實(shí)現(xiàn)手段:
- 代理 -> 否定 (因?yàn)榇硎且粚?duì)一的, 多處同時(shí)需要使用RefreshControl 時(shí), 就會(huì)出現(xiàn)混亂)
- KVO -> 可以 (一對(duì)多)
-
-
關(guān)于偏移量的問題分析 (越往下拉, ContentOffset.Y 越來越小, ContentOffset.Y 的絕對(duì)值越來越大)
-
如果 y >= 負(fù)的(導(dǎo)航欄高度 + RefreshControl 自身的高度) -> 代表正常中
-
如果 y < 負(fù)的(導(dǎo)航欄高度 + RefreshControl 自身的高度) -> 代表又繼續(xù)下拉了 , 也就是下拉中, 此時(shí)
- 用戶松手了, 那就變成刷新中.
- 用戶沒松手 -> 恢復(fù)成 下拉中 -> 正常中
-
邏輯優(yōu)化: 判斷用戶是都在拖動(dòng)列表, 并且是否松手
- 如果沒有松手
- 狀態(tài)為 正常中 或者 下拉中
- y >= 負(fù)的(導(dǎo)航欄高度 + RefreshControl 自身的高度) -> 正常狀態(tài)
- y < 負(fù)的(導(dǎo)航欄高度 + RefreshControl 自身的高度) -> 下拉狀態(tài)
- 狀態(tài)為 正常中 或者 下拉中
- 如果松手
- 如果狀態(tài)為下拉中 -> 刷新中
- 如果沒有松手
-
根據(jù)邏輯優(yōu)化, 逐步從代碼上開始講解分析
1. 創(chuàng)建繼承自UIControl 的RefreshControl作為自定義刷新控件
import UIKit
// 抽取刷新控件的高度
private let RefreshControlHeight: CGFloat = 50
// 刷新控件當(dāng)前的狀態(tài)類型
enum RefreshControlType: String {
case normal = "正常中"
case pulling = "下拉中"
case refreshing = "刷新中"
}
class RefreshControl: UIControl {
// MARK: - 記錄列表(superView)
private var scrollView: UIScrollView?
// MARK: - 實(shí)時(shí)記錄刷新控件的狀態(tài)
private var refreshType: RefreshControlType = .normal
override init(frame: CGRect) {
// 設(shè)置自定義刷新控件的大小
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?) 方法獲取當(dāng)前對(duì)象將要加載的父控件
// MARK: 監(jiān)聽當(dāng)前對(duì)象將要加載到父控件上
override func willMove(toSuperview newSuperview: UIView?) {
// 判斷newSuperview 不為nil, 且能夠滾動(dòng)
guard let scrollView = newSuperview as? UIScrollView else { return }
// 賦值全局變量 -> 值就是以后要刷新的列表對(duì)象
self.scrollView = scrollView
}
3. 通過KVO 來監(jiān)聽可滾動(dòng)列表的contentOffset 屬性變化
// KVO 監(jiān)聽scrollView 的contentOffset 屬性變化
// 1. 注冊(cè)KVO - 監(jiān)聽新值(NSKeyValueObservingOptions.new)變化
scrollView.addObserver(self, forKeyPath: "contentOffset", options: NSKeyValueObservingOptions.new, context: nil)
接下來實(shí)現(xiàn)觀察者回調(diào)方法
:(核心邏輯)
// 2. 觀察者中實(shí)現(xiàn)的方法
override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
// 這兩個(gè)打印結(jié)果相同 , 也就表示change 中就是監(jiān)聽的結(jié)果
// print(change?[NSKeyValueChangeKey(rawValue: "new")] as Any)
// print(self.scrollView!.contentOffset.y)
// 定義偏移臨界值
let criticalValue = -(RefreshControlHeight + CGFloat(NaviHeight))
// 定義下拉偏移的大小
let contentOffsetY = self.scrollView!.contentOffset.y
// 判斷用戶是否在拖動(dòng)中
if self.scrollView!.isDragging {
// 拖動(dòng)中
if contentOffsetY >= criticalValue && refreshType == .pulling {
// 當(dāng) 偏移量 >= 臨界值, 代表向下拉的距離沒超過臨界值, 且當(dāng)前狀態(tài)為 下拉中, 這是要切換狀態(tài)為 -> 正常中
refreshType = .normal
} else if contentOffsetY < criticalValue && refreshType == .normal {
// 當(dāng) 偏移量 < 臨界值, 代表向下拉的距離更大, 且當(dāng)前狀態(tài)為 正常中, 這是要切換狀態(tài)為 -> 下拉中
refreshType = .pulling
}
} else{
// 沒有拖動(dòng), 也就是松手了
// 只關(guān)心 刷新狀態(tài)為下拉中時(shí) 松開手 , 此時(shí)切換狀態(tài)為 刷新中
if refreshType == .pulling {
refreshType = .refreshing
}
}
}
不要忘記移除KVO
deinit {
// 3. 移除KVO
self.scrollView!.removeObserver(self, forKeyPath: "contentOffset")
}
4. 使用
override func viewDidLoad() {
super.viewDidLoad()
// 添加刷新控件
tableView.addSubview(refreshControl)
// 監(jiān)聽刷新事件
refreshControl.addTarget(self, action: #selector(refreshAction), for: UIControl.Event.valueChanged)
}
@objc private func refreshAction() {
// 模仿網(wǎng)絡(luò)請(qǐng)求數(shù)據(jù)
DispatchQueue.global().asyncAfter(deadline: DispatchTime.now() + 3) {
// 數(shù)據(jù)請(qǐng)求結(jié)束, 結(jié)束刷新動(dòng)畫
self.refreshControl.endRefreshing()
}
}
5. 最后附上RefreshControl.swift 的完整代碼
import UIKit
// 抽取刷新控件的高度
private let RefreshControlHeight: CGFloat = 50
// 刷新控件當(dāng)前的狀態(tài)類型
enum RefreshControlType: String {
case normal = "正常中"
case pulling = "下拉中"
case refreshing = "刷新中"
}
class RefreshControl: UIControl {
// MARK: - 提供給外界調(diào)用, 結(jié)束刷新動(dòng)畫
func endRefreshing() {
// 修改刷新狀態(tài) 為 正常中
refreshType = .normal
}
// MARK: - 記錄列表(superView)
private var scrollView: UIScrollView?
// MARK: - 實(shí)時(shí)記錄刷新控件的狀態(tài)
private var refreshType: RefreshControlType = .normal{
didSet{
DispatchQueue.main.async {
// MARK: 通過枚舉名稱獲得枚舉值
self.tipsLabel.text = self.refreshType.rawValue
switch self.refreshType {
case .normal:
// print("正常中")
// 修改下拉箭頭朝向 -> 恢復(fù)原狀
UIView.animate(withDuration: 0.25, animations: {
self.arrowImageView.transform = CGAffineTransform.identity
}) { (_) in
}
// 判斷refreshType 上一個(gè)狀態(tài)是否為refreshing
if oldValue == .refreshing {
// 停止loading動(dòng)畫, 顯示箭頭
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動(dòng)畫
self.arrowImageView.isHidden = true
self.indicatorView.startAnimating()
// 在動(dòng)畫中設(shè)置頂部inset 否則會(huì)特別生硬, 注釋掉看效果即可.
UIView.animate(withDuration: 0.25, animations: {
self.scrollView!.contentInset.top = self.scrollView!.contentInset.top + RefreshControlHeight
}) { (_) in
// 動(dòng)畫結(jié)束, 告知外界開始刷新數(shù)據(jù) (UIControl 的方法, 外界注冊(cè)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: 監(jiān)聽當(dāng)前對(duì)象將要加載到父控件上
override func willMove(toSuperview newSuperview: UIView?) {
// 判斷newSuperview 不為nil, 且能夠滾動(dòng)
guard let scrollView = newSuperview as? UIScrollView else { return }
// 賦值全局變量
self.scrollView = scrollView
// KVO 監(jiān)聽scrollView 的contentOffset 屬性變化
// 1. 注冊(cè)KVO - 監(jiān)聽新值(NSKeyValueObservingOptions.new)變化
scrollView.addObserver(self, forKeyPath: "contentOffset", options: NSKeyValueObservingOptions.new, context: nil)
}
// 2. 觀察者中實(shí)現(xiàn)的方法
override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
// 這兩個(gè)打印結(jié)果相同 , 也就表示change 中就是監(jiān)聽的結(jié)果
// print(change?[NSKeyValueChangeKey(rawValue: "new")] as Any)
// print(self.scrollView!.contentOffset.y)
// 定義偏移臨界值
let criticalValue = -(RefreshControlHeight + CGFloat(NaviHeight))
// 定義下拉偏移的大小
let contentOffsetY = self.scrollView!.contentOffset.y
// 判斷用戶是否在拖動(dòng)中
if self.scrollView!.isDragging {
// 拖動(dòng)中
if contentOffsetY >= criticalValue && refreshType == .pulling {
// 當(dāng) 偏移量 >= 臨界值, 代表向下拉的距離沒超過臨界值, 且當(dāng)前狀態(tài)為 下拉中, 這是要切換狀態(tài)為 -> 正常中
refreshType = .normal
} else if contentOffsetY < criticalValue && refreshType == .normal {
// 當(dāng) 偏移量 < 臨界值, 代表向下拉的距離更大, 且當(dāng)前狀態(tài)為 正常中, 這是要切換狀態(tài)為 -> 下拉中
refreshType = .pulling
}
} else{
// 沒有拖動(dòng), 也就是松手了
// 只關(guān)心 刷新狀態(tài)為下拉中時(shí) 松開手 , 此時(shí)切換狀態(tài)為 刷新中
if refreshType == .pulling {
refreshType = .refreshing
}
}
}
private func setupUI() {
backgroundColor = .orange
// 添加控件
addSubview(tipsLabel)
addSubview(arrowImageView)
addSubview(indicatorView)
// 設(shè)置約束 (注: 原生約束千萬要加 translatesAutoresizingMaskIntoConstraints, 否則會(huì)有autoresize 生成的constraints , 導(dǎo)致沖突, 也就是代碼設(shè)置的約束不管用了.)
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"))
// 刷新時(shí)的 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