Mastering Refreshable in SwiftUI : Pull to refresh

  • Sep 2, 2024

Mastering Refreshable in SwiftUI : Pull to refresh 

  • DevTechie

Mastering Refreshable in SwiftUI : Pull to refresh

The refreshable modifier in SwiftUI provides a way to allow users to manually refresh the content of a view. When added to a view, it adds a pull-to-refresh function to initiate a refresh action.

func refreshable(action: @escaping @Sendable () async -> Void) -> some View

The refreshable modifier requires an asynchronous closure that's executed when the view needs to be refreshed. For most views, you'll need to manually trigger the refresh and provide a UI element if necessary. However, iOS List views automatically refresh when pulled down, showing a progress indicator during the process.

Let’s create a simple List view that displays data from a state variable containing a collection of strings. We’ll use the refreshable modifier to update the state variable with new data fetched from an asynchronous function called fetchDataFromAPI.

struct RefreshableListView: View {
    @State private var data = [String]()

    var body: some View {
        NavigationStack {
            List(data, id: \.self) { item in
                Text(item)
            }
            .refreshable {
                self.data = await fetchDataFromAPI()
            }
            .navigationTitle("DevTechie Courses")
        }
    }

    func fetchDataFromAPI() async -> [String] {
        return ["Mastering SwiftData", "Mastering SwiftUI", "Practical Core Data"]
    }
}

Pulling view from top will populate the list with fetched values.

Because refreshable uses async-await, you don't need a Task block for asynchronous functions. Let's create a simple app that fetches random advice from an API and displays it in a list view.

API endpoint we will use is:

https://api.adviceslip.com

Main endpoint for our API call will be:

https://api.adviceslip.com/advice

JSON response from this API call looks like this:

{"slip": { "id": 40, "advice": "Never run with scissors."}}

The JSON data describes a single slip of advice. The slip object has two properties:

  • id: An integer representing the unique identifier of the advice. In this case, the ID is 40.

  • advice: A string containing the actual advice. In this case, the advice is “Never run with scissors.”

We will create corresponding model structs to parse this JSON into a Swift type. These models will conform to the Codable protocol. 

The Codable protocol in Swift provides a way to easily encode and decode custom types to and from JSON, plist, or other formats. This makes it simple to work with structured data in your applications.

import Foundation

struct Advice: Codable {
    let slip: Slip
}

struct Slip: Codable {
    let id: Int
    let advice: String
}

Next, we will create a network client to send requests to an API. We will design this client using generics, allowing it to fetch data for any type that conforms to the Codable protocol. This approach ensures flexibility, enabling the client to work with various data models while simplifying the process of retrieving data from the various API endpoints.

import Combine

final class NetworkClient {
    static let shared = NetworkClient()
    
    func request<T: Decodable>(_ url: URL, type: T.Type) -> AnyPublisher<T, Error> {
        URLSession.shared.dataTaskPublisher(for: url)
            .map { $0.data }
            .decode(type: T.self, decoder: JSONDecoder())
            .receive(on: DispatchQueue.main)
            .eraseToAnyPublisher()
    }
}

Let’s build an observable view model to fetch advice from the API.

import Observation

@Observable
final class RefreshViewModel {
    
    private var cancellables = Set<AnyCancellable>()
    
    var advice = Advice(slip: Slip(id: 0, advice: ""))
    
    init() {
        fetchAdvice()
    }
    
    func fetchAdvice() {
        let url = URL(string: "https://api.adviceslip.com/advice")!
        
        NetworkClient.shared.request(url, type: Advice.self)
            .sink(receiveCompletion: { completion in
                switch completion {
                case .failure(let error):
                    print("Error: \(error)")
                case .finished:
                    print("Finished")
                }
            }, receiveValue: {[weak self] dataModel in
                self?.advice = dataModel
            })
            .store(in: &cancellables)
    }
    
    
}

Update the view to fetch new advice when user pulls down the list to refresh.


