Swift and SwiftUI tutorials for Swift Developers

Pull to Refresh in SwiftUI

In the fast-paced world of mobile development, User Experience (UX) is everything. For an iOS Developer, few interactions are as critical and universally recognized as “Pull to Refresh.” Since its popularization in the early days of iOS, this gesture has become the de facto standard for reloading data in lists and collections.

With the arrival and maturation of SwiftUI, the way we implement this functionality has changed radically compared to UIKit. Forget complex delegates and manual loading state management. In modern Swift programming, the .refreshable() modifier is the ultimate tool Apple provides us to manage asynchronous content updates natively.

In this comprehensive tutorial, designed for developers using Xcode, we will explore how to master Pull to Refresh in SwiftUI. We won’t just look at the basic implementation, but we’ll dive deep into MVVM architecture, concurrency with async/await, and how to adapt this functionality for professional applications on iOS, macOS, and watchOS.

From UIRefreshControl to SwiftUI Declarativity

Historically, in UIKit, a developer had to instantiate a UIRefreshControl, assign it to a UITableView, configure a target-action, and remember to manually call endRefreshing() when the task was finished. It was an imperative process prone to state errors.

SwiftUI changes this paradigm. Instead of manipulating the view directly, we declare the intent for a view to be refreshable. The operating system handles the “how” (showing the spinner or appropriate animation), while you focus on the “what” (the asynchronous business logic).

The .refreshable() Modifier: Technical Fundamentals

Introduced in iOS 15, the .refreshable() modifier leverages the power of Swift’s structured concurrency. Its signature expects an asynchronous closure (async). This is fundamental because SwiftUI automatically manages the loading indicator’s lifecycle based on the duration of your asynchronous function.

Let’s first look at the data structure we will use for our example: a news app for developers.

import SwiftUI

// Simple Data Model
struct NewsItem: Identifiable, Decodable {
    let id: UUID
    let title: String
    let category: String
}

// Mock Data
let initialNews = [
    NewsItem(id: UUID(), title: "Swift 6: Concurrency News", category: "Language"),
    NewsItem(id: UUID(), title: "Xcode 16 and Generative AI", category: "IDE"),
    NewsItem(id: UUID(), title: "Modular Architecture in iOS", category: "Architecture")
]

Basic Implementation in a List

The most common container for implementing Pull to Refresh in SwiftUI is the List. Below, we will create a basic view that displays this data and allows the user to update it.

In this example, we will use Task.sleep to simulate a network call and demonstrate how the interface automatically responds to the wait time.

struct NewsFeedView: View {
    // Local state for the view
    @State private var news: [NewsItem] = initialNews

    var body: some View {
        NavigationStack {
            List(news) { item in
                VStack(alignment: .leading) {
                    Text(item.title)
                        .font(.headline)
                    Text(item.category)
                        .font(.caption)
                        .foregroundColor(.secondary)
                }
            }
            .navigationTitle("Swift News")
            // PULL TO REFRESH IMPLEMENTATION
            .refreshable {
                await fetchNewData()
            }
        }
    }

    // Async function simulating a network request
    func fetchNewData() async {
        // 1. Simulate network latency (2 seconds)
        // Note: In a real environment, this would be a URLSession call
        try? await Task.sleep(nanoseconds: 2 * 1_000_000_000)
        
        // 2. Create a new item
        let newItem = NewsItem(
            id: UUID(), 
            title: "New Article: Mastering SwiftUI", 
            category: "Tutorial"
        )
        
        // 3. Update state
        // SwiftUI ensures this happens on the MainActor automatically
        // when returning from suspension inside a View.
        withAnimation {
            news.insert(newItem, at: 0)
        }
    }
}

When running this code in the Xcode simulator, you will see that dragging the list down reveals the native spinner. It keeps spinning for exactly the 2 seconds that Task.sleep lasts and disappears automatically when the function returns.

MVVM Architecture and Advanced Concurrency

Any senior iOS Developer will tell you that business logic should not reside inside the View. To keep our code clean, testable, and scalable, we must move the update logic to a ViewModel.

This is where modern Swift programming shines. We will use the @MainActor attribute to ensure UI updates are safe, and ObservableObject for data binding.

import SwiftUI

@MainActor
class NewsViewModel: ObservableObject {
    @Published var news: [NewsItem] = []
    
    init() {
        self.news = initialNews
    }
    
    // The function that .refreshable() will call
    func refreshNews() async {
        do {
            // Simulate loading
            try await Task.sleep(for: .seconds(1.5))
            
            // "Business" logic to fetch data
            let randomUpdate = NewsItem(
                id: UUID(), 
                title: "Update received: \(Date().formatted(date: .omitted, time: .standard))", 
                category: "Live Feed"
            )
            
            // Since it's @MainActor, we can modify @Published safely
            self.news.insert(randomUpdate, at: 0)
            
        } catch {
            print("Error updating: \(error)")
        }
    }
}

Now, our view becomes much more declarative and clean, fully delegating responsibility to the ViewModel:

struct CleanNewsFeedView: View {
    @StateObject private var viewModel = NewsViewModel()

