Daniel Tull: Blog

Loading Views in SwiftUI

Tuesday, 22 July 2025

A common desire when building apps is to have a loading view appear while content is loading from the network and for the content to show when it has loaded or an error view to show if there was an error.

What is a Resource??

This is an idea I stole from Daniel Steinberg, though I’ve also seen it used by Chris and Florien, the fine objc.io folks too. It is a type which packages up details on where to get a http-based resource and how to convert the returned data into a type we care about.

I will be using the Resource type from my Resourced package, it takes a request and a throwing transform function from Data and URLResponse to a generic Value.

public struct Resource<Value> {
    public let request: URLRequest
    public let transform: (data: Data, response: URLResponse) throws -> Value
}

What about Loading?

Using a Resource type, we can package that loading into a view that can choose the correct view to show at any given point.

Radio stations are grouped by genres and lists of stations in a genre are fetched separately.

This example shows a stations view which takes a genre and in the body we use a ResourceLoader view to load the stations, providing view builders for each stage: loading, success and failure. Only one of these will be on screen at any given point and only one of success or failure will ever be called.

struct StationsView: View {

    let genre: Genre

    var body: some View {

        ResourceLoader(resource: .stations(for: genre), loading: {

            Text("Loading...")

        }, success: { stations in

            List(stations.identified(by: \.identifier)) { station in
                Text(station.name)
            }

        }, failure: { error in

            Text("There was an error")

        })
        .navigationBarTitle(genre.name)
    }
}

Building a Resource Loader

Now

public struct ResourceLoader<Value, Loading, Success, Failure>: View
    where
    Loading: View,
    Success: View,
    Failure: View
{

    @ObjectBinding private var loader: Loader

    public init(session: URLSession = .shared,
                resource: Resource<Value>,
                loading: @escaping () -> Loading,
                success: @escaping (Value) -> Success,
                failure: @escaping (Error) -> Failure) {

        loader = Loader(session: session,
                        resource: resource,
                        loading: loading,
                        success: success,
                        failure: failure)
    }

    public var body: some View {
        loader.view
    }
}

extension ResourceLoader {

    private final class Loader: BindableObject {

        var view: AnyView { didSet { didChange.send(self) } }
        var didChange = PassthroughSubject<Loader, Never>()
        private var sink: Subscribers.Sink<Value, Error>? = nil

        init(session: URLSession,
             resource: Resource<Value>,
             loading: @escaping () -> Loading,
             success: @escaping (Value) -> Success,
             failure: @escaping (Error) -> Failure) {

            view = AnyView(loading())

            sink = session
                .publisher(for: resource)
                .receive(on: DispatchQueue.main)
                .sink(receiveCompletion: { completion in
                    guard case .failure(let error) = completion else { return }
                    self.view = AnyView(failure(error))
                }, receiveValue: { value in
                    self.view = AnyView(success(value))
                })
        }
    }
}

An example of how this would be used to show the stations in a given group. The group property is a model object that represents a group of radio stations.

struct StationsView: View {

    let group: StationKit.Group

    var body: some View {

        ResourceLoader(resource: .stations(for: group), loading: {

            Text("Loading...")

        }, success: { stations in

            List(stations.identified(by: \.identifier)) { station in
                Text(station.name)
            }

        }, failure: { error in

            Text("There was an error")

        })
        .navigationBarTitle(group.name)
    }
}

Perhaps useful to see that .stations(for: group) returns a Resource:

extension Resource where Value == [Station] {

    public static func stations(for group: Group) -> Resource<[Station]> {

        return Resource(request: URLRequest(url: group.url)) {
            try JSONDecoder().decode([Station].self, from: $0.data)
        }
    }
}