struct RefreshableListView: View {
    @State private var viewModel = RefreshViewModel()

    var body: some View {
        NavigationStack {
            List {
                Text(viewModel.advice.slip.advice)
                    .font(.largeTitle)
            }
            .refreshable {
                viewModel.fetchAdvice()
            }
            .navigationTitle("Advice")
        }
    }

    func fetchDataFromAPI() async -> [String] {
        return ["Mastering SwiftData", "Mastering SwiftUI", "Practical Core Data"]
    }
}

Complete code should look like this


struct RefreshableListView: View {
    @State private var viewModel = RefreshViewModel()

    var body: some View {
        NavigationStack {
            List {
                Text(viewModel.advice.slip.advice)
                    .font(.largeTitle)
            }
            .refreshable {
                viewModel.fetchAdvice()
            }
            .navigationTitle("Advice")
        }
    }

    func fetchDataFromAPI() async -> [String] {
        return ["Mastering SwiftData", "Mastering SwiftUI", "Practical Core Data"]
    }
}

struct Advice: Codable {
    let slip: Slip
}

struct Slip: Codable {
    let id: Int
    let advice: String
}

import Combine

final class NetworkClient {
    static let shared = NetworkClient()
    
    func request<T: Decodable>(_ url: URL, type: T.Type) -> AnyPublisher<T, Error> {
        URLSession.shared.dataTaskPublisher(for: url)
            .map { $0.data }
            .decode(type: T.self, decoder: JSONDecoder())
            .receive(on: DispatchQueue.main)
            .eraseToAnyPublisher()
    }
}

import Observation

@Observable
final class RefreshViewModel {
    
    private var cancellables = Set<AnyCancellable>()
    
    var advice = Advice(slip: Slip(id: 0, advice: ""))
    
    init() {
        fetchAdvice()
    }
    
    func fetchAdvice() {
        let url = URL(string: "https://api.adviceslip.com/advice")!
        
        NetworkClient.shared.request(url, type: Advice.self)
            .sink(receiveCompletion: { completion in
                switch completion {
                case .failure(let error):
                    print("Error: \(error)")
                case .finished:
                    print("Finished")
                }
            }, receiveValue: {[weak self] dataModel in
                self?.advice = dataModel
            })
            .store(in: &cancellables)
    }
    
    
}

Build and run

Currently, we’re displaying only a single piece of advice. Let’s modify our example to display a collection of advice, with the most recent one appearing at the top. To do this, update the view model to store a collection of advice and insert newly fetched advice at the zero index of the collection. This approach ensures that the latest advice is always shown first, improving the user experience by highlighting the most recent information.

import Observation

@Observable
final class RefreshViewModel {
    
    private var cancellables = Set<AnyCancellable>()
    
    var adviceCollection = [Advice]()
    
    init() {
        fetchAdvice()
    }
    
    func fetchAdvice() {
        let url = URL(string: "https://api.adviceslip.com/advice")!
        
        NetworkClient.shared.request(url, type: Advice.self)
            .sink(receiveCompletion: { completion in
                switch completion {
                case .failure(let error):
                    print("Error: \(error)")
                case .finished:
                    print("Finished")
                }
            }, receiveValue: {[weak self] advice in
                self?.adviceCollection.insert(advice, at: 0)
            })
            .store(in: &cancellables)
    }
    
    
}

Update the view to use advice collection inside the list.

struct RefreshableListView: View {
    @State private var viewModel = RefreshViewModel()

    var body: some View {
        NavigationStack {
            List(viewModel.adviceCollection, id: \.slip.id) { anAdvice in
                Text(anAdvice.slip.advice)
                    .font(.title)
            }
            .refreshable {
                viewModel.fetchAdvice()
            }
            .navigationTitle("Advice")
        }
    }

    func fetchDataFromAPI() async -> [String] {
        return ["Mastering SwiftData", "Mastering SwiftUI", "Practical Core Data"]
    }
}

Build and run


Thanks for reading. Check out more at https://www.devtechie.com