SwiftUI View Models: A Polymorphic Approach

Luis Recuenco
The Swift Cooperative
7 min readJan 18, 2024
Photo by Possessed Photography on Unsplash

Introduction

Polymorphism is one of the most powerful tools we have at our disposal. Coming from Objective-C, Swift exposed us to parametric polymorphism via generics, but this article is all about the other type of polymorphism, arguably the most common one: subtype polymorphism. Let’s see how it works with view models and more generally, SwiftUI observable objects.

iOS 17 and the new Observation framework

I wrote about the new Observation framework here. It’s one of the best additions we’ve recently had in SwiftUI. If you are lucky enough to target the latest iOS 17 in your apps, things are quite straightforward.

First, let’s introduce the example we want to build: a counter screen that increments or decrements a value differently: synchronously or asynchronously. First, let’s define the view model abstraction.

@MainActor
protocol CounterViewModelType: Observable {
var count: Int { get }
var isLoading: Bool { get }

func increment()
func decrement()
}

Simple enough. Just a number that will be shown on screen, a flag indicating if it’s loading the number and two actions to increment and decrement.

Underneath you can see the app working with the synchronous implementation of the CounterViewModelType.

And here’s the asynchronous one.

The simplest app ever! But as always, foundational concepts are better explained with simple, naive examples to avoid unnecessary noise around the important concepts.

Let’s start with the synchronous implementation:

@Observable
class SyncCounterViewModel: CounterViewModelType {
private(set) var count = 0

func increment() {
count += 1
}

func decrement() {
count -= 1
}

var isLoading: Bool { false }
}

And now, the asynchronous one:

@Observable
class AsyncCounterViewModel: CounterViewModelType {
private(set) var count = 0
private(set) var isLoading = false

func increment() {
afterOneSecond { [weak self] in self?.count += 1 }
}

func decrement() {
afterOneSecond { [weak self] in self?.count -= 1 }
}

private func afterOneSecond(_ callback: @escaping () -> Void) {
Task {
isLoading = true
try! await Task.sleep(nanoseconds: 1 * NSEC_PER_SEC)
isLoading = false
callback()
}
}
}

And finally, the view layer.

@MainActor
struct CounterView: View {
let viewModel: any CounterViewModelType

var body: some SwiftUI.View {
VStack {
Button("Increment") { viewModel.increment() }
.disabled(viewModel.isLoading)

Text(viewModel.isLoading ? "Loading…" : "\(viewModel.count)")

Button("Decrement") { viewModel.decrement() }
.disabled(viewModel.isLoading)
}
}
}

CounterView(viewModel: {
if Int.random(in: 0..<100) % 2 == 0 {
SyncCounterViewModel()
} else {
AsyncCounterViewModel()
}
}())

As you can see, passing a different implementation of the view model at runtime works as expected when working with the new Observation framework. Unfortunately, if you aren’t as lucky as to use iOS 17 already (like most of us I guess), things get much more interesting…

Pre-iOS 17 and the old ObservableObject

Let’s refactor our previous code to work with ObservableObject. The first step is to modify our view model abstraction to conform to it.

@MainActor
protocol CounterViewModelType: ObservableObject {
var count: Int { get }
var isLoading: Bool { get }

func increment()
func decrement()
}

We also need to modify both sync and async implementations to publish their properties via @Published.

class SyncCounterViewModel: CounterViewModelType {
@Published private(set) var count = 0

}

class AsyncCounterViewModel: CounterViewModelType {
@Published private(set) var count = 0
@Published private(set) var isLoading = false

}

And finally, we have to add the @ObservedObject property wrapper on the view layer.

struct CounterView: View {
@ObservedObject var viewModel: any CounterViewModelType

}

And… we get our first error.

Type ‘any CounterViewModelType’ cannot conform to ‘ObservableObject’

This is interesting. CounterViewModelType is an ObservableObject , so it’d be sensible to assume that any CounterViewModelType would also conform to ObservableObject . Unfortunately, that’s not how Swift works when we use protocols as existential types (unlike using protocols as generic constraints). As meta as this sounds, protocols do not conform to themselves in Swift. Take a look at this.

// Using `Codable` as type -> compiler error
struct Container: Codable {
let data: Codable
}

// Using `Codable` as generic constraint -> it works
struct Container<Data: Codable>: Codable {
let data: Data
}

So, let’s do that, let’s change our view to use our CounterViewModelType as a generic constraint.

@MainActor
struct CounterView<ViewModel: CounterViewModelType>: View {
@ObservedObject var viewModel: ViewModel
}

But as expected, this no longer compiles

CounterView(viewModel: {  () -> any CounterViewModelType in
if Int.random(in: 0..<100) % 2 == 0 {
SyncCounterViewModel()
} else {
AsyncCounterViewModel()
}
}())

Type ‘any CounterViewModelType’ cannot conform to ‘CounterViewModelType’

This was expected. As CounterView is now generic over a specific CounterViewModelType, the compiler needs to disambiguate the concrete type when creating the instance. The previous code would make ViewModel == any CounterViewModelType , and, if you remember… protocols do not conform to themselves! 😅

