How to implement pull to refresh in SwiftUI
Attach .refreshable { } to a List or ScrollView and provide an async closure — SwiftUI shows the spinner automatically and dismisses it when your await call completes.
List(items) { item in
Text(item.name)
}
.refreshable {
await viewModel.fetchItems()
}
Full implementation
The example below uses Swift's @Observable macro (iOS 17+) for the view model and a simulated network delay with Task.sleep. The .refreshable modifier works on both List and ScrollView — the spinner is rendered by the system, so it inherits the correct tint automatically. The closure is structured-concurrency-safe; you can freely await multiple calls in sequence.
import SwiftUI
// MARK: - Model
struct FeedItem: Identifiable {
let id = UUID()
let title: String
let subtitle: String
}
// MARK: - View Model
@Observable
final class FeedViewModel {
var items: [FeedItem] = FeedItem.samples
var errorMessage: String?
func refresh() async {
do {
// Replace with your real network call, e.g. await APIClient.fetchFeed()
try await Task.sleep(for: .seconds(1.5))
items = FeedItem.refreshed
} catch {
errorMessage = error.localizedDescription
}
}
}
// MARK: - View
struct FeedView: View {
@State private var viewModel = FeedViewModel()
var body: some View {
NavigationStack {
List(viewModel.items) { item in
VStack(alignment: .leading, spacing: 4) {
Text(item.title)
.font(.headline)
Text(item.subtitle)
.font(.subheadline)
.foregroundStyle(.secondary)
}
.padding(.vertical, 4)
.accessibilityElement(children: .combine)
.accessibilityLabel("\(item.title), \(item.subtitle)")
}
.navigationTitle("Feed")
.refreshable {
await viewModel.refresh()
}
.alert(
"Something went wrong",
isPresented: Binding(
get: { viewModel.errorMessage != nil },
set: { if !$0 { viewModel.errorMessage = nil } }
)
) {
Button("OK", role: .cancel) {}
} message: {
Text(viewModel.errorMessage ?? "")
}
}
}
}
// MARK: - Sample Data
extension FeedItem {
static let samples: [FeedItem] = [
FeedItem(title: "Swift 6.1 Released", subtitle: "Improved concurrency tooling"),
FeedItem(title: "Xcode 17 Beta", subtitle: "Predictive code completion"),
FeedItem(title: "WWDC 2026 Announced", subtitle: "June 8–12 in Cupertino"),
]
static let refreshed: [FeedItem] = [
FeedItem(title: "Refreshed at \(Date.now.formatted(date: .omitted, time: .shortened))", subtitle: "Fresh data loaded"),
FeedItem(title: "Swift 6.1 Released", subtitle: "Improved concurrency tooling"),
FeedItem(title: "WWDC 2026 Announced", subtitle: "June 8–12 in Cupertino"),
]
}
// MARK: - Preview
#Preview {
FeedView()
}
How it works
-
.refreshable { await … }— SwiftUI detects a drag-down past the scroll origin, shows the system spinner, calls your async closure, then hides the spinner automatically when the closure returns. You never manage spinner visibility yourself. -
@Observable FeedViewModel— The iOS 17@Observablemacro replacesObservableObject+@Published. Anyvarproperty change automatically invalidates the view — no manualobjectWillChangeneeded. -
try await Task.sleep(for:)— Simulates a real network round-trip. Swap this line for your URLSession, SwiftData fetch, or anyasync throwsfunction. Structured concurrency guarantees the closure runs on the correct actor. -
Error handling via
.alert— The binding-based alert pattern surfaces network errors without crashing. WhenerrorMessagebecomes non-nil the alert appears; tapping OK nils it again. -
Accessibility on each row —
.accessibilityElement(children: .combine)merges title and subtitle into one VoiceOver utterance, giving screen-reader users a natural reading experience without extra taps.
Variants
Pull to refresh on a ScrollView
refreshable works equally well on ScrollView — useful when your content isn't a flat list but a custom vertical layout.
ScrollView {
LazyVStack(spacing: 16) {
ForEach(viewModel.items) { item in
CardView(item: item)
}
}
.padding()
}
.refreshable {
await viewModel.refresh()
}
// CardView example
struct CardView: View {
let item: FeedItem
var body: some View {
VStack(alignment: .leading) {
Text(item.title).font(.headline)
Text(item.subtitle).foregroundStyle(.secondary)
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12))
}
}
Custom tint for the refresh spinner
Apply .tint(.accentColor) — or any ShapeStyle — to the List or ScrollView to recolour the system spinner. For example: .tint(.indigo). The spinner inherits the tint hierarchy, so setting it on a parent view propagates automatically — no extra modifier needed per child.
Common pitfalls
-
iOS version:
refreshablewas introduced in iOS 15, but the@Observablemacro used here requires iOS 17+. If you need iOS 15–16 support, swap@Observablefor@ObservableObject+@Publishedand annotate the view model with@MainActor. -
Forgetting
await: The refreshable closure isasync, so the spinner stays visible until the closure returns. If you fire a detachedTask { }inside instead of awaiting directly, the spinner disappears immediately while your fetch is still in flight — alwaysawaitinline. -
Performance with large data sets: Assigning a brand-new array to
itemson every refresh causes the entire list to re-render. For large lists, diff-merge new items into the existing array or useSwiftDatawith a@Query— the store handles diffing automatically andrefreshablestill works the same way.
Prompt this with Claude Code
When using Soarias or Claude Code directly to implement this:
Implement pull to refresh in SwiftUI for iOS 17+. Use refreshable with an async closure. Make it accessible (VoiceOver labels on each row). Add a #Preview with realistic sample data.
In Soarias, paste this prompt during the Build phase after your screen mockup is approved — Claude Code will scaffold the view model, wire the refreshable closure, and generate the preview in one shot.
Related
FAQ
Does this work on iOS 16?
The refreshable modifier itself has been available since iOS 15. However, the @Observable macro used in the view model above requires iOS 17+. For iOS 16 targets, replace @Observable with @MainActor class FeedViewModel: ObservableObject and mark each property @Published — the .refreshable call site stays identical.
Can I trigger a refresh programmatically without a drag?
Not directly via refreshable — the modifier is intentionally gesture-driven. For programmatic refreshes, call your fetch function directly in .task { } on appear, or from a toolbar button that invokes Task { await viewModel.refresh() }. You can also use .task(id: refreshTrigger) { } where refreshTrigger is a value you increment to re-fire the task.
What is the UIKit equivalent?
In UIKit you attach a UIRefreshControl to a UIScrollView (or UITableView.refreshControl), add a target-action for .valueChanged, and manually call refreshControl.endRefreshing() when done. SwiftUI's refreshable removes all of that boilerplate — the spinner lifecycle is fully managed for you.
Last reviewed: 2026-05-11 by the Soarias team.