One approach is to create your own "refresh view" and:
- constrain it to the top of the scroll view
- add a pan gesture to the scroll view
- if the user drags down, move the "refresh view" down
- when it's fully visible, run your data refresh
- animate it back away
Here's a quick example:
class RefreshVC: UIViewController {
let scrollView: UIScrollView = UIScrollView()
let contentView: UIView = UIView()
let contentLabel: UILabel = UILabel()
let myRefreshView: UIView = UIView()
let activityView: UIActivityIndicatorView = UIActivityIndicatorView()
var cBottom: NSLayoutConstraint!
var myData: [String] = []
override func viewDidLoad() {
super.viewDidLoad()
[contentLabel, contentView, scrollView, myRefreshView, activityView].forEach { v in
v.translatesAutoresizingMaskIntoConstraints = false
}
myRefreshView.addSubview(activityView)
contentView.addSubview(contentLabel)
scrollView.addSubview(contentView)
scrollView.addSubview(myRefreshView)
view.addSubview(scrollView)
let g = view.safeAreaLayoutGuide
let cg = scrollView.contentLayoutGuide
let fg = scrollView.frameLayoutGuide
NSLayoutConstraint.activate([
scrollView.topAnchor.constraint(equalTo: g.topAnchor, constant: 80.0),
scrollView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 40.0),
scrollView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -40.0),
scrollView.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: -80.0),
contentView.topAnchor.constraint(equalTo: cg.topAnchor, constant: 8.0),
contentView.leadingAnchor.constraint(equalTo: cg.leadingAnchor, constant: 8.0),
contentView.trailingAnchor.constraint(equalTo: cg.trailingAnchor, constant: -8.0),
contentView.bottomAnchor.constraint(equalTo: cg.bottomAnchor, constant: -8.0),
contentView.widthAnchor.constraint(equalTo: fg.widthAnchor, constant: -16.0),
contentLabel.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8.0),
contentLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 8.0),
contentLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -8.0),
contentLabel.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -8.0),
activityView.centerXAnchor.constraint(equalTo: myRefreshView.centerXAnchor),
activityView.centerYAnchor.constraint(equalTo: myRefreshView.centerYAnchor),
myRefreshView.widthAnchor.constraint(equalToConstant: 200.0),
myRefreshView.heightAnchor.constraint(equalToConstant: 100.0),
myRefreshView.centerXAnchor.constraint(equalTo: scrollView.centerXAnchor),
])
cBottom = myRefreshView.bottomAnchor.constraint(equalTo: fg.topAnchor)
cBottom.isActive = true
myRefreshView.backgroundColor = .white.withAlphaComponent(0.90)
myRefreshView.layer.cornerRadius = 12
myRefreshView.layer.borderColor = UIColor.black.cgColor
myRefreshView.layer.borderWidth = 1
//activityView.style = .large
activityView.color = .red
activityView.startAnimating()
scrollView.backgroundColor = .systemBlue
contentView.backgroundColor = .systemYellow
contentLabel.backgroundColor = .cyan
contentLabel.numberOfLines = 0
contentLabel.textAlignment = .center
contentLabel.font = .systemFont(ofSize: 40.0, weight: .bold)
// let's start with 5 lines of text as our content
myData = (1...5).compactMap({ "Line \($0)" })
contentLabel.setContentCompressionResistancePriority(.required, for: .vertical)
contentLabel.text = myData.joined(separator: "\n")
scrollView.bounces = false
let pg = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:)))
scrollView.addGestureRecognizer(pg)
pg.delegate = self
}
var startPT: CGPoint = .zero
var isRefreshing: Bool = false
@objc func handlePan(_ pan: UIPanGestureRecognizer) {
guard let sv = pan.view as? UIScrollView,
isRefreshing == false
else { return }
let curPT = pan.location(in: view)
switch pan.state {
case .began:
// we only want to "pull down" the refresh view if
// we start dragging when the scroll view is all the
// way at the top
if sv.contentOffset.y == 0 {
startPT = curPT
} else {
startPT.y = .greatestFiniteMagnitude
}
case .changed:
let diff = curPT.y - startPT.y
// if we are dragging down
if diff > 0 {
// if the scroll view content is at the top
if sv.contentOffset.y == 0 {
scrollView.isScrollEnabled = false
// move the refresh view down
cBottom.constant = min(diff, myRefreshView.frame.height + 4.0)
// if the refresh view is fully down
if cBottom.constant == myRefreshView.frame.height + 4.0 {
isRefreshing = true
refreshContent()
}
}
}
default:
// if the refresh view has not been pulled all the way
// when drag ended / was cancelled
// animate it back up
print("done", cBottom.constant)
self.scrollView.isScrollEnabled = true
if cBottom.constant > 0.0, cBottom.constant < myRefreshView.frame.height + 4.0 {
cBottom.constant = 0
UIView.animate(withDuration: 0.3, animations: {
self.view.layoutIfNeeded()
})
}
}
}
@objc func refreshContent() {
// let's simulate a 1-second refresh task
// and add a line to the scroll view content
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0, execute: {
self.myData.append("Line \(self.myData.count + 1)")
self.contentLabel.text = self.myData.joined(separator: "\n")
// animate the refresh view back up
DispatchQueue.main.async {
self.cBottom.constant = 0
UIView.animate(withDuration: 0.3, animations: {
self.view.layoutIfNeeded()
}, completion: {_ in
self.isRefreshing = false
self.scrollView.isScrollEnabled = true
})
}
})
}
}
extension RefreshVC: UIGestureRecognizerDelegate {
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
return true
}
}
and it looks like this when running:

Once you've added enough lines, the scroll view will scroll... and the "refresh view" will only get pulled-down if the scroll view is scrolled all the way to the top.
Note: this is EXAMPLE CODE ONLY!!!
It is just to give you a start. You would likely want to tweak the distances, interactive capabilities, etc.