Building an Observable type for SwiftUI views
Discover page available: SwiftUISwiftUI 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.