UIRefreshControl
UIScrollView
iOS development
Swift programming
iOS UI components

Can I use a UIRefreshControl in a UIScrollView?

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

Introduction

Yes, you can use UIRefreshControl with UIScrollView, and this is a common pattern for pull to refresh outside table views. The exact integration depends on iOS version and whether you use plain UIKit or custom scroll layouts. A clean setup attaches the refresh control once, triggers async reload, then ends refreshing on the main thread.

iOS Behavior and Supported Approaches

For modern iOS, UIScrollView supports refreshControl directly. For older compatibility targets, developers used addSubview on the scroll view.

Modern style:

swift
scrollView.refreshControl = refreshControl

Compatibility style:

swift
scrollView.addSubview(refreshControl)

If your app supports only recent iOS versions, prefer the property based API for clarity.

Basic Implementation in a View Controller

swift
1import UIKit
2
3final class FeedViewController: UIViewController {
4    private let scrollView = UIScrollView()
5    private let stackView = UIStackView()
6    private let refresh = UIRefreshControl()
7
8    override func viewDidLoad() {
9        super.viewDidLoad()
10        view.backgroundColor = .systemBackground
11
12        setupLayout()
13        setupRefresh()
14        loadContent()
15    }
16
17    private func setupRefresh() {
18        refresh.attributedTitle = NSAttributedString(string: "Pull to refresh")
19        refresh.addTarget(self, action: #selector(refreshTriggered), for: .valueChanged)
20        scrollView.refreshControl = refresh
21    }
22
23    @objc private func refreshTriggered() {
24        Task {
25            await reloadData()
26            await MainActor.run {
27                self.refresh.endRefreshing()
28            }
29        }
30    }
31
32    private func setupLayout() {
33        scrollView.translatesAutoresizingMaskIntoConstraints = false
34        stackView.axis = .vertical
35        stackView.spacing = 12
36        stackView.translatesAutoresizingMaskIntoConstraints = false
37
38        view.addSubview(scrollView)
39        scrollView.addSubview(stackView)
40
41        NSLayoutConstraint.activate([
42            scrollView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
43            scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
44            scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
45            scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
46
47            stackView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor, constant: 16),
48            stackView.leadingAnchor.constraint(equalTo: scrollView.frameLayoutGuide.leadingAnchor, constant: 16),
49            stackView.trailingAnchor.constraint(equalTo: scrollView.frameLayoutGuide.trailingAnchor, constant: -16),
50            stackView.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor, constant: -16)
51        ])
52    }
53
54    private func loadContent() {
55        for i in 1...20 {
56            let label = UILabel()
57            label.text = "Item \(i)"
58            stackView.addArrangedSubview(label)
59        }
60    }
61
62    private func reloadData() async {
63        try? await Task.sleep(nanoseconds: 1_000_000_000)
64    }
65}

This example works with plain UIScrollView and vertical content.

Ensure Refresh Gesture Can Trigger

Pull to refresh requires enough vertical scroll interaction. If content is shorter than viewport, set always bounce vertical.

swift
scrollView.alwaysBounceVertical = true

Without this, users may not be able to drag enough to trigger refresh on short content.

Integrating with Async Network Calls

Refresh handlers should:

  1. start network request
  2. update UI state on success or failure
  3. call endRefreshing on main thread

A typical pattern uses async tasks or completion handlers with weak self capture to avoid retain cycles.

swift
1@objc private func refreshTriggered() {
2    APIClient.shared.fetchFeed { [weak self] result in
3        DispatchQueue.main.async {
4            guard let self else { return }
5            switch result {
6            case .success(let items):
7                self.apply(items)
8            case .failure(let error):
9                self.showError(error)
10            }
11            self.refresh.endRefreshing()
12        }
13    }
14}

Visual and UX Considerations

Keep refresh interaction predictable:

  • avoid nested scroll views competing for gesture ownership
  • show clear loading feedback if refresh takes longer than one second
  • prevent duplicate concurrent refresh requests

Simple guard:

swift
guard !refresh.isRefreshing else { return }

This avoids stacked requests during aggressive pull gestures.

Common Pitfalls

A common pitfall is forgetting endRefreshing, leaving spinner active forever and confusing users.

Another issue is running UI updates from background queue after network completion. Always return to main thread before changing views.

A third issue is disabling vertical bounce on short content, which makes refresh gesture appear broken.

Teams also place UIRefreshControl on inner views inside the scroll hierarchy instead of the scroll view itself, which can break gesture behavior.

Summary

  • UIRefreshControl works with UIScrollView and is widely used
  • Use scrollView.refreshControl in modern iOS projects
  • Set alwaysBounceVertical for short content pull support
  • End refreshing on main thread after async work completes
  • Avoid nested scroll conflicts and duplicate refresh requests

Course illustration
Course illustration

All Rights Reserved.