Weekly Swift articles, podcasts and tips by John Sundell.

Building an Observable type for SwiftUI views

Published on 14 May 2020
Basics article available: Combine

SwiftUI ships with a number of tools for connecting a view to a piece of state, which in turn makes the framework automatically re-render that view whenever its state was modified.

For example, the @State property wrapper can be used to keep track of a view’s internal, local state — while @Binding enables us to pass mutable state between different views. There’s also @ObservedObject, which along with its ObservableObject protocol counterpart, enables us to construct custom objects that our views can then observe.

Here’s how we might implement a view model as such an observed object, which uses a Combine publisher to subscribe to changes in its underlying data model — in this case a Podcast type:

class PodcastViewModel: ObservableObject {
    @Published private(set) var podcast: Podcast
    private var cancellable: AnyCancellable?

    init<T: Publisher>(
        podcast: Podcast,
        publisher: T
    ) where T.Output == Podcast, T.Failure == Never {
        self.podcast = podcast
        self.cancellable = publisher.assign(to: \.podcast, on: self)
    }
}

To learn more about the @Published property wrapper used above, check out “Published properties in Swift”.

We could then build a corresponding PodcastView that uses the above PodcastViewModel as its data source, like this:

struct PodcastView: View {
    @ObservedObject var viewModel: PodcastViewModel

    var body: some View {
        HStack {
            Image(uiImage: viewModel.podcast.image)
            VStack(alignment: .leading) {
                Text(viewModel.podcast.name)
                    .bold()
                Text(viewModel.podcast.creator)
                    .foregroundColor(.secondary)
            }
        }
    }
}

While view models can be incredibly useful in order to encapsulate the logic required to bridge the gap between a view and its data model (while also enforcing some separation of concerns between those two layers), in the above case, our view model simply acts as an observable wrapper for our Podcast model — which in turn requires us to always access that model using viewModel.podcast.

Since there’s really nothing Podcast model-specific about our view model implementation — let’s see if we can generalize it instead, and in doing so, make it easier to use as well. To do that, let’s rename it to Observable, and make it a generic over any Value — with one important addition: we’ll also make it support dynamic member lookup, like this:

@dynamicMemberLookup
final class Observable<Value>: ObservableObject {
    @Published private(set) var value: Value
    private var cancellable: AnyCancellable?

    init<T: Publisher>(
        value: Value,
        publisher: T
    ) where T.Output == Value, T.Failure == Never {
        self.value = value
        self.cancellable = publisher.assign(to: \.value, on: self)
    }

    subscript<T>(dynamicMember keyPath: KeyPath<Value, T>) -> T {
        value[keyPath: keyPath]
    }
}

The big benefit of the above approach (apart from the fact that we now have a completely reusable type that can be used to observe any model) is that we can now access each of our Podcast model properties directly, thanks to @dynamicMemberLookup:

struct PodcastView: View {
    @ObservedObject var podcast: Observable<Podcast>

    var body: some View {
        HStack {
            Image(uiImage: podcast.image)
            VStack(alignment: .leading) {
                Text(podcast.name)
                    .bold()
                Text(podcast.creator)
                    .foregroundColor(.secondary)
            }
        }
    }
}

Much nicer! Now, time for the bonus round. Since our new Observable type is very similar to Combine’s built-in CurrentValueSubject (in that it keeps track of the latest emitted value), let’s also create a convenience API that’ll let us easily convert any such subject into an Observable object:

extension CurrentValueSubject where Failure == Never {
    func asObservable() -> Observable<Output> {
        Observable(value: value, publisher: self)
    }
}

Note that a key difference between our Observable and Combine’s CurrentValueSubject is that the latter is mutable, which might not be something that we want to expose to certain parts of our view layer.

With the above in place, we can now easily create instances of our PodcastView from a CurrentValueSubject that emits new values whenever the podcast in question was updated:

func makePodcastView(
    with subject: CurrentValueSubject<Podcast, Never>
) -> some View {
    PodcastView(podcast: subject.asObservable())
}

While there are several other approaches can be used to update SwiftUI views whenever their models change (including using the built-in .onReceive view modifier to let a view subscribe to a Combine publisher directly), I feel like the above kind of Observable type provides a really neat way to let a view subscribe to a single model in a read-only fashion, without requiring that view to contain any form of subscription logic.