So, we have to change things so the view model is not given to us. Rather, we create the specific view model explicitly, so the compiler has a specific type, conforming to the CounterViewModelType.

if Int.random(in: 0..<100) % 2 == 0 {
CounterView(viewModel: SyncCounterViewModel()) // CounterView<SyncCounterViewModel>
} else {
CounterView(viewModel: AsyncCounterViewModel()) // CounterView<AsyncCounterViewModel>
}

This works. But we can’t always afford the privilege of creating the view model ourselves. Sometimes, for whatever reason, the view model is just given to us. We want a more dynamic approach to provide this view model dependency to our view. And in those cases… Well, we need a different approach.

Leaving ObservableObject behind

It seems that ObservableObject and polymorphism don’t get along very well… Let’s try something different. Let’s forget about ObservableObject and let’s declare our view model like a container that emits states over time.

@MainActor
protocol ViewModelType<State> {
associatedtype State

var statePublisher: Published<State>.Publisher { get }
}

Now, let’s define our new state and protocols for the counter screen:

struct CounterState {
var count: Int
var isLoading: Bool
}

@MainActor
protocol CounterViewModelType: ViewModelType<CounterState> {
func increment()
func decrement()
}

Having that in place, our two view model implementations would be:

class SyncCounterViewModel: CounterViewModelType {
var statePublisher: Published<CounterState>.Publisher { $state }
@Published private var state = CounterState(count: 0, isLoading: false)

func increment() {
state.count += 1
}

func decrement() {
state.count -= 1
}
}

class AsyncCounterViewModel: CounterViewModelType {
var statePublisher: Published<CounterState>.Publisher { $state }
@Published private var state = CounterState(count: 0, isLoading: false)

func increment() {
afterOneSecond { [weak self] in self?.state.count += 1 }
}

func decrement() {
afterOneSecond { [weak self] in self?.state.count -= 1 }
}

private func afterOneSecond(_ callback: @escaping () -> Void) {
Task {
state.isLoading = true
try! await Task.sleep(nanoseconds: 1 * NSEC_PER_SEC)
state.isLoading = false
callback()
}
}
}

As you can see, we have a little bit of extra code, by having to define the statePublisher computed property, which forwards the projected value of our @Published property.

The view layer would look like this:

struct CounterView: View {
let viewModel: any CounterViewModelType

@State private var state = CounterState(count: 0, isLoading: false)

var body: some SwiftUI.View {
VStack {
Button("Increment") { viewModel.increment() }
.disabled(state.isLoading)

Text(state.isLoading ? "Loading…" : "\(state.count)")

Button("Decrement") { viewModel.decrement() }
.disabled(state.isLoading)
}
.onReceive(viewModel.statePublisher) {
state = $0
}
}
}

We need two things:

  • Declare a new @State variable with the state that will render the view.
  • Subscribe to the underlying view model publisher, setting the new state.

That gets the job done without a lot of boilerplate. But as always, things can be improved.

The final solution

I always like to think from an API point of view. That is, which is the API I would like to have and work backwards from there. I’d like to:

  1. Avoid having to subscribe each time to the view model publisher and reset the state.
  2. I’d like to avoid setting an initial, arbitrary, @State variable on the view. The view model should be in charge of providing the initial state, not the view itself.

With those two preconditions, our ideal solution would look like this:

struct CounterView: View {
let viewModel: any CounterViewModelType

var body: some SwiftUI.View {
WithViewModel(viewModel: viewModel) { state in
VStack {
Button("Increment") { viewModel.increment() }
.disabled(state.isLoading)

Text(state.isLoading ? "Loading…" : "\(state.count)")

Button("Decrement") { viewModel.decrement() }
.disabled(state.isLoading)
}
}
}
}

Let’s then create that WithViewModel wrapper.

@MainActor
protocol ViewModelType<State> {
associatedtype State

var statePublisher: Published<State>.Publisher { get }
}

@MainActor
struct WithViewModel<WrappedView, State>: View where WrappedView: View {
@SwiftUI.State private var state: State?
private let publisher: Published<State>.Publisher
private let wrappedView: (State) -> WrappedView

init<ViewModel>(
viewModel: ViewModel,
@ViewBuilder wrappedView: @escaping (State) -> WrappedView
) where ViewModel: ViewModelType<State> {
self.wrappedView = wrappedView
publisher = viewModel.statePublisher
}

var body: some View {
root.onReceive(publisher) {
state = $0
}
}

@ViewBuilder
private var root: some View {
if let state {
wrappedView(state)
}
}
}

Conclusion

Thanks for reaching the end of the article. It was hopefully an interesting exercise to make polymorphic observable objects work flawlessly with SwiftUI.

I think the final solution gets the job done with a fairly clean API, without fighting the SwiftUI framework too much to make it work.

But as I previously said, if you are lucky enough to target iOS 17, the new Observation framework solves all your problems. Well, almost all of them.

--

--

Luis Recuenco
The Swift Cooperative

iOS at @openbank_es. Former iOS at @onuniverse, @jobandtalent_es, @plex and @tuenti. Creator of @ishowsapp.