Implementing Pull-to-Refresh in SwiftUI Lists

Pull-to-refresh is a common and intuitive UI pattern that allows users to refresh content in a list or scrollable view by simply pulling down from the top. This functionality is crucial for many modern apps that display dynamic content. SwiftUI, Apple’s declarative UI framework, simplifies the implementation of pull-to-refresh in lists. In this comprehensive guide, we’ll explore various ways to implement pull-to-refresh in SwiftUI lists, including the latest approaches introduced in recent iOS versions.

What is Pull-to-Refresh?

Pull-to-refresh is a user interface pattern where a user drags a list or scrollable view downwards to trigger a refresh of the content. This action is typically accompanied by a visual indicator, such as a loading spinner, to provide feedback to the user.

Why Implement Pull-to-Refresh?

  • Enhanced User Experience: Provides a familiar and intuitive way to update content.
  • Up-to-Date Data: Ensures users can easily retrieve the latest information.
  • Engagement: Encourages users to interact with the content more frequently.

Methods to Implement Pull-to-Refresh in SwiftUI

Method 1: Using .refreshable (iOS 15 and Later)

The .refreshable modifier is the simplest and most straightforward way to add pull-to-refresh in SwiftUI on iOS 15 and later. It automatically handles the UI and animations related to the refresh control.

Step 1: Implement the Refresh Action

Add the .refreshable modifier to your List or ScrollView and provide an asynchronous action to execute when the user triggers a refresh.


import SwiftUI

struct ContentView: View {
    @State private var items: [String] = ["Item 1", "Item 2", "Item 3"]

    var body: some View {
        NavigationView {
            List {
                ForEach(items, id: .self) { item in
                    Text(item)
                }
            }
            .navigationTitle("Pull to Refresh")
            .refreshable {
                await refreshData()
            }
        }
    }

