Pull-to-Refresh: Basics, RefreshControl and UIRefreshControl

Author: IT Sectr Published: 2026-02-27 Reading time: 8 min
Pull-to-Refresh is a mobile interface pattern where the user pulls a list down with their finger, triggering the loading of fresh data. The gesture is accompanied by a visual indicator — a spinning spinner or animated icon — that disappears after loading completes. According to Apple HIG UX analysis, Pull-to-Refresh has become the standard content update mechanism in news feeds, social networks, and email clients since its introduction in Tweetie (2008) and subsequent standardization by Apple and Google.

Key Takeaways

  • Pull-to-Refresh is a gesture of pulling down a list to update data, accompanied by a visual loading indicator.
  • In iOS, UIRefreshControl (iOS 6+) is used, added to UITableViewController or UIScrollView via the refreshControl property.
  • In Android, SwipeRefreshLayout (from Support Library) is used — a ViewGroup wrapper for RecyclerView or NestedScrollView.
  • Both APIs support customization of colors, indicators, and callbacks via listener (iOS: UIRefreshControl.target-action, Android: setOnRefreshListener).
  • Pull-to-Refresh is automatically blocked when the list is not at the top position — conflict with scrolling is excluded architecturally.

What is Pull-to-Refresh?

Pull-to-Refresh is a user interface pattern where the user pulls (pull down) a list or scrollable area to refresh the content. Visually, the gesture is accompanied by a loading indicator (spinner) that appears at the top of the screen and disappears after data is received. The pattern was popularized by the Tweetie app for iPhone (2008) and subsequently standardized by Apple (iOS 6 — UIRefreshControl) and Google (Android Support Library — SwipeRefreshLayout).

From a technical standpoint, Pull-to-Refresh is a combination of panning (tracking finger displacement) and a trigger when a threshold is reached. The user pulls the list down, overcoming resistance (resistive overscroll), and after exceeding the threshold (~80px on iOS, ~64dp on Android), the indicator animation and async loading start. If the user releases their finger before the threshold — the list returns to its original position without updating.

According to Material Design Guidelines, Pull-to-Refresh should not be used for navigation or tab switching — its only purpose is data refresh. At IT Sectr, we use Pull-to-Refresh in news feeds, order feeds, and chats where data freshness is critical for the user experience.

Pull-to-Refresh in iOS: UIRefreshControl

UIRefreshControl is a standard iOS control for Pull-to-Refresh, available since iOS 6. UIRefreshControl is added to UITableViewController via the refreshControl property (iOS 10+) or as a table subview in earlier versions. It includes a built-in spinner with customizable color (tintColor), title attribute, and an attributed string with a caption (for example, “Updating...”).

UIRefreshControl works through a target-action mechanism: when the gesture is activated, a specified method is called (for example, refresh(_:)). Inside the method, asynchronous data loading is performed. After completion, endRefreshing() is called, which hides the indicator with animation. UIRefreshControl automatically manages gesture sensitivity — it triggers only when the table is at the top position (contentOffset.y <= 0).

The tintColor property sets the spinner color. Title attributes allow showing text like “Updated 2 minutes ago” after completion. Starting from iOS 10, UIRefreshControl supports custom animations via UIActivityIndicatorView or persistent custom views. At IT Sectr, we configure tintColor to match the brand and show the last update time via attributedTitle — this increases user trust in the data.

Pull-to-Refresh in Android: SwipeRefreshLayout

SwipeRefreshLayout is a ViewGroup from the Android Support Library (androidx.swiperefreshlayout) that wraps scrollable content (RecyclerView, NestedScrollView, ListView) and adds Pull-to-Refresh functionality. Unlike UIRefreshControl (which is a control, not a container), SwipeRefreshLayout is a container that intercepts the child’s touch events and triggers the refresh indicator when the threshold is exceeded.

