How to Implement Pull to Refresh on UITableView in Swift

How to Implement Pull to Refresh on UITableView in Swift

Written by

in

Quick answer: Instantiate a UIRefreshControl, add a target for the .valueChanged control event, and assign it directly to tableView.refreshControl. When data fetching completes, reload your table view and call refreshControl.endRefreshing() on the main thread.

Why this happens

Developers migrating to iOS often attempt to recreate pull-to-refresh mechanics manually by wiring a UIPanGestureRecognizer or UIScreenEdgePanGestureRecognizer to their view. This introduces gesture conflicts with the table view’s internal UIScrollView gestures, leads to initialization errors like Property 'self.refresh' not initialized at super.init call when outlets are misconfigured, and causes jittery scrolling transitions.

UIKit provides native support for pull-to-refresh interactions via UIRefreshControl. Prior to iOS 10, developers had to add a UIRefreshControl as a subview to a standard UITableView, which frequently introduced view hierarchy glitches or miscalculated content offsets. Modern UIKit provides a dedicated refreshControl property directly on UIScrollView (and by inheritance, UITableView), making custom gesture recognizers completely unnecessary.

A secondary issue arises around UI state management: failing to call endRefreshing() after updating your data source leaves the native spinning indicator visible indefinitely, blocking subsequent user interactions.

How to fix it

1. Assign UIRefreshControl to the Table View refreshControl Property

If you use a standard UIViewController hosting a UITableView, instantiate UIRefreshControl, bind an action using the target-action pattern, and assign the instance to tableView.refreshControl.

import UIKit

final class NewsFeedViewController: UIViewController, UITableViewDataSource, UITableViewDelegate {

    @IBOutlet private weak var tableView: UITableView!

    private let refreshControl = UIRefreshControl()
    private var articles: [String] = ["Article 1", "Article 2", "Article 3"]

    override func viewDidLoad() {
        super.viewDidLoad()
        setupTableView()
        setupRefreshControl()
    }

    private func setupTableView() {
        tableView.dataSource = self
        tableView.delegate = self
    }

    private func setupRefreshControl() {
        refreshControl.attributedTitle = NSAttributedString(string: "Pull to refresh")
        refreshControl.addTarget(self, action: #selector(handleRefreshControl), for: .valueChanged)
        tableView.refreshControl = refreshControl
    }

    @objc private func handleRefreshControl() {
        // Simulate fetching updated data
        fetchLatestData { [weak self] updatedArticles in
            guard let self = self else { return }
            self.articles = updatedArticles
            self.tableView.reloadData()
            self.refreshControl.endRefreshing()
        }
    }

    private func fetchLatestData(completion: @escaping ([String]) -> Void) {
        DispatchQueue.global().asyncAfter(deadline: .now() + 1.5) {
            let newItems = ["Breaking News 1", "Breaking News 2", "Breaking News 3"]
            DispatchQueue.main.async {
                completion(newItems)
            }
        }
    }

    // MARK: - UITableViewDataSource

    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return articles.count
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "NewsCell", for: indexPath)
        cell.textLabel?.text = articles[indexPath.row]
        return cell
    }
}

2. Configure Pull to Refresh in UITableViewController

UITableViewController provides a built-in refreshControl property out of the box. You do not need to create a new instance programmatically if you configure it via Storyboards, or you can manage it directly in code.

Storyboard Setup:

  1. Open your storyboard and select the UITableViewController.
  2. In the Attributes Inspector, locate the Table View Controller section.
  3. Change the Refreshing dropdown from Disabled to Enabled.

Code Binding:

import UIKit

final class RSSFeedTableViewController: UITableViewController {

    private var feedItems: [String] = ["Item A", "Item B"]

    override func viewDidLoad() {
        super.viewDidLoad()

        // The storyboard creates the instance automatically when Refreshing is Enabled
        refreshControl?.addTarget(self, action: #selector(didPullToRefresh), for: .valueChanged)
    }

    @objc private func didPullToRefresh() {
        reloadFeedData()
    }

    private func reloadFeedData() {
        DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
            self.feedItems.insert("Item \(self.feedItems.count + 1)", at: 0)
            self.tableView.reloadData()
            self.refreshControl?.endRefreshing()
        }
    }

    override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return feedItems.count
    }

    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "FeedCell", for: indexPath)
        cell.textLabel?.text = feedItems[indexPath.row]
        return cell
    }
}

3. Handle Modern Swift Concurrency (async/await)

When loading data from remote endpoints via URLSession, execute the refresh operation inside an asynchronous task. Ensure UI updates and endRefreshing() run on the main actor.

import UIKit

final class AsyncNewsViewController: UIViewController {

    @IBOutlet private weak var tableView: UITableView!
    private let refreshControl = UIRefreshControl()
    private var newsTitles: [String] = []

    override func viewDidLoad() {
        super.viewDidLoad()

        refreshControl.addTarget(self, action: #selector(refreshWithAsync), for: .valueChanged)
        tableView.refreshControl = refreshControl
    }

    @objc private func refreshWithAsync() {
        Task { @MainActor in
            await loadRemoteNews()
            self.refreshControl.endRefreshing()
        }
    }

    @MainActor
    private func loadRemoteNews() async {
        do {
            let url = URL(string: "https://example.com/api/news")!
            let (data, _) = try await URLSession.shared.data(from: url)
            let decodedTitles = try JSONDecoder().decode([String].self, from: data)
            self.newsTitles = decodedTitles
            self.tableView.reloadData()
        } catch {
            // Keep existing UI intact on network failure
            print("Failed to fetch news: \(error.localizedDescription)")
        }
    }
}

How to prevent it

  • Never attach custom pan gesture recognizers for refresh: Use UIKit’s native UIRefreshControl API instead of manually monitoring scroll offsets or touches.
  • Use tableView.refreshControl over addSubview: Direct assignment to the refreshControl property avoids subview hierarchy stacking conflicts.
  • Always end refreshing on the main thread: Calling endRefreshing() on a background thread causes delayed visual dismissals or thread runtime warnings.
  • Dismiss the spinner in failure branches: If an HTTP request fails, ensure your error handler calls refreshControl.endRefreshing() so the spinner does not get stuck indefinitely.

FAQ

Can I programmatically trigger the refresh animation and action?

Calling refreshControl.beginRefreshing() updates the visual state but does not trigger the .valueChanged action. If you trigger the spinner programmatically on screen load, call your reload method explicitly in addition to beginRefreshing().

Why does the refresh spinner overlap my navigation bar or table header?

If UIRefreshControl renders behind the navigation bar or overlaps custom table headers, ensure you assigned it to tableView.refreshControl rather than tableView.addSubview(refreshControl). Direct property assignment allows UIKit to calculate correct content insets.

How do I customize the tint color of the refresh indicator?

Set the tintColor property on the UIRefreshControl instance: refreshControl.tintColor = .systemBlue. You can also style the label using refreshControl.attributedTitle = NSAttributedString(string: "Refreshing...", attributes: [.foregroundColor: UIColor.systemBlue]).

Sources & credits

Parts of this guide are adapted from content licensed under CC BY-SA 4.0; this article is shared under the same license.