    func refreshData() async {
        // Simulate fetching data from a network
        try? await Task.sleep(nanoseconds: 1_000_000_000) // 1 second delay
        items = ["New Item 1", "New Item 2", "New Item 3"].shuffled()
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

In this example:

  • .refreshable is added to the List, enabling pull-to-refresh.
  • refreshData() is an async function that simulates fetching new data. A delay is added to mimic a network request.
  • The list is updated with new, shuffled items after the refresh action.

Method 2: Custom Implementation with ScrollView and GeometryReader

For iOS versions prior to 15 or when you need more control over the refresh behavior and appearance, you can implement pull-to-refresh using ScrollView and GeometryReader.

Step 1: Set Up the Basic ScrollView

Wrap your content in a ScrollView to enable scrolling.


import SwiftUI

struct ContentView: View {
    @State private var items: [String] = ["Item 1", "Item 2", "Item 3"]
    @State private var isRefreshing = false

    var body: some View {
        NavigationView {
            ScrollView {
                LazyVStack { // Use LazyVStack for performance
                    ForEach(items, id: .self) { item in
                        Text(item)
                            .padding()
                            .frame(maxWidth: .infinity)
                            .background(Color.gray.opacity(0.1))
                            .cornerRadius(8)
                            .padding(.horizontal)
                    }
                }
            }
            .navigationTitle("Pull to Refresh")
        }
    }

    func refreshData() {
        isRefreshing = true
        DispatchQueue.main.asyncAfter(deadline: .now() + 1) { // Simulate network request
            items = ["New Item 1", "New Item 2", "New Item 3"].shuffled()
            isRefreshing = false
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}
Step 2: Detect Scroll Offset with GeometryReader

Use a GeometryReader to detect the scroll offset. Add it as the first item inside the ScrollView.


import SwiftUI

struct ContentView: View {
    @State private var items: [String] = ["Item 1", "Item 2", "Item 3"]
    @State private var isRefreshing = false
    @State private var refreshThreshold: CGFloat = 80

    var body: some View {
        NavigationView {
            ScrollView {
                // Detect Scroll Offset
                GeometryReader { geometry ->
                    Color.clear.preference(key: ScrollOffsetPreferenceKey.self, value: geometry.frame(in: .global).origin)
                }
                .frame(height: 0) // Zero height to avoid affecting layout

                LazyVStack {
                    ForEach(items, id: .self) { item in
                        Text(item)
                            .padding()
                            .frame(maxWidth: .infinity)
                            .background(Color.gray.opacity(0.1))
                            .cornerRadius(8)
                            .padding(.horizontal)
                    }
                }
            }
            .navigationTitle("Pull to Refresh")
            .onPreferenceChange(ScrollOffsetPreferenceKey.self) { value in
                if value.y > refreshThreshold {
                    return  // Avoid triggering refresh multiple times
                }
                if value.y < -refreshThreshold && !isRefreshing {
                    refreshData()
                }
            }
            .overlay(alignment: .top) {
                if isRefreshing {
                    ProgressView()
                        .padding(.top)
                }
            }
        }
    }

    func refreshData() {
        isRefreshing = true
        DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
            items = ["New Item 1", "New Item 2", "New Item 3"].shuffled()
            isRefreshing = false
        }
    }
}

// PreferenceKey to track scroll offset
struct ScrollOffsetPreferenceKey: PreferenceKey {
    static var defaultValue: CGPoint = .zero
    static func reduce(value: inout CGPoint, nextValue: () -> CGPoint) {
        value = nextValue()
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

Key updates:

  • ScrollOffsetPreferenceKey: Custom PreferenceKey to pass scroll offset values.
  • GeometryReader: Captures the scroll offset.
  • .onPreferenceChange: Listens for changes in the scroll offset.
Step 3: Implement Refresh Logic and Indicator

Add the refresh logic based on the scroll offset and display a loading indicator.


import SwiftUI

struct ContentView: View {
    @State private var items: [String] = ["Item 1", "Item 2", "Item 3"]
    @State private var isRefreshing = false
    @State private var refreshThreshold: CGFloat = 80

    var body: some View {
        NavigationView {
            ScrollView {
                // Detect Scroll Offset
                GeometryReader { geometry ->
                    Color.clear.preference(key: ScrollOffsetPreferenceKey.self, value: geometry.frame(in: .global).origin)
                }
                .frame(height: 0) // Zero height to avoid affecting layout

                LazyVStack {
                    ForEach(items, id: .self) { item in
                        Text(item)
                            .padding()
                            .frame(maxWidth: .infinity)
                            .background(Color.gray.opacity(0.1))
                            .cornerRadius(8)
                            .padding(.horizontal)
                    }
                }
            }
            .navigationTitle("Pull to Refresh")
            .onPreferenceChange(ScrollOffsetPreferenceKey.self) { value in
                if value.y > refreshThreshold {
                    return  // Avoid triggering refresh multiple times
                }
                if value.y < -refreshThreshold && !isRefreshing {
                    refreshData()
                }
            }
            .overlay(alignment: .top) {
                if isRefreshing {
                    ProgressView()
                        .padding(.top)
                }
            }
        }
    }

    func refreshData() {
        isRefreshing = true
        DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
            items = ["New Item 1", "New Item 2", "New Item 3"].shuffled()
            isRefreshing = false
        }
    }
}

// PreferenceKey to track scroll offset
struct ScrollOffsetPreferenceKey: PreferenceKey {
    static var defaultValue: CGPoint = .zero
    static func reduce(value: inout CGPoint, nextValue: () -> CGPoint) {
        value = nextValue()
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

Important Considerations:

  • isRefreshing State: Indicates whether the refresh is in progress.
  • refreshThreshold: Defines the scroll offset needed to trigger a refresh.
  • ProgressView: Displays the loading indicator at the top.
  • Rate Limiting: Added check (value.y > refreshThreshold) to avoid rapidly triggering refreshData if the user continues to pull.

Method 3: Using Third-Party Libraries

There are also third-party libraries available that offer customizable pull-to-refresh solutions. These libraries often provide additional features and flexibility.

// Example of integrating a hypothetical PullToRefresh library
import SwiftUI
// import PullToRefresh // Hypothetical library

struct ContentView: View {
    @State private var items: [String] = ["Item 1", "Item 2", "Item 3"]
    @State private var isRefreshing = false

    var body: some View {
        NavigationView {
            /* PullToRefresh(isRefreshing: $isRefreshing) {
                refreshData()
            } content: {
                List {
                    ForEach(items, id: .self) { item in
                        Text(item)
                    }
                }
            }
            .navigationTitle("Pull to Refresh") */
            Text("Replace above commented code with library code once imported")
        }
    }

    func refreshData() {
        isRefreshing = true
        DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
            items = ["New Item 1", "New Item 2", "New Item 3"].shuffled()
            isRefreshing = false
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

Best Practices for Implementing Pull-to-Refresh

  • Provide Clear Feedback: Use a loading indicator (e.g., ProgressView) to show that the refresh is in progress.
  • Handle Errors Gracefully: Implement error handling to manage cases where the refresh fails.
  • Optimize Performance: Use LazyVStack or LazyHStack inside ScrollView for better performance with large datasets.
  • Consider Accessibility: Ensure the pull-to-refresh action is accessible to users with disabilities.
  • Avoid Overuse: Only use pull-to-refresh when it makes sense for the content being displayed.

Conclusion

Implementing pull-to-refresh in SwiftUI enhances user experience by providing an intuitive way to refresh content. With the .refreshable modifier in iOS 15 and later, adding this feature has become simpler than ever. For older iOS versions or when more customization is needed, custom implementations with ScrollView and GeometryReader offer greater control. By following best practices and choosing the right approach, you can create a seamless and engaging user experience in your SwiftUI apps.