Loading Views in SwiftUI
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)
}
}
}