MINIMAL MVVM IN SWIFTUI
2/20/21
Using SwiftUI effectively is made easier by choosing the right architecture for your app. Ever since Cocoa, Apple had emphasized a Model-View-Controller (MVC) architecture, but SwiftUI works best with a Model-View-ViewModel (MVVM) architecture. When I first heard those terms, the difference wasn’t clear, but I stumbled backwards into the MVVM architecture on one of my recent apps, and now the difference is obvious. This is a short tutorial of a minimum MVVM architecture in SwiftUI. The app won’t win any design awards, and it doesn’t do anything remarkable, but it shows how to think in MVVM terms in the simplest way. More complicated apps can be constructed easily using these principles.
Before getting to the app, an explanation of how MVC and MVVM differ will help. In MVC, the user interacts with the view, which sends messages to the controller about those interactions, and the controller may update the model (the data) behind the app. The controller may also retrieve the model and use it to send updates to the view. The model in some cases may also send updates directly to the view. In Cocoa and CocoaTouch apps, the controller can also instantiate (create) views. In short, the flow of information is complicated, and because of the tight coupling of the view and controller, testing can be difficult.
In MVVM, the flow of information is much simpler. The user interacts with the view and bindings link these changes to the view model, and the view model in turn is linked via bindings to the model. In short, the view does not know about the model, and it is the responsibility of the view model to convert the data in the model to the format that it needs to be displayed. In other words, the view model is as the name says, it is the model of the data that underlies the view. That data may not be the same as the data in the model, and the view model provides the back and forth translation.
Moreover, object ownership is clearer in the MVVM approach, where the view owns the view model, that is the view model is instantiated with the view. Furthermore, the view model owns the model: the model is instantiated when the view model is instantiated. With this architecture, the model becomes a very small object, and is literally just the structures or objects needed to hold the data. As such, it is readily testable. Moreover, because the view model does not create views, it is also more testable.
Our app will do one thing: convert degrees Fahrenheit to degrees Celsius. There will be text field where a user enters the temperature in Fahrenheit, a button that triggers the conversion, and a text label that shows the temperature in Celsius. First, create an iOS app called FtoC, and be sure the interface is set to SwiftUI, the life cycle is a SwiftUI App, and the language is Swift.
Once the project is created, the ContentView struct will be our View in the MVVM notation. It currently just displays a Text with "Hello, World!", but we will replace that with a text field, a button, and a text label.
To do this, we need to create (temporarily, we will see later) two properties corresponding to the strings in the text field and in the text label. Because the text field will modify its contents, it wants a binding to a string, not the string itself. To do that, we add @State when the property is declared, and we precede it with a $ when we use it in the text field. Also, note that the button does nothing so far, and we will fix that later. Check the preview, we see the text field, button, and the text.
Next, we need to create the model. This will be a very simple object that holds the value of the temperature in degrees Fahrenheit, and the value will be stored as a Double to allow it to be converted to Celsius. Create a new iOS Swift file in Xcode, and call it Model. In this file, create a struct called Model, with one property, a Double named fahrenheit. In your app, you would give the file and the struct more meaningful names, but we will use Model here to make the model, view, and view model structure obvious.
We have a view and a model, but we also have some unsolved issues. The first is that the model doesn’t yet exist within our app; nothing instantiates it. The second is that the model treats fahrenheit temperature as a Double, but the view treats it as a string. Some conversion will be needed in both directions, and our view model will do just that./p>
Next, we’ll create the view model, and this will instantiate our model. It will also provide the conversion of fahrenheit from a string to a Double. Finally, it will convert the temperature to Celsius and supply that as a string for our view. In Xcode, create a new iOS Swift file, and call it ViewModel. Change the import statement from Foundation to Swift, and create a new class called ViewModel.
Because our view model will need to supply the strings for fahrenheit and celsius to our view, we need two properties for those. We will also need a function that will update the value of fahrenheit based on what a user enters, and it will calculate the value of celsius from this. This function will be what our button triggers. Our view model should like this:
At this point, we want need to specify how our view model will communicate with our view, and that is through bindings. To do this, first add : ObservableObject after class ViewModel, which will make the class conform to the ObservableObject protocol. Second add @Published before fahrenheitString and celsiusString, which will allow their values to be bound to the view. Bindings mean that any changes in the view will be reflected in the view model, and vice versa. Our view model now looks like this:
A couple of changes have to made to ContentView to communicate with the ViewModel. First, ContentView must be aware of the model, so initialize a property for the ViewModel and preface it with @EnvironmentObject. Because we will be observing the values of fahrenheitString and celsiusString in the view model, we don’t need local values and can delete the two properties we had by these names in ContentView. Second, we want any changes to the fahrenheit value in the textField to be conveyed back to the view model, so we use a binding in the text field to $viewModel.fahrenheitString. Since we are only observing the value of celsiusString in the view model, we don’t need to bind to it. Finally, give the button an action by calling the updateModel() method of the view model.
Just two more steps to go. At this point, viewModel is never instantiated in ContentView. To do that, go to the FtoCapp.swift file, and add .environmentObject(viewModel()) after the instantiation of ContentView. This injects the view model into ContentView.
Do the same for ContentView_Previews so that you can see a preview.
Build and run the model. Enter a temperature, tap Convert to Celsius, and you should see the temperature updated.
Notice what is happening here. The view is updating whenever its bound values (fahrenheitString, celsiusString) change. It has no idea how the conversion is done, or even that these are numbers. All it knows is that these values are bound to an object that is a string. Likewise, it has no idea what the Convert to Celsius button actually does; it just knows that it is telling viewModel to update its model (whatever that may be). The view is completely agnostic about the model, even the details of what the view model is doing. Likewise, the model has no idea that the view or the view model exists. It is perfectly isolated. ContentView is likewise isolated from the View, and it has no idea it exists.
Ownership of objects is clear and simple. The app provides the view (ContentView) with the view model (ViewModel) as an environment object, so ContentView knows that the text field is bound to fahrenheitString, that it can obtain celsiusString, and that it can tell the view model to update its model. But that is the end of the road for view, it has no idea what the actual model looks like or how it stores its data.
Likewise, the view model is aware of the model and it instantiates its own copy of it. It is aware that fahrenheit is stored in the model as a Double. The view model’s only jobs are to update the value of fahrenheit in the model and its own value of celsiusString, if it receives a valid number through the string binding. The view model, however, is not aware of the view or what it is doing with any of these values.
This separation makes testing much easier in MVVM than in MVC. In MVC, model objects are generally easy to test. However, testing view controllers is notoriously difficult and often requires the use of mock objects because of the tight coupling between view controllers, views, and other objects. In MVVM, isolation makes the view model just as easily testable as the model.