Pull-to-Refresh with .refreshable

The .refreshable modifier adds native pull-to-refresh with minimal code. Here's how to implement it properly.
Follow along with the code: iOS-Practice on GitHub
Basic Implementation
struct PullToRefreshExerciseView: View {
@State private var posts: [Post] = []
@State private var isLoading = false
@State private var lastRefresh: Date?
var body: some View {
List {
if let lastRefresh = lastRefresh {
Section {
HStack {
Text("Last updated")
Spacer()
Text(lastRefresh, style: .relative)
.foregroundColor(.secondary)
}
.font(.caption)
}
}
Section("Posts") {
ForEach(posts) { post in
PostRowView(post: post)
}
}
}
.refreshable {
await loadPosts()
}
.task {
await loadPosts()
}
}
private func loadPosts() async {
isLoading = true
do {
posts = try await MockAPIService.shared.fetchPosts()
lastRefresh = Date()
} catch {
// Handle error
}
isLoading = false
}
}
How .refreshable Works
The modifier takes an async closure:
.refreshable {
await loadPosts()
}
SwiftUI automatically:
- Shows the pull-to-refresh indicator
- Waits for your async work to complete
- Hides the indicator when done
No need to manage the refresh state manually.
Combining with Initial Load
Use .task for initial load and .refreshable for manual refresh:
.task {
await loadPosts()
}
.refreshable {
await loadPosts()
}
Both can call the same function.
Showing Last Refresh Time
Track when data was last refreshed:
@State private var lastRefresh: Date?
private func loadPosts() async {
// ... load data ...
lastRefresh = Date()
}
Display it with relative formatting:
Text(lastRefresh, style: .relative)
// Shows: "2 minutes ago", "1 hour ago", etc.
Error Handling with Refresh
Show errors while keeping stale data visible:
@State private var error: Error?
var body: some View {
List { ... }
.refreshable {
await loadPosts()
}
.overlay {
if isLoading && posts.isEmpty {
ProgressView("Loading...")
} else if let error, posts.isEmpty {
ContentUnavailableView {
Label("Error", systemImage: "exclamationmark.triangle")
} description: {
Text(error.localizedDescription)
}
}
}
}
If refresh fails but old data exists, users still see content.
Post Row View
struct PostRowView: View {
let post: Post
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(post.title)
.font(.headline)
.lineLimit(2)
Text(post.body)
.font(.subheadline)
.foregroundColor(.secondary)
.lineLimit(2)
HStack {
Label("\(post.likes)", systemImage: "heart.fill")
.foregroundColor(.red)
.font(.caption)
Spacer()
Text(post.createdAt, style: .relative)
.font(.caption)
.foregroundColor(.secondary)
}
}
.padding(.vertical, 4)
}
}
Interview Tip
When discussing .refreshable:
- It's async-native—no completion handlers
- The indicator dismisses automatically when the closure returns
- Works with any scrollable view, not just List
- Combine with
.taskfor initial load