Creating a SwiftUI view for loading Combine publishers
When making views that display information from the internet, a common pattern is to have a view that displays three states: an initial view while content is being fetched and then either the view to display the content or a view to show there was an error.
In this post we’ll walk through creating a SwiftUI view to load content using a Combine publisher, showing views provided by the user for our three possible states; initial, output and failure. This PublisherView
will reduce the complexities involved to a single initialiser at the call site.
PublisherView(publisher: publisher,
initial: { return someInitialView() },
output: { value in someView(using: value) },
failure: { error in someView(displaying: error) })
The code in this post is in my PublisherView Swift package on GitHub.
Defining our PublisherView
We’ll make a view that is generic over the publisher and displays different views for its output or failure, as well as defining the view to show for the initial state while the we wait for the publisher to receive its first output value.
struct PublisherView<Publisher, InitialView, OutputView, FailureView>
where
Publisher: Combine.Publisher,
InitialView: View,
OutputView: View,
FailureView: View
{
typealias Output = Publisher.Output
typealias Failure = Publisher.Failure
}
We define the publisher to be generic, so that we can pass any instance of a publisher in, but have access to its specified Output
and Failure
types, which we’ll make use of later in this post. Using Publisher
as the name for this generic parameter reads nicer in the rest of the code, but because we want to constrain it to conform to the Publisher
protocol in the Combine framework, we need to reference the protocol using its fully qualified name: Combine.Publisher
. It’ll be the only place we need to reference the Combine protocol, so in my mind it’s better than naming our generic parameter P
or some such.
We also define three generic types InitialView
, OutputView
and FailureView
which are all types of View
. These are the views that will be displayed for each of our given states.
Display State
To model the different possible states we will use an enum with the three cases each having an associated value, allowing us to specify that we only want to show one single view at any time and that could be one of either the initial, output or failure views. The Display
type is nested in the PublisherView
gaining access to all of its generic parameters, so we don’t need to duplicate them which is nice for both brevity and understanding.
extension PublisherView {
fileprivate enum Display {
case initial(InitialView)
case output(OutputView)
case failure(FailureView)
}
}
Subscribing to the Publisher
When subscribing to publishers we need to keep a strong reference to the returned Cancellable
object. When it is released, the publisher will be cancelled and no longer output values.
Attempting to subscribe using the PublisherView
At first, we might try to have two private variables, one to hold the cancellable and a variable to hold the current Display
value. As we want changes to the display to trigger SwiftUI to recreate our view we think we can put it as a @State
property. Anytime the display state changes, the view body will be refetched.
struct PublisherView<Publisher, InitialView, OutputView, FailureView>
where
Publisher: Combine.Publisher,
InitialView: View,
OutputView: View,
FailureView: View
{
typealias Output = Publisher.Output
typealias Failure = Publisher.Failure
private var cancellable: AnyCancellable?
@State private var display: Display
init(publisher: Publisher,
initial makeInitialView: () -> InitialView,
output makeOutputView: @escaping (Output) -> OutputView,
failure makeFailureView: @escaping (Failure) -> FailureView) {
_display = State(initialValue: .initial(makeInitialView()))
cancellable = publisher
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: { completion in
guard case .failure(let failure) = completion else { return }
self.display = .failure(makeFailureView(failure))
}, receiveValue: { output in
self.display = .output(makeOutputView(output))
})
}
}
It may look odd that we’re using the underscore prefix to set the state property itself for _display
. This is because we need the initial view passed in the initialiser to set the initial value. if you try to just set a value for display
then the compiler will give quite a confusing error that it is being accessed before initialized, which actually occurs because the State
wrapper is not initialised but we are attempting to put a value inside it.
Escaping closure captures mutating ‘self’ parameter
We also see two cases of the compiler error above, which are telling us that we are mutating self by setting the display property from both the receiveCompletion
and receiveValue
closures. PublisherView
is a struct so it cannot mutate its properties unless we’re in a mutating function. The solution to this is to create an ObservableObject
.
Fixing these issues by subscribing using an internal class
Instead of trying to mutate the struct, we can instead push the publisher loading logic into a class. Our PublisherView
struct can now hold a reference to the class, which is free to mutate itself. SwiftUI provides the @ObservedObject
property wrapper to observe changes in objects which publish their changes, causing our view to be refreshed. In the initialiser of the PublisherView
we can simply pass all the values through to the Loader
.
struct PublisherView<Publisher, InitialView, OutputView, FailureView>
where
Publisher: Combine.Publisher,
InitialView: View,
OutputView: View,
FailureView: View
{
typealias Output = Publisher.Output
typealias Failure = Publisher.Failure
@ObservedObject private var loader: Loader
init(publisher: Publisher,
initial: () -> InitialView,
output: @escaping (Output) -> OutputView,
failure: @escaping (Failure) -> FailureView) {
loader = Loader(publisher: publisher,
initial: initial,
output: output,
failure: failure)
}
}
The Loader
class will play a similar role to what we attempted to do directly in the view. We’ll store the display state as a variable, but use the @Published
property wrapper, which publishes chages whenever the display state variable is changed. Initially we’ll set this to be our initial view using the user-provided function. We subscribe to the publisher on the main thread and update the display when a value or failure completion is received.
extension PublisherView {
fileprivate final class Loader: ObservableObject {
@Published var display: Display
private var cancellable: AnyCancellable?
init(publisher: Publisher,
initial makeInitialView: () -> InitialView,
output makeOutputView: @escaping (Output) -> OutputView,
failure makeFailureView: @escaping (Failure) -> FailureView) {
display = .initial(makeInitialView())
cancellable = publisher
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: { completion in
guard case .failure(let failure) = completion else { return }
self.display = .failure(makeFailureView(failure))
}, receiveValue: { output in
self.display = .output(makeOutputView(output))
})
}
}
}
Conforming to the View protocol
Finally, we’ll conform PublisherView to SwiftUI’s View protocol which requires us to specify a body. At first this seemed like an impossible challenge given that you cannot use switch statements inside a view builder and we cannot return different views depending on the display state because SwiftUI requires the same type returned. This may lead us down the path of AnyView
, which is the equivalent of putting all the view information into a black box that is very expensive for SwiftUI to inspect.
Instead, there is one key piece of understanding needed; in SwiftUI Optional is View and if that optional is .none
, then SwiftUI doesn’t render anything for it. So instead of attempting to return only one view, we should try to display all three views and supply nil for the ones that do not exist.
View builders also don’t currently support if let
syntax, so we can extract this logic into private variables for each of the potential views, the initial, output and failure views, making the type returned for each explict. We can be safe in the knowledge of only one of these properties returning a non-nil value for two important reasons:
- The display enum only provides access to one of the views at any time.
InitialView
,OutputView
andFailureView
are generic, which means thePublisherView
never knows what types these will end up being so cannot create instances of them without going through the functions passed in. This is often an overlooked benefit to using generic parameters.
Our implementation of the body can now place each of the optional view properties into a ZStack
, which means that views will replace views when they load in. For example between the initial and an output display state, SwiftUI will create a diff between them. The ZStack
will go from having the initial view and two nil views to one with the output view and two nil views.
extension PublisherView: View {
var body: some View {
ZStack {
initialView
outputView
failureView
}
}
private var initialView: InitialView? {
guard case let .initial(view) = loader.display else { return nil }
return view
}
private var outputView: OutputView? {
guard case let .output(view) = loader.display else { return nil }
return view
}
private var failureView: FailureView? {
guard case let .failure(view) = loader.display else { return nil }
return view
}
}
Using the PublisherView
As an example of using the PublisherView
, we’ll create a PostsView
to display a list of posts fetched from a server, which is all defined in a publisher that is in this example is passed in. We’ll also create a nested Content
view which will do the actual work of showing the array of posts.
The generated memberwise initialiser of the internal Content
view has the function signature ([Post]) -> Content
, which is interesting because it matches what the PublisherView wants for the output function. This means we can pass Content.init as the parameter for the output, without seeing any curlies in sight!
We can use this same tactic by passing the initialiser functions for a LoadingView
and FailureView
, which we’ll show for the initial and failure cases respectively. These could be views defined separately to the PostsView
and used across the app for consistency.
Remember when we used a ZStack
to contain our views? What’s really cool about SwiftUI is that the user can specify that they would like these transitions to animate by using the .animation()
modifier on the PublisherView
. SwiftUI will use its diff to fade out the initial view and fade in the output view, all without the PublisherView
explicitly handling animations or providing properties to turn animations on; This is a very different world to UIKit!
struct PostsView: View {
// Get this publisher from somewhere, maybe a data task publisher
let publisher: AnyPublisher<[Post], Error>
var body: some View {
PublisherView(publisher: publisher,
initial: LoadingView.init,
output: Content.init,
failure: FailureView.init)
.animation(.default)
}
}
extension PostsView {
fileprivate struct Content: View {
let posts: [Post]
var body: some View {
List(posts) { post in
Text(post.title)
}
}
}
}
struct LoadingView: View {
var body: some View {
// Some awesome loading view
}
}
struct FailureView: View {
let error: Error
var body: some View {
Text(error.localizedDescription)
}
}
Conclusion
It’s now possible to create libraries that can integrate with one another based around the core concepts defined in Combine and SwiftUI and specifically the core types of the Publisher
and the View
. Together with the Swift Package Manager integration into Xcode, this could very well be an explosion of ideas.
As an example, I’m currently using my Resourceful library to create publishers that fetch and transform data from the network and display these using PublisherView. Yet neither package knows anything about the other. The fundamental types are provided by Apple. It’s easy to see that you could use the code in this post and mix it with another library of your choice which vends a Combine publisher, which may be another networking library or other purposes such as observing AVPlayerItem metadata changes or performing HealthKit queries.