```html SwiftUI: How to Pull to Refresh (iOS 17+, 2026)

How to implement pull to refresh in SwiftUI

iOS 17+ Xcode 16+ Beginner APIs: refreshable Updated: May 11, 2026
TL;DR

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

  1. .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.
  2. @Observable FeedViewModel — The iOS 17 @Observable macro replaces ObservableObject + @Published. Any var property change automatically invalidates the view — no manual objectWillChange needed.
  3. try await Task.sleep(for:) — Simulates a real network round-trip. Swap this line for your URLSession, SwiftData fetch, or any async throws function. Structured concurrency guarantees the closure runs on the correct actor.
  4. Error handling via .alert — The binding-based alert pattern surfaces network errors without crashing. When errorMessage becomes non-nil the alert appears; tapping OK nils it again.
  5. 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

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.

```