Flow with SwiftUI and MVVM — Part 2: ViewModels [deprecated]

Nick McConnell
5 min readJun 22, 2020

--

[Note this was been reworked for SwiftUI release 3 — see update]

In part 1, we looked at the navigation structure needed to support a multi-screen data entry flow with branching which typical to but not limited to a user onboarding flow. We partially succeeded in satisfying our “screen flow manifestoand in this final part we look to completing that with introduction of ViewModels.

As part of point 2 of the manifesto our goal is to have isolated ViewModels for each screen in the flow. SwiftUI works very cleanly with ViewModels giving us 2-way binding with OberservableObject. Overall though, how do we want to structure the generation and usage of ViewModels? There are many ways to implement the MVVM design pattern, but for this case I like to think in these terms:

  1. ViewModel acts as a simple non-UI data interface to the View. Generally contains only limited business logic — e.g. simple validation etc.
  2. We have a single model (“the Model”) as our overall data store which is persistent across screens.
  3. ViewModels can be generated from the Model and can also update the Model.
  4. Because this is data entry which means updating data, ViewModels and the Model are more naturally as classes rather than structs.
  5. We will use our FlowController to orchestrate where necessary.

So visually this could look like:

Navigation and Model Structure

So a screen with it’s paired ViewModel would generically look like this:

class Screen1VM: ObservableObject {
@Published var detail1 = ""
@Published var detail2 = ""
}
struct Screen1: View {
@ObservedObject var vm: Screen1VM
let didTapNext: () -> ()
var body: some View {
VStack(alignment: .center) {
Text("Please enter details")
TextField("Detail1", text: $vm.detail1)
.textFieldStyle(RoundedBorderTextFieldStyle())
TextField("Detail2", text: $vm.detail2)
.textFieldStyle(RoundedBorderTextFieldStyle())
Button(
action: { self.didTapNext() },
label: { Text("Next") }
)
}
}
}

All well and good but how to we generate the ViewModel and pass the ViewModel back through the Controller to potentially update the main Model? Both actions can be handled by the FlowControllerView delegating to the FlowController.

Firstly, we update the can pass the ViewMode through the didTapNext function so the above becomes:

struct Screen1: View {
@ObservedObject var vm: Screen1VM
let didTapNext: (Screen1VM) -> ()
var body: some View {
...
Button(
action: { self.didTapNext(self.vm) },
label: { Text("Next") }
)
...

FlowControllerView and it’s delegate now need to accept a screen-specific didTapNext and a new function to generate the ViewModel.

protocol FlowControllerViewDelegate: class {
...
func didTapNext(vm: Screen1VM)
...
}
struct FlowControllerView: View {
...
var body: some View {
NavigationView {
VStack() {
Screen1Phone(
vm: self.delegate.make(),
didTapNext: self.delegate.didTapNext
)
Flow(state: navigateTo2) {
...
}
...

The 2 new delegate functions are implemented by FlowController. These ultimately require the overall, persistent Model which is owned by the FlowController. This would look like:

class Model {
var detail1: String?
var detail2: String?
var detail3: String?
...

func make() -> Screen1VM {
return Screen1PhoneVM()
}
... func update(with vm: Screen1PhoneVM) {
detail1 = vm.detail1
detail2 = vm.detail2
}
...
}
class FlowController {
...
let model: Model ... init() {
self.model = Model()
...
}
func didTapNext(vm: Screen1VM) {
// Network call to send verification number, then...
model.update(with: vm)
view?.navigate(to: .screen2)
}
... func make() -> Screen1VM {
return model.make()
}
...}

FlowController now simply orchestrates the initialization of the ViewModel, any once the user has entered the screen details, any necessary network calls, the updating of the Model and moving the navigation foward. However, it is not directly responsibility for any of these actions. The Model generates and updates itself from the ViewModel.

This may seem like overkill for this simple example, but in the real world organizing and scaling this complexity can challenging and this helps to keep responsibilities clear (point 5 in our manifesto). Note also the ViewModel of a screen often needs defaults in its generation, which can be easily handled by the make() function of the Model.

Validation is beyond the scope of this article, but typically there would be different levels required. Simple validation (such as all fields must be completed) could be handled directly by the logic in the ViewModel and disabled(Bool) added to the screen view’s Button referencing the ViewModel. More complex validation (such as backend calls) would be orchestrated by the FlowController.

Another important and interesting note is that normally in SwiftUI all views within the hierarchy are instantiated upfront. For us that correlates to all screens and their corresponding ViewModels are initalized at the beginning. If we have some screen’s ViewModels relying on previous screen’s ViewModels this will cause us problems. Luckily there is a fix with a LazyView discussed in detail here (thank you!), but I do hope that lazy instantiation is more formally handled in the next version of SwiftUI especially in the case of screen navigation.

So going back to our overall screen flow, lets make it a bit more specific. Something like this:

An example onboarding flow

In screen 1, the user enters a phone number, enters the SMS verification number in screen 2, then personal details in 3 and can either skip to the end or decide to add work email on screen 4. Some screens require data from previous screens. The implementation can be found here https://github.com/nickm01/NavigationFlow/tree/part2 (this is the part2 branch on the same repo as part 1).

With the help of lazy computed screens, you’ll notice that the final FlowControllerView implementation is pretty close to our original part 1 pseudo-code:

var body: some View {
NavigationView {
VStack() {
screen1Phone
Flow(state: navigateTo2) {
screen2Verification
Flow(state: navigateTo3) {
screen3NameEmail
Flow(state: navigateTo4) {
screen4CompanyInfo
Flow(state: navigateToFinalFrom4) {
screen5Final
}
}
Flow(state: navigateToFinalFrom3) {
screen5Final
}
}
}
}
}
}

And lastly a note on unit testing as per point 3 of the manifesto. With the introduction of an injectable view protocol, FlowController can now be unit tested and as it itself has no reference to SwiftUI is independant of actual UI implementation. Navigation, ViewModels and Model orchestration can be independantly tested.

Hope you enjoyed this journey. Would love to hear any feedback.

--

--