SwiftUI: Multilevel MVVM

--

Image from https://www.pexels.com/search/cocoon/

In our previous article, we talked about the danger regarding the misuse of ObservedObject and how you may sacrifice all your View state integrity by not saving the data across multiple redraws. In fact, as we talked before, we need a way to save our data and ObservableObject s any time it's re-rendered. The solution to that is mixing both declarative and imperative paradigms into a new state of architecture we call Multilevel MVVM(ML-MVVM for short).

Save your State!

Let's record the problem we had previously with ObservableObjects: We had a scene that relied its content on a ParentViewModel class and a children view's data was instantiated in the shape of a children ViewModel and injected directly in your subview when the scene updated. However, if we were accessing the ChildViewModel as an ObservedObject , which is instantiated each time the view is refreshed, we had a consistency problem because the state of truth of our subview was not persisted across the updates, when some ParentViewModel property changed. Because of that, we started using StateObject viewModels to avoid reinitializing our observables each time an update is needed. Take a look into our scene:

This is what we are rendering:

Basically we have three different views each one being the child of another, therefore establishing a declarative hierarchy:

Repair that we are injecting a new ViewModel into each child after the parent is refreshed. Usually if the observables were ObservedObject , the state of the child would not be kept the same and it would be reset as well, however, as we are relying on a StateObject , the observable initializer is not called once again. This solution works perfectly for most scenarios, but there are some edge cases where even a StateObject would not maintain our precious data for the view.

Forcing the StateObject to reinitialize

There is a case where your ViewModel is reset even if it is a StateObject . Let's include some lines into our previous code example and give the option to hide our ChildView1 and show it again through a simple button:

Try running this code. This is the outcome you shall be seeing:

Pay attention in the state of our children view models not persisting when we hide them by clicking on the button. Why that?

What's really happening is that SwiftUI works with a structure that is something like a tree of components representing(declaring) what the layout looks like. When we simply change the parent, everything is updated, but SwiftUI still has our hierarchy in memory and checks that as we still have a ChildView1 , its StateObject is still needed. But when we click the button and our ChildView1 is not there anymore(emphasis to the fact it's not hidden, it was destroyed), SwiftUI interprets its StateObject is not needed anymore and its reference is destroyed:

Thinking of that, we need a way to guarantee we can persist the data of our observable even when our View is destroyed in the hierarchy by some logical condition( if statements, switch cases, guard let , etc).

Solution: Multilevel MVVM

Image from https://www.bbcgoodfood.com/recipes/collection/layer-cake-recipes

ML-MVVM is not quite a new architectural pattern but something that perfectly fits the way SwiftUI handles the logical state of a scene. SwiftUI is all about Views and reusability in any kind of context. We may have a custom view that might be both a top-level scene and a reusable component, or even a reusable dialogue. In all these cases, we can keep its logic within a ViewModel and we desire a way to keep its consistency in a case where the view is removed and reintroduced in our hierarchy.

What ML-MVVM does is reflecting the UI hierarchy of our views into the logical unit as well. Basically the parent ViewModel also coordinates and listens to events that happen in a lower-level of the scene. As the top-most ViewModel has a reference to the child ViewModels(which shall be accessed by the View to inject them in the child views), the consistency of the subviews states is never broken, because they are kept in memory:

Some people don't like this approach because we kinda remove some of the declarative way of making things happen and introduce some imperative boilerplate. But I strongly believe that separating UI from the scene logic is beneficial, mainly if we are considering complex applications. As long as our subviews layers don't know anything about what's in the top level, we are still keeping all the reusability SwiftUI provides to us.

Reimplementing our scene

Thinking of the image we presented above, let's reshape our SwiftUI scene:

Now that we are saving a scene ViewModel in the top level, it never vanishes from the memory and we introduce consistency to the state:

Now we don't lose any data across view updates, even if our View layer is lost itself. However, as we are injecting a reference of the ViewModel to our subview instead of reinitializing, we still need a way to coordinate changes to our sub scenes.

Coordinating events to lower ViewModels

What about we want to reflect a change from a top-level scene to be propagated to its subviews? We need to send a message to its respective ViewModel, and if that's the case, reflect even lower to the child's children. As our ParentViewModel has access to the ChildViewModel1 and this one has access to the ChildViewModel2 , we need a way to watch each change and directly propagate to the other ViewModels:

Repair that we are taking advantage of Combine for listening to each change in our ViewModel data to be propagated to the children, as it has access to the ChildViewModel . As the Published properties are Combine publishers under the hood, we may create a consistent asynchronous chain of events that reflect in the children in real time.

If we desire a callback from the child to a parent, we can simply inject a closure into the ChildViewModel to be called at any time and trigger events in the parent.

Conclusion

We introduced the idea of Multilevel MVVM in order to keep consistency in all the views of a SwiftUI scene and not losing any data across redraws. As everything in SwiftUI is about reusability, we can perfectly implement a hierarchy of ViewModels that listen to each other and reflect the hierarchy of our views, instead of allowing the views to pass the data themselves. That also increases the testability of our scenes as we can test each callback and interaction between the logic units and don't need to worry about the changes we may apply in our hierarchy tree anymore. This concept may also be applied to the hierarchy of coordinators as well, but it's another topic. I hope you enjoyed this article and understood a bit more about how the lifecycle of a SwiftUI View works ;).

--

--