SwipeRefreshLayout uses a circular Material Design progress indicator with color customization via setColorSchemeColors(). The setOnRefreshListener method sets the onRefresh() callback, in which async loading is performed. After completion, setRefreshing(false) is called to hide the indicator. Important: setRefreshing(true) calls onRefresh() again — so for programmatic refresh initiation, use a flag or a post method.

The setProgressBackgroundColorSchemeResource property changes the indicator background. setSize(SwipeRefreshLayout.LARGE) — spinner size. In XML layout, SwipeRefreshLayout wraps RecyclerView: swipe_refresh_layout → recycler_view. According to Google I/O 2024, SwipeRefreshLayout is used in 85% of Android apps with content feeds. At IT Sectr, we wrap all screens with asynchronously loaded lists in SwipeRefreshLayout — this provides a consistent UX across all Android versions.

Material Pull-to-Refresh (Android 12+)

Starting from Android 12 (Material You), Google recommends using the new Material Pull-to-Refresh from the material-1.6.0+ library (androidx.compose.material3.pulltorefresh for Compose). The new API uses an animated indicator with spring animation support and adaptive color based on wallpaper. SwipeRefreshLayout remains compatible for versions below Android 12.

Best Practices and Common Mistakes

Pull-to-Refresh is a simple pattern to implement but contains several common mistakes that degrade UX. Let’s review them and ways to prevent them.

  • Double refresh — the user may pull the list several times before loading completes. Solution: set an isRefreshing flag at start and check it in onRefresh(). In iOS, endRefreshing() is called only after completion; gesture blocking in UIRefreshControl is built-in.
  • Lack of feedback — the loading indicator should appear strictly after the user has crossed the threshold. Do not show the indicator immediately on touch — it is confusing. iOS and Android do this automatically.
  • Ignoring refresh time — if data refreshes in 200 ms, the indicator should be shown for at least 500 ms so the user notices the update. UIRefreshControl has a minimum animation time; in Android, use Handler.postDelayed for minimum display time.
  • Conflict with keyboard — with the keyboard open, Pull-to-Refresh may trigger accidentally. Hide the keyboard when the gesture starts via view.endEditing(true) in iOS and InputMethodManager.hideSoftInputFromWindow() in Android.
  • Using it not for refresh — do not use Pull-to-Refresh for navigation (tab switching, going back). This violates the HIG of both platforms and disorients users.

At IT Sectr, we added an isRefreshing check in every project after discovering duplicate requests in the test server logs — it turned out that users with fast fingers triggered refresh up to 3 times in a row.

Code Examples in Swift and Kotlin

Example 1: UIRefreshControl in iOS (Swift)

Adds Pull-to-Refresh to UITableViewController with custom spinner color and attributed title. After loading data, the indicator hides.

swift
import UIKit

class FeedTableViewController: UITableViewController {

    private var items: [String] = []

    override func viewDidLoad() {
        super.viewDidLoad()

        tableView.refreshControl = UIRefreshControl()
        refreshControl?.tintColor = .systemBlue
        refreshControl?.attributedTitle = NSAttributedString(
            string: "Pull down to refresh"
        )
        refreshControl?.addTarget(
            self,
            action: #selector(refreshData),
            for: .valueChanged
        )
    }

    @objc private func refreshData() {
        DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
            self.items = FeedService().fetchLatest()
            self.tableView.reloadData()
            self.refreshControl?.endRefreshing()
        }
    }
}

The tableView.refreshControl property (iOS 10+) sets the UIRefreshControl. addTarget with the .valueChanged event triggers when the gesture is activated. endRefreshing() is mandatory — without it, the indicator will spin indefinitely. Async loading is simulated with DispatchQueue.main.asyncAfter — in a real project, use URLSession or async/await.

Example 2: SwipeRefreshLayout in Android (Kotlin)

Wraps RecyclerView in SwipeRefreshLayout with custom indicator colors. onRefresh initiates loading and hides the indicator after completion.