    var body: some View {
        NavigationStack {
            List(viewModel.news) { item in
                VStack(alignment: .leading) {
                    Text(item.title)
                        .font(.headline)
                    Text(item.category)
                        .font(.subheadline)
                }
            }
            .navigationTitle("Swift News")
            .refreshable {
                // The view waits for the ViewModel to finish its work
                await viewModel.refreshNews()
            }
        }
    }
}

Support for ScrollView and Lazy Stacks (iOS 16+)

One of the biggest recent advancements for an iOS Developer using SwiftUI was the extension of .refreshable() support to ScrollView in iOS 16. Previously, we were limited to List, which restricted custom layout possibilities.

Now you can have complex layouts with LazyVStack or LazyVGrid inside a ScrollView and maintain the native drag-to-refresh functionality.

struct CustomLayoutView: View {
    @StateObject private var viewModel = NewsViewModel()

    var body: some View {
        ScrollView {
            // Use LazyVStack for performance with lots of data
            LazyVStack(spacing: 16) {
                ForEach(viewModel.news) { item in
                    // A custom card view
                    HStack {
                        Circle()
                            .fill(Color.blue)
                            .frame(width: 40, height: 40)
                        VStack(alignment: .leading) {
                            Text(item.title).bold()
                            Text(item.category).font(.caption)
                        }
                        Spacer()
                    }
                    .padding()
                    .background(Color(.systemGray6))
                    .cornerRadius(10)
                }
            }
            .padding()
        }
        // The modifier works the same way on ScrollView
        .refreshable {
            await viewModel.refreshNews()
        }
    }
}

Multi-platform Adaptability: iOS, macOS, and watchOS

One of SwiftUI‘s promises is “learn once, apply everywhere.” However, the behavior of .refreshable() intelligently adapts to the platform where the code runs:

  • iOS and iPadOS: Displays the classic circular spinner (UIActivityIndicator) when dragging content down.
  • macOS: Since “pulling” with a mouse isn’t natural, SwiftUI doesn’t show the spinner by default when dragging. Instead, it automatically enables the standard Cmd + R keyboard shortcut and adds a “Refresh” option to the “View” menu in the Mac menu bar. This is crucial for desktop accessibility.
  • watchOS: On the Apple Watch, space is vital. The modifier adds a toolbar-style button at the top of the list or allows a specific scroll interaction tailored for watches.

Error Handling and User Feedback

In a production environment, requests fail. If your async function throws an error or fails silently, the spinner will simply disappear, leaving the user confused. As a good iOS Developer, you must manage these errors.

Below, we present a robust pattern for handling errors within the refresh flow:

@MainActor
class RobustViewModel: ObservableObject {
    @Published var items: [String] = []
    @Published var errorMessage: String?
    @Published var showError: Bool = false

    func reloadData() async {
        do {
            // Attempt network call
            try await performNetworkCall()
        } catch {
            // Capture the error
            self.errorMessage = "Could not connect to server. Please try again."
            self.showError = true
            // Important: The function ends here, so the UI spinner disappears,
            // and immediately after, we show the alert.
        }
    }
    
    private func performNetworkCall() async throws {
        // Simulate random failure
        try await Task.sleep(for: .seconds(1))
        if Bool.random() { throw URLError(.notConnectedToInternet) }
    }
}

// Implementation in the View
struct ErrorHandlingView: View {
    @StateObject var vm = RobustViewModel()
    
    var body: some View {
        List(vm.items, id: \.self) { item in
            Text(item)
        }
        .refreshable {
            await vm.reloadData()
        }
        .alert("Error", isPresented: $vm.showError) {
            Button("OK", role: .cancel) { }
        } message: {
            Text(vm.errorMessage ?? "Unknown error")
        }
    }
}

Customization: Can We Change the Spinner Color?

A frequent question when working with Pull to Refresh in SwiftUI is visual customization. Unlike UIKit, SwiftUI does not offer (as of Xcode 15/16) a direct modifier like .refreshableStyle(color: .red).

To achieve this, we must resort to UIKit interoperability, modifying the global appearance of UIRefreshControl. Although not the “purest” SwiftUI solution, it is effective and widely used.

init() {
    // This will affect all lists in the application
    UIRefreshControl.appearance().tintColor = UIColor.systemIndigo
    UIRefreshControl.appearance().attributedTitle = NSAttributedString(string: "Updating content...")
}

Conclusion

Implementing Pull to Refresh in SwiftUI is a testament to how Apple is simplifying development in Xcode. What used to require multiple lines of configuration and delegates is now solved with a single modifier and a robust asynchronous function.

Mastering the .refreshable() modifier is essential for any iOS Developer looking to build modern, reactive apps that feel native in the Apple ecosystem. Always remember to separate your logic into a ViewModel and handle errors to provide the best possible experience for your users.

If you have any questions about this article, please contact me and I will be happy to help you 🙂. You can contact me on my X profile or on my Instagram profile.

Leave a Reply

Your email address will not be published. Required fields are marked *

Previous Article

How to Select Multiple Dates in SwiftUI

Next Article

How to add Liquid Glass in SwiftUI

Related Posts