Skip to content

Pull-to-Refresh (SwiftUI)

A SwiftUI guide for coding agents. Also covers refreshable modifier, refresh list gesture ios, environment refresh action, reload on pull, custom refresh button.

The refreshable view modifier (SwiftUI Release 3, iOS 15+) adds the pull-to-refresh gesture to a List and runs an async closure when the user pulls. Reach for it whenever a screen needs a manual data reload.

Basics

Attach refreshable to the List and pass an async closure. SwiftUI shows the spinner while the task runs and hides it when the closure returns.

struct ContentView: View {
  @StateObject private var viewModel = SearchViewModel()
  @State private var query = "Swift"

  var body: some View {
    NavigationView {
      List(viewModel.repos) { repo in
        Text(repo.name)
      }
      .navigationTitle("Search")
      .refreshable {
        await viewModel.search(matching: query)
      }
    }
  }
}

Two constraints in this release: the gesture only works on List, and async/await is the only way to control the indicator. You cannot show or hide it manually.

Custom Refresh UI via the Environment

refreshable also stores its closure in the environment as \.refresh, so a child view can read it and build its own trigger. That is how a grid, which has no built-in gesture, still honours a refreshable attached by its parent.

struct SearchView: View {
  @ObservedObject var viewModel: SearchViewModel
  @Environment(\.refresh) private var refreshAction
  @State private var isRefreshing = false

  var body: some View {
    LazyVGrid(columns: Array(repeating: .init(), count: 4)) {
      ForEach(viewModel.repos) { repo in
        AsyncImage(url: repo.owner.avatar)
          .frame(width: 44, height: 44)
          .clipShape(Circle())
      }
    }
    .toolbar {
      if let refreshAction {
        Button("Refresh") {
          Task {
            isRefreshing = true
            await refreshAction()
            isRefreshing = false
          }
        }
        .disabled(isRefreshing)
        .opacity(isRefreshing ? 0 : 1)
        .overlay { if isRefreshing { ProgressView() } }
      }
    }
  }
}

\.refresh is nil when no ancestor attached refreshable, so gate the button on it. Track your own isRefreshing state to disable the trigger and show progress, because outside a List the system draws nothing for you.

Use this guidance in your coding agent

Install the Better Design MCP once. Your agent then loads this page with one call.

get-swiftui-guide({ topic: "swiftui-pull-to-refresh" })
claude mcp add --scope user better-design --transport http https://better-design.com/api/mcp --header "Authorization: Bearer <YOUR_API_KEY>"
Browse related design systems