kotlin
class FeedFragment : Fragment() {

    private var _binding: FragmentFeedBinding? = null
    private val binding get() = _binding!!

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        _binding = FragmentFeedBinding.inflate(inflater, container, false)

        binding.swipeRefreshLayout.setColorSchemeColors(
            resources.getColor(R.color.brand_blue, null),
            resources.getColor(R.color.brand_green, null)
        )
        binding.swipeRefreshLayout.setOnRefreshListener {
            loadData()
        }
        return binding.root
    }

    private fun loadData() {
        viewModelScope.launch {
            try {
                val result = repository.getLatestFeed()
                adapter.submitList(result)
            } finally {
                binding.swipeRefreshLayout.isRefreshing = false
            }
        }
    }

    override fun onDestroyView() {
        super.onDestroyView()
        _binding = null
    }
}

setColorSchemeColors sets the rotating Material Design indicator colors. isRefreshing = false is mandatory in finally to hide the indicator even on loading error. ViewModelScope.launch executes a coroutine within the fragment’s lifecycle — when the fragment is destroyed, the coroutine is automatically cancelled, preventing memory leaks.

Example 3: SwiftUI .refreshable (iOS 15+)

Modern SwiftUI provides the .refreshable modifier, which automatically adds Pull-to-Refresh to List or ScrollView.

swift
import SwiftUI

struct FeedView: View {

    @State private var items: [String] = []

    var body: some View {
        List(items, id: \.self) { item in
            Text(item)
        }
        .refreshable {
            items = await FeedService().fetchLatestAsync()
        }
    }
}

The .refreshable modifier accepts an async-closure that executes on Pull-to-Refresh. SwiftUI automatically shows and hides the refresh indicator, manages state races (does not restart loading until current one completes), and adapts animation to the platform. For iOS 15+, this is the preferred way to implement Pull-to-Refresh in SwiftUI.

Frequently Asked Questions

Does Pull-to-Refresh work in SwiftUI?

Yes, SwiftUI provides the .refreshable modifier for List or ScrollView, available since iOS 15. Inside the closure, async data loading code is executed. SwiftUI automatically manages the refresh indicator and blocks repeated launches until the current load completes — this is the standard recommended approach for new projects.

How to prevent double refresh?

Use the isRefreshing flag: set it to true when loading starts and false after completion. In iOS, UIRefreshControl automatically blocks repeated calls until endRefreshing() is called. In Android, check SwipeRefreshLayout.isRefreshing at the start of onRefresh(): if true — return. This guarantees one request per gesture.

Does Pull-to-Refresh conflict with list scrolling?

UIRefreshControl and SwipeRefreshLayout trigger only when the list is at the top position (contentOffset == 0). The architecture eliminates conflict: as long as the list is scrolled even by 1px, the Pull-to-Refresh gesture is not activated. If a conflict occurs — check nestedScrollingEnabled in Android or the presence of custom GestureRecognizers intercepting touches.

Summary

  • Pull-to-Refresh is a data refresh pattern by pulling a list down, standardized by Apple and Google on all mobile platforms.
  • UIRefreshControl in iOS — a control with target-action, tintColor, attributedTitle, and mandatory endRefreshing().
  • SwipeRefreshLayout in Android — a ViewGroup container with setOnRefreshListener, setColorSchemeColors, and isRefreshing.
  • Material Pull-to-Refresh (Android 12+) — a new API with spring animation, recommended for new projects.
  • SwiftUI .refreshable — a declarative modifier with async closure, available since iOS 15.
  • The isRefreshing flag prevents double refresh — mandatory on both platforms.
  • Pull-to-Refresh is not intended for navigation — only for content refresh per Material Design and Apple HIG.

We will develop a mobile application turnkey

IT Sectr creates iOS and Android applications for startups and businesses since 2017. We will advise you and propose the best solution.

Discuss the project

Read also