Key Takeaways
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.
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.
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.
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.
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.
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.
Adds Pull-to-Refresh to UITableViewController with custom spinner color and attributed title. After loading data, the indicator hides.
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.
Wraps RecyclerView in SwipeRefreshLayout with custom indicator colors. onRefresh initiates loading and hides the indicator after completion.
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.
Modern SwiftUI provides the .refreshable modifier, which automatically adds Pull-to-Refresh to List or ScrollView.
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
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.
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.
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
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.
Read also