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.