5.
Architecture: MVVM
Written by René Cacheaux & Josh Berlin
Model-View-ViewModel (MVVM) is the new trend in the iOS community, but its roots date back to the early 2000s at Microsoft. Yes, you read that correctly! Microsoft. Microsoft architects introduced MVVM to simplify design and development using Extensible Application Markup Language (XAML) platforms, such as Silverlight.
Prior to MVVM, designers would drag and drop user interface components to create views, and developers would write code for each view specifically. This resulted in the tight coupling between views and business logic — changing one typically required changing the other. Designers lost freedom due to this workflow: They became hesitant to change view layouts because, doing so, often required massive code rewrites.
Microsoft specifically introduced MVVM to decouple views and business logic. This alleviated pain points for designers: They could now change the user interface, and developers wouldn’t have to change too much code.
Fast forward to iOS today, and you’ll find that iOS designers usually don’t modify Xcode storyboards or auto layout constraints directly. Rather, they create designs using graphical editors such as Adobe Photoshop. They hand these designs to developers, who, in turn, create both the views and code. Thereby, the goals of MVVM are different for iOS.
MVVM isn’t intended to allow designers to create views via Xcode directly. Rather, iOS developers use MVVM to decouple views from models. But the benefits are the same: iOS designers can freely change the user interface, and iOS developers won’t need to change much business logic code.
What is it?
MVVM is a “reactive” architecture. The view reacts to changes on the view model, and the view model updates its state based on data from the model.
MVVM involves three layers:
-
The model layer contains data access objects and validation logic. It knows how to read and write data, and it notifies the view model when data changes.
-
The view model layer contains the state of the view and has methods to handle user interaction. It calls methods on the model layer to read and write data, and it notifies the view when the model’s data changes.
-
The view layer styles and displays on-screen elements. It doesn’t contain business or validation logic. Instead, it binds its visual elements to properties on the view model. It also receives user inputs and interaction, and it calls methods on the view model in response.
As a result, the view layer and model layer are completely decoupled. The view layer and model layer only communicate with the view model layer.
Next, you’ll go into the each of these layers in depth.
Model layer
The model layer is responsible for all create, read, update and delete (CRUD) operations.
You can design the model layer in many different ways; yet, two of the most common are push-and-pull and observe-and-push designs:
-
Push-and-pull designs require consumers to ask for data and wait for the response, which is the “pull” part. Consumers can also update model data and tell the model layer to send it, which is the “push” part.
-
Observe-and-push designs require consumers to “observe” the model layer, instead of asking for data directly. Like push-and-pull designs, consumers can also update model data and tell the model layer to “push” it.
Koober uses a push-and-pull design. Specifically, it uses an implementation of the repository pattern. You’ll go into more detail about this, next.
Repository pattern
Repositories contain data access objects that can call out to a server or read from disk.
The repository pattern provides a façade for networking, persistence and in-memory caching. This façade creates, reads, updates and deletes data on disk and in the cloud. The repository doesn’t expose to consumers how it retrieves or stores the data.
When combined with MVVM, view models use the repository façade, instead of performing these operations themselves. In turn, view models transform and expose model data to views to display on-screen.
Repository structure
The repository provides a set of asynchronous CRUD methods. The underlying implementations can be either stateless or stateful. Stateless implementations don’t keep data around after retrieving it, whereas stateful implementations save data for later. The components are usually stateful and keep data in-memory for quick access.
Under the hood, the repository has multiple layers of data access. Each implementation of a repository may implement all or only one of these layers:
-
The cloud-remote-API layer makes calls to a server to read and update data. This may make REST calls, get data from a socket connection or another means. The data at this layer always comes from outside of the app.
-
The persistent-store layer puts data in a local database. The database can be Core Data, Realm or a Plist file on disk. The data at this layer always comes from the app. The data gets persisted after the app closes.
-
The in-memory-cache layer stores data in objects that stay around for the lifetime of the repository. The cache doesn’t persist between app sessions. The in-memory cache is useful for showing pre-fetched data before making a network call to the cloud.
Example: KooberUserSessionRepository
In Koober, signing up or signing in creates a new session. The session contains the current user’s authentication token and metadata, such as name and avatar.
The KooberUserSessionRepository handles all user-related activity in the Koober app, including signing up, signing in, signing out and getting the current user.
When a new user signs up, the KooberUserSessionRepository calls out to the Koober Cloud REST API, creates a user session from the response, and finally saves the user session to a persistent store.
This all happens under the covers. KooberUserSessionRepository exposes none of the internal implementation to its consumers. In particular, KooberUserSessionRepository API exposes nothing about where the data comes from. The underlying implementation could change to call out to a different REST API and store the data in-memory only. If it did, the API would still stay the same and consumers wouldn’t be impacted.
The sign-in API takes in an email and password, and asynchronously returns a user session object. The API’s caller only cares that they get the most up-to-date user session. They don’t care whether or not the user session comes from an in-memory store, a cloud API or a persistent store.
Repositories allow flexibility in your implementations, while keeping the user interface layer stable. Repository implementations can change due to new project requirements. The callers of the user repository methods never change, regardless of the implementation. If your company asks you to switch from REST to Protocol Buffers, do you panic or do you keep calm and refactor? The flexibility makes your app more stable, less prone to bugs and requires less refactoring when implementations do inevitably change.
View layer
A view is a user interface for a screen. In MVVM, the view layer reacts to state changes through bindings to view model properties. It also notifies the view model of user interaction, like button taps or text input updates.
The purpose of the view is to render the screen. It knows how to layout and style the user interface elements, but doesn’t know anything about business logic.
In MVVM, you use one-way data binding to bind the UI elements from the view to the view model. This means the view model is the single source of truth. The view doesn’t update until the view model changes its state.
The view layer contains a hierarchy of views. Each parent view knows about its children and has access to their properties.
View model layer
The view model is the life of the party in this chapter. It contains a view’s state, methods for handling user interaction and bindings to different user interface elements.
The view model knows how to handle user interactions, like button taps. User interactions map to methods in the view model. The methods do some work, like making an API call, and then change the state of the view model. The state update causes the view to react.
The purpose of the view model is to decouple the view controller from the view. Ever heard of the “massive view controller problem”? Does your view controller file seem to scroll forever? View models are here to help. They are completely separate from the view controller, and they know nothing about its implementation.
You can replace your entire view with a different layout without changing the view model. View models give MVVM big wins in testability, since you can test them without a user interface.
Kickstarter wrote its iOS app using view models. It has over 1,000 view model tests. On its blog, Kickstarter writes, “We write these as a pure mapping of input signals to output signals, and test them heavily, including tests for localization, accessibility, and event tracking.” This idea of “pure mapping” is at the core of MVVM. View models take input signals and produce output signals, providing a clear boundary between view models and views.
Next, you’ll learn about the structure of a view model in more depth.
-
View State is stored in the view model. The state is made up of
@Publishedproperties. UsingCombine, the user interface subscribes to the publishers when the view model is created. -
Task Methods perform tasks in response to user interactions. The methods do some work, such as calling a sign-in API, and then updating the view model’s state. The view knows if the state changes because the publishers signal new data. You usually mark task methods as
@objcmethods, because you have to target-action pair on a UI Control. -
Dependencies are passed to the view model through initializer injection. Task methods rely on the dependencies to communicate with other subsystems in the app, such as a REST API or persistent store. View models know how to use the dependencies, but have no knowledge of the underlying implementations.
View models sometimes use other view models to change state across the app. In this case, other view models are injected using initializer injection. You’ll cover how to signal out to other view models in the code example sections.
Example: Koober sign-in view model
The sign-in view model contains business logic for signing in to Koober and publishers to update state.
The view model depends on a UserSessionRespository and a SignedInResponder, which the initializing object injects through the initializer.
-
The
UserSessionRespositorycalls the Koober sign-in API to authenticate with an email and password. -
The
SignedInResponderhandles a successful sign in. It signals out to switch app state from onboarding to signed in.
The view model contains two values:
- The
emailandpasswordvariables update each time you enter new text in the text fields. These variables are bound to the text fields in the sign-in view.
The view model also contains publishers:
-
The
emailInputEnabledandpasswordInputEnabledpublishers bind to the text fields in the view. They update each time you enter new text in the text fields. -
The
errorMessagePublisherpublisher sendsErrorMessageobjects. The view presents the error each time the publisher sends a new one.
The only task method in the sign-in view model is signIn(). The method asks the UserSessionRespository to sign in using values in email and password. If sign in succeeds, the view model gives the SignedInResponder the new user session. If the sign in fails, the view model adds the error to errorMessages and the view displays the error.
Creating the view
The view knows how to style and layout its subviews, as well as hook up user interface elements to the view model publishers. In Koober, view controllers create the view and the view model inside loadView(). The view controller creates the view model first and passes it to the view. Since Koober creates view layouts in code, views can have a custom initializer.
If you use Interface Builder to create view controllers and views, view controllers would contain implicitly unwrapped view model variables. Dependency containers would inject view models into view controllers using property injection instead of initializer injection. View controllers would pass the view model to the View inside viewDidLoad() or awakeFromNib().
Container views
Each screen in Koober has a container view — a top-level view that contains other child views. The container view’s purpose is to build a complex screen out of modular views. Instead of throwing all the user interface into one massive view, keep your views small, focused and reusable.
The “view” in container view refers to a UIViewController and its UIView.
Structuring container views
A dependency container initializes a container view with its child views. A container view adds and displays child views in its view hierarchy. Child views limit the responsibility of the top-level container view. The number of child views needed depends on the screen’s complexity. Each child view is reusable and performs all its work independently.
Why not throw everything in one massive container view? What if you don’t need to reuse the view anywhere else in the app? It doesn’t matter if the view is reusable. What’s important is moving the coordination of view code out of the view model. This lets you change the structure of the app without having to change code inside every view model.
A view model shouldn’t know how things work at a higher level. It shouldn’t make assumptions or tightly couple itself to coordination code. This makes view coordination code easier to change and allows developers to work together without stepping on each other’s toes.
This allows one developer to work on a single screen while another developer works on coordinator screens. They can even make changes in parallel, which is pretty cool!
Example: Koober ride request
The pick-me-up screen contains the meat of the Koober app — the map, the available ride options, and pick-up and drop-off location selections. This is a ton of functionality. If we wrote all this functionality in one view controller, the file would be massive.
Container views help us organize all the functionality and design each piece independently.
The map and ride-option picker are child views that can live on their own. In Koober, they only live in the pick-me-up screen, but they get the advantage modular view design. One developer could build out the entire map screen while another builds the ride-option picker. Then, the pick-me-up screen adds them to the view hierarchy in the proper place.
Communicating amongst view models
Sometimes, view models need to signal out to the rest of the app when state changes. If a task is outside of the responsibility of one view model, the application may need to notify another view model to take over.
When view models signal out, they communicate what occurred rather than what to do. The application decides what to do. This provides flexibility — you can change how the app responds without changing the view model.
Collaborating view models
Normally state changes in a view model update a view. Sometimes, those state changes affect the entire app. View models don’t know how to post app-wide notifications; they take inputs and produce outputs. One way for view models to communicate with the app is to call into another view model, forming a graph of view models.
View models have three ways to collaborate with each other:
-
Closures: For signaling out, view models take a closure as an initializer argument. The view model calls the closure when an event occurs.
-
Protocols: Each output signal is modeled with a single method protocol. Other view models that want to respond to outgoing signals must conform to the protocol. You should use a single protocol per signal; otherwise, you force the conformer to place all response logic into a single object.
-
Publishers: For outgoing signals, a view model exposes a publisher that other view models can modify by updating the publisher’s value.
Navigating
For the onboarding flow in Koober, tapping the Sign In button pushes the sign-in screen onto the navigation stack.
In an architecture like Model-View-Controller (MVC), navigating this flow is straightforward: The Sign In button tap fires a method in the welcome view controller, which creates the sign-in view controller and pushes it onto the navigation stack.
In MVVM, the same flow is more complicated. Remember, the view calls task methods on the view model to get work done — even navigation. The Sign In button tap tells the view model what happened. Next, the view model circles back and tells the view to navigate to the sign-in screen. This indirection is weird but, in pure MVVM, view models handle user interaction.
In this section, you’ll look at how to drive navigation between screens, manage view state during navigation and manage scopes when navigating.
Model-driven navigation
In model-driven navigation, view models contain a view enum describing all possible navigation states. The system observes this and navigates to the next screen when the value changes.
Container views and container view models handle navigation for their children. Child view models signal out to the container view model that handles navigation at the top level.
Child views can signal out in two ways:
-
Collaborating view models signal to each other when a view enum value changes. Child view models get injected with a higher level view model and call task methods when navigation should occur.
-
Shared publisher view state holds a mutable
Publisherproperty with the current view enum value. A dependency container injects child view models with thePublisher. Any child view model can push a new view enum value. The container view model observes the value and navigates to the next screen when it changes.
System-driven navigation
System-driven navigation is any navigation managed by the system. For example, gestures that trigger scroll view page navigation, or tapping a Back button in a navigation stack, automatically navigate the user to the previous screen.
In pure MVVM, you override all these gestures, and the view model handles the user interaction. For most apps, this is overkill. A better option is to work with the system and leverage what’s already designed for you by Apple. As soon as an MVVM implementation causes friction with the built-in system paradigms, consider using an MVVM implementation that combines model-driven navigation and system-driven navigation.
Combination
You can use built-in, system-driven navigation to your advantage, while still implementing an MVVM architecture. For example, you can use model-driven navigation to move a navigation stack forwards and use system-driven navigation to move backwards.
Koober’s onboarding flow uses a combination of model-driven and system-driven navigation.
The onboarding view model switches between three states: welcome, sign in, and sign up. Tapping the Sign In button on the welcome screen changes the view model state to “sign in.” The onboarding view reacts to the change, and it pushes the sign-in screen onto the navigation stack. Tapping the Back button on the sign-in screen’s navigation bar uses system-driven navigation to pop the screen from the stack.
Managing state
Some navigation schemes create new views when navigating, and other schemes hold onto views and reuse them.
Creating new views on navigation
Creating a new view each time a view is presented is easier to manage. The view and view model aren’t held in memory when the view is offscreen.
The system deallocates views each time the application dismisses them. When the application creates them again, the view model populates the view with the correct initial state.
With this option, you don’t need to worry about state changes when the view is offscreen.
In Koober, you get the user’s current location before showing the map screen.
That operation might take a couple seconds, so you show the finding-your-location screen while it’s in progress.
The main-view-custom-container handles the transition between the finding-your-location screen and the map screen. It creates the finding-your-location and the map screens right before any transitions. After presenting the map, it destroys and deallocates the finding-your-location screen.
Reusing views on navigation
Reusing views makes sense when they need to preserve their state. System containers, like tab bars and navigation controllers, reuse views on navigation.
Tab bars hold onto a list of view controllers that live in memory. Navigation controllers reuse views when moving backwards in the stack.
Applying theory to iOS apps
Congratulations for making it to the code examples - the kangaroos are proud! You’ve just learned a ton of theory about MVVM.
Now that you know the core MVVM concepts, you’ll have much more fun in the code example section.
The code example section covers three important real-world use cases for MVVM:
-
In Building a view, you’ll learn how to create the Koober sign-in screen’s model layer, view model layer, and view layer.
-
In Composing views, you’ll learn how to request a Koober ride on the map screen. You’ll learn how to build the ride option selector, the map screen, and how they communicate with each other using view models.
-
In Navigating, you’ll learn how to drive navigation with view models, and how the map screen navigates between views. Also, you’ll learn how the user’s profile info is modally presented from the map screen.
Time to dive into the code!
Building a view
The sign-in screen allows you to authenticate with Koober. The initial state shows placeholders for empty email and password fields. The Sign In button is always active, even when the text fields are empty. Tapping the button validates the email and password, and shows an error if either field is empty or if the API call returns an error.
While the sign-in API request is in progress, the screen displays a spinner and disables the user interface.
Note: Most of the code snippets are subsets of the full files. Feel free to open 05-architecture-mvvm/final/KooberApp/KooberApp.xcodeproj while reading if you’d like to follow along and check out the full source.
Model layer
The sign-in model layer does most of the authentication work. It authenticates with the Koober server and persists the user session. The repositories are in KooberKit/DataLayer/Repositories and the models are in KooberKit/DataLayer/Model.
The sign-in model layer uses the repository pattern for accessing data — specifically, the UserSessionRepository protocol:
public protocol UserSessionRepository {
func readUserSession() -> Promise<UserSession?>
func signUp(newAccount: NewAccount) -> Promise<UserSession>
func signIn(
email: String, password: String) -> Promise<UserSession>
func signOut(
userSession: UserSession) -> Promise<UserSession>
}
UserSessionRepository has methods for reading the user session and authenticating a user. For the sign-in screen, you’ll call signIn(email: password:).
All the Repository methods return a promise with a UserSession object. You use PromiseKit, a third-party framework, to create promises. A promise allows the caller to return from the method immediately and expect either a success or failure. You’ll learn more about how to use promises in the View model layer section below.
public class UserSession: Codable {
public let profile: UserProfile
public let remoteSession: RemoteUserSession
}
public struct UserProfile: Codable {
public let name: String
public let email: String
public let mobileNumber: String
public let avatar: URL
}
public struct RemoteUserSession: Codable {
let token: AuthToken
}
UserSession is a simple class that contains a profile and a user session. UserProfile contains metadata about the user. RemoteUserSession contains an AuthToken, a typealisased String.
That’s it for the model layer! The repository pattern is awesome because the actual underlying implementation of the UserSessionRepository doesn’t matter to the callers of the protocol methods.
You can see the implementation in KooberKit/DataLayer/Repositories/KooberUserSessionRepository.swift. The repository calls out to a remote API, and stores the data in a data store. You could swap that out with a fake remote API and in-memory store, and the UserSessionRepository protocol wouldn’t change.
View model layer
SignInViewModel is where all the reactive magic happens in the sign-in screen. It holds all the view’s state, and it signs the user in.
You can find the view model in KooberKit/UILayer/Onboard/SignIn/SignInViewModel.swift.
public class SignInViewModel {
// MARK: - Properties
let userSessionRepository: UserSessionRepository
let signedInResponder: SignedInResponder
// MARK: - Methods
public init(userSessionRepository: UserSessionRepository,
signedInResponder: SignedInResponder) {
self.userSessionRepository = userSessionRepository
self.signedInResponder = signedInResponder
}
public var email = ""
public var password: Secret = ""
// Publishers go here
// Task Methods go here
}
And you can find the sign in responder protocol in KooberKit/UILayer/SignedInResponder.swift.
protocol SignedInResponder {
func signedIn(to userSession: UserSession)
}
First, take a look at the dependencies:
-
UserSessionRepositoryauthenticates the user as you saw above in the Model layer section. -
SignedInResponderhandles a successful sign-in by switching the app state from onboarding to signed in. This causes the app to dismiss the onboarding flow and show the map screen. The sign-in view model doesn’t care how the switch happens — it just tells the responder what happened.
The view model also contains email and password variables. These variables are bound to the email and password input fields in the view layer. You’ll see how that’s done below in View layer.
Next, take a look at the publishers:
// SignInViewModel’s Publishers
public var errorMessagePublisher:
AnyPublisher<ErrorMessage, Never> {
errorMessagesSubject.eraseToAnyPublisher()
}
private let errorMessagesSubject =
PassthroughSubject<ErrorMessage, Never>()
@Published public private(set)
var emailInputEnabled = true
@Published public private(set)
var passwordInputEnabled = true
@Published public private(set)
var signInButtonEnabled = true
@Published public private(set)
var signInActivityIndicatorAnimating = false
SignInViewModel contains the entire state of the sign-in view. The view binds its user interface elements to the publishers, and the view model updates them internally.
The $emailInputEnabled and $passwordInputEnabled publishers are bound to the view’s email and password input fields. To sign in, they both must contain non-empty values.
This diagram shows the sign-in flow:
The view model tells the user-session repository to sign in using the email and password values. On success, the view model notifies the signed in responder of the new user session object. On error, the view model updates the errorMessagesSubject PassthroughSubject publisher with the error message.
Next, look at how the sign-in view model implements task methods:
@objc
public func signIn() {
indicateSigningIn()
userSessionRepository.signIn(
email: email,
password: password)
.done(signedInResponder.signedIn(to:))
.catch(indicateErrorSigningIn)
}
The sign-in view calls the signIn() task method when the user taps the Sign In button. The method is marked @objc since the view adds a target / action pair to this selector for its Sign In button.
First, the method calls indicateSigningIn().
func indicateSigningIn() {
emailInputEnabled = false
passwordInputEnabled = false
signInButtonEnabled = false
signInActivityIndicatorAnimating = true
}
The indicateSigningIn method disables all the user interface controls by setting their values to false, and shows the spinner by updating signInActivityIndicatorAnimating to true.
The view model doesn’t care how the sign-in view reacts to these state changes. The view can contain a UIActivityIndicatorView or custom spinner.
This is cool because the sign-in business logic is completely separate from the user interface implementation.
Next, the method asks the UserSessionRepository to sign the user in using the email and password values. signIn() immediately method returns a promise that will eventually resolve with a valid UserSession or fail with an Error.
In the success case, signedInResponder updates the app with the new UserSession.
In the error case, indicateErrorSigningIn() updates the view model state using the generated Error.
The error will appear with this Sign In Failed dialog:
func indicateErrorSigningIn(_ error: Error) {
errorMessagesSubject.send(
ErrorMessage(
title: "Sign In Failed",
message: "Could not sign in.\nPlease try again."))
emailInputEnabled = true
passwordInputEnabled = true
signInButtonEnabled = true
signInActivityIndicatorAnimating = false
}
This method re-enables all the user interface controls and hides the spinner.
The method also publishes a sign-in error through the errorMessagesSubject.
That’s the entire view model!
One more thing: You’ll notice, when looking at the view model files in the sample project, that none of them import UIKit and are completely independent of UIKit. This ensures you can test the view model logic without access to any UIKit elements.
View layer
We created all Koober root views in code instead of using storyboards. The kangaroos made us do it! No, really, there’s a valid reason for this. Root views get a view model injected on initialization. Using storyboards, this would be impossible. Also, in-code constraint creation is a lot easier these days. But that’s a debate for another day.
In this section, you’ll learn how to create the SignInRootView in the SignInViewController.
SignInRootView is a UIView subclass that contains all the sign-in UI: email and password text fields, the Sign In button and an activity-indicator spinner.
You can find the View files in Koober_iOS/iOSApp/Onboarding/SignIn.
public class SignInViewController : NiblessViewController {
// MARK: - Properties
let viewModelFactory: SignInViewModelFactory
let viewModel: SignInViewModel
private var subscriptions = Set<AnyCancellable>()
// MARK: - Methods
init(viewModelFactory: SignInViewModelFactory) {
self.viewModelFactory = viewModelFactory
self.viewModel = viewModelFactory.makeSignInViewModel()
super.init()
}
public override func loadView() {
self.view = SignInRootView(viewModel: viewModel)
}
}
SignInViewController initializes its SignInRootView with a SignInViewModel in loadView(). The root view knows how to bind its UI elements to the view model’s publishers.
protocol SignInViewModelFactory {
func makeSignInViewModel() -> SignInViewModel
}
SignInViewModelFactory protocol has a single responsibility: create a sign-in view model. The makeSignInViewModel method returns a ready-to-use SignInViewModel.
View controllers don’t know how to create view models. View models have dependencies that are outside of the view controller’s scope. So you inject factories into view controllers that know how to create view models.
Note: Creating view model factories is out of the scope for this chapter. But, if you’d like to explore the code, the
SignInViewModelFactoryimplementation is in Koober_iOS/iOSApp/Onboarding/KooberOnboardingDependencyContainer.swift.
That’s it for the view controller’s responsibility in MVVM. The root view handles the user interface updates, and the view controller manages the view’s lifecycle.
Next, look at the sign-in root view:
class SignInRootView: NiblessView {
// MARK: - Properties
let viewModel: SignInViewModel
//...
// MARK: - Methods
init(frame: CGRect = .zero,
viewModel: SignInViewModel) {
self.viewModel = viewModel
super.init(frame: frame)
bindTextFieldsToViewModel()
bindViewModelToViews()
}
//...
}
SignInRootView has a custom initializer that takes a frame and a view model. It uses the injected SignInViewModel to bind its UI elements to publishers in initialization.
// SignInRootView
// ...
func bindTextFieldsToViewModel() {
bindEmailField()
bindPasswordField()
}
func bindEmailField() {
emailField
.publisher(for: \.text)
.map { $0 ?? "" }
.assign(to: \.email, on: viewModel)
.store(in: &subscriptions)
}
func bindPasswordField() {
passwordField
.publisher(for: \.text)
.map { $0 ?? "" }
.assign(to: \.password, on: viewModel)
.store(in: &subscriptions)
}
// ...
The bindEmailField() method binds the emailField text variable to the view model’s email value. Anytime the user enters text in the email text field, the email value changes. The passwordField behaves the same way.
The bind methods use the text field’s text variable to drive the view model’s corresponding values. Binding the text field to the view model’s value means the view model always contains a valid text value. The map function returns an empty string if the text is nil to ensure the value is always valid.
Next, let’s look at how the view binds the view model’s publishers to its views.
// SignInRootView
// ...
// MARK: - Dynamic behavior
extension SignInRootView {
func bindViewModelToViews() {
bindViewModelToEmailField()
bindViewModelToPasswordField()
bindViewModelToSignInButton()
bindViewModelToSignInActivityIndicator()
}
func bindViewModelToEmailField() {
viewModel
.$emailInputEnabled
.receive(on: DispatchQueue.main)
.assign(to: \.isEnabled, on: emailField)
.store(in: &subscriptions)
}
// ...
The binding is pretty simple. The view binds the view model’s $emailInputEnabled publisher to the isEnabled flag on the view’s emailField. When emailInputEnabled changes, emailField enables or disables.
SignInRootView has one more thing remaining to complete its set up: Bind the Sign In button action to the view model’s sign-in task method:
// SignInRootView
// ...
func wireController() {
signInButton.addTarget(
viewModel,
action: #selector(SignInViewModel.signIn),
for: .touchUpInside)
}
// ...
The wireController() method gets called in the view’s didMoveToWindow() lifecycle method. The view knows which task method in the view model to wire the signInButton touch event. That’s the entire view.
The view controller and root view work together to make up the “View” in MVVM. The view controller configures the view with its dependencies. The view lays out and styles the user interface, and it knows how to bind the user inputs to the right publishers.
Composing views
The pick-me-up screen is the heart of the Koober app. This is where the ’roos hop around and fulfill their ride-sharing destinies.
Here, you select where you want a Koober to take you and select your Koober ride option. The map displays pins for your pick-up and drop-off locations, and the bottom container shows the ride-option picker.
In this example, you’ll look at the flow of selecting a ride option after you select a drop-off location.
You can find the pick-me-up view models in KooberKit/iOSApp/UILayer/SignedIn/PickMeUp and the UI in Koober_iOS/iOSApp/SignedIn/PickMeUp.
Pick-me-up container view
The PickMeUpViewController is a container with three children: PickMeUpMapViewController, RideOptionPickerViewController and SendingRideRequestViewController:
public class PickMeUpViewController: NiblessViewController {
// MARK: - Properties
// View Model
let viewModel: PickMeUpViewModel
// Child View Controllers
let mapViewController: PickMeUpMapViewController
let rideOptionPickerViewController:
RideOptionPickerViewController
let sendingRideRequestViewController:
SendingRideRequestViewController
// ...
// MARK: - Methods
init(viewModel:
PickMeUpViewModel,
mapViewController:
PickMeUpMapViewController,
rideOptionPickerViewController:
RideOptionPickerViewController,
sendingRideRequestViewController:
SendingRideRequestViewController,
viewControllerFactory:
PickMeUpViewControllerFactory) {
self.viewModel =
viewModel
self.mapViewController =
mapViewController
self.rideOptionPickerViewController =
rideOptionPickerViewController
self.sendingRideRequestViewController =
sendingRideRequestViewController
self.viewControllerFactory =
viewControllerFactory
super.init()
}
public override func loadView() {
view = PickMeUpRootView(viewModel: viewModel)
}
// ...
}
PickMeUpViewController gets its child view controllers and PickMeUpViewModel on initialization through initializer injection. It adds the children to the view hierarchy, but it knows nothing about their implementations. It’s only responsible for laying them out.
The children notify PickMeUpViewModel when the user interacts with their views. They don’t communicate directly with the parent container view.
Next, look at how RideOptionPickerViewController loads ride options based on the user’s pick-up location.
Ride-option picker view controller
RideOptionPickerViewController shows available ride options at the user’s pick-up location and a Confirm button.
public class RideOptionPickerViewController:
NiblessViewController {
// ...
// MARK: - Methods
init(pickupLocation: Location,
imageCache: ImageCache,
viewModelFactory: RideOptionPickerViewModelFactory) {
self.pickupLocation = pickupLocation
self.imageCache = imageCache
self.viewModel =
viewModelFactory.makeRideOptionPickerViewModel()
super.init()
}
// ...
}
RideOptionPickerViewController has three dependencies:
-
The pick-up
Locationto load the ride options at a specific coordinate. -
An
ImageCacheto get the Koober ride option icons. -
A
RideOptionPickerViewModelFactoryto create theRideOptionPickerViewModel.
RideOptionPickerViewController uses the dependencies from above to load the ride options:
public class RideOptionPickerViewController:
NiblessViewController {
// ...
public override func viewDidLoad() {
super.viewDidLoad()
rideOptionSegmentedControl
.loadRideOptions(availableAt: pickupLocation)
observeErrorMessages()
}
// ...
}
On loadView(), the custom segmented control gets set as the screen’s root view. On viewDidLoad(), the custom segmented control loads the available ride options from the network based on the user’s pick-up location.
That’s it for the view controller. It creates its root view and loads the ride options.
All the user interaction happens in the root view, which communicates with the view model.
Ride-option picker segmented control
RideOptionSegmentedControl displays a button for each Koober ride option:
class RideOptionSegmentedControl: UIControl {
// MARK: - Properties
let mvvmViewModel: RideOptionPickerViewModel
// ...
private func makeRideOptionButton(
forSegment segment:
RideOptionSegmentViewModel) ->
(RideOptionID, RideOptionButton) {
let button = RideOptionButton(segment: segment)
button.didSelectRideOption = { [weak self] id in
self?.mvvmViewModel.select(rideOptionID: id)
}
return (segment.id, button)
}
// ...
}
The segmented control configures each ride-option button ride to notify the RideOptionPickerViewModel when the ride option selection changes. Each button’s didSelectRideOption closure fires on tap, and calls the func select(rideOptionID: RideOptionID) with the new id.
Since the view model gets injected into the view, it has no clue how the underlying method implementation works. The segmented control only knows how to make calls to the view model’s select ride-option task method.
Ride-option picker view model
Next, you’ll go into the view model in more depth.
public class RideOptionPickerViewModel {
// MARK: - Properties
let repository: RideOptionRepository
@Published public private(set) var pickerSegments =
RideOptionSegmentedControlViewModel()
let rideOptionDeterminedResponder:
RideOptionDeterminedResponder
public var errorMessages:
AnyPublisher<ErrorMessage, Never> {
errorMessagesSubject.eraseToAnyPublisher()
}
private let errorMessagesSubject =
PassthroughSubject<ErrorMessage, Never>()
// MARK: - Methods
public init(repository: RideOptionRepository,
rideOptionDeterminedResponder:
RideOptionDeterminedResponder) {
self.repository = repository
self.rideOptionDeterminedResponder =
rideOptionDeterminedResponder
}
public func loadRideOptions(
availableAt pickupLocation: Location,
screenScale: CGFloat) {
// Call loadRideOptions on repository here
// and show ride options
// ...
}
public func select(rideOptionID: RideOptionID) {
var segments = pickerSegments.segments
for (index, segment) in segments.enumerated() {
segments[index].isSelected =
(segment.id == rideOptionID)
}
pickerSegments = RideOptionSegmentedControlViewModel(
segments: segments
)
rideOptionDeterminedResponder.pickUpUser(in: rideOptionID)
}
}
RideOptionPickerViewModel has two dependencies: RideOptionRepository and RideOptionDeterminedResponder.
-
RideOptionRepositoryloads the ride options from the server. -
RideOptionDeterminedResponderupdates the pick-me-up view state with the new ride-option selections.
When a ride-option button calls select(rideOption: RideOptionID), the method updates the isSelected state of the ride-option buttons and publishes the new segments. Then, it tells the responder that you selected a new ride option:
protocol RideOptionDeterminedResponder {
func pickUpUser(in rideOptionID: RideOptionID)
}
The RideOptionDeterminedResponder is a protocol with a single method to handle selection. Under the hood, the RideOptionDeterminedResponder is the PickMeUpViewModel that implements the protocol. Using a protocol to signal out of means that you don’t have to pass entire view models to each other and expose extra functionality.
Next, circle back to the PickMeUpViewModel and see how you use the responder to update the pick-me-up screen.
Pick-me-up view model
PickMeUpViewModel describes the state of the pick-me-up screen using enums:
public enum PickMeUpView {
case initial
case selectDropoffLocation
case selectRideOption
case confirmRequest
case sendingRideRequest
case final
}
PickMeUpView captures every possible state of the pick-me-up screen.
-
initialdisplays the map with an initial hardcoded pick-up location. Koober currently supports one pick-up spot. The select ride-option picker is hidden in this state. -
selectDropoffLocationdisplays the select drop-off-location picker with a list of predefined drop-off locations. -
selectRideOptiondisplays the select ride-option picker. The Confirm Ride button is initially hidden until you select a ride option. -
confirmRequestdisplays the select ride-option picker with one option highlighted, as well as the Confirm button. -
sendingRideRequestdisplays the Requesting Ride screen. -
finaldismisses the Requesting Ride screen.
enum PickMeUpRequestProgress {
case initial(pickupLocation: Location)
case waypointsDetermined(waypoints: NewRideWaypoints)
case rideRequestReady(rideRequest: NewRideRequest)
}
public struct NewRideRequest: Codable {
public let waypoints: NewRideWaypoints
public let rideOptionID: RideOptionID
}
public struct NewRideWaypoints: Codable {
let pickupLocation: Location
let dropoffLocation: Location
}
PickMeUpRequestProgress determines the user’s pre-ride request state.
-
initial(pickupLocation: Location)configures the initial state with a pick-up location. -
waypointsDetermined(waypoints: NewRideWaypoints)stores the pick-up and drop-off location once a user selects a drop-off location from the list. -
rideRequestReady(rideRequest: NewRideRequest)stores the ride-option selection along with the waypoints in aNewRideRequestobject.
For ride-option selection, take an in-depth look at the transition from selectRideOption state to confirmRequest state.
PickMeUpViewModel makes the state transition as soon as the user selects one of the ride options.
public class PickMeUpViewModel:
DropoffLocationDeterminedResponder,
RideOptionDeterminedResponder,
CancelDropoffLocationSelectionResponder {
// MARK: - Properties
var progress: PickMeUpRequestProgress
let newRideRepository: NewRideRepository
let newRideRequestAcceptedResponder:
NewRideRequestAcceptedResponder
let mapViewModel: PickMeUpMapViewModel
@Published public private(set) var view: PickMeUpView
@Published public private(set) var shouldDisplayWhereTo = true
// ...
func pickUpUser(in rideOptionID: RideOptionID) {
if case let .waypointsDetermined(waypoints) = progress {
// 1
let rideRequest = NewRideRequest(
waypoints: waypoints,
rideOptionID: rideOptionID)
// 2
progress = .rideRequestReady(rideRequest: rideRequest)
// 3
view = .confirmRequest
} else if case
let .rideRequestReady(oldRideRequest) = progress {
let rideRequest = NewRideRequest(
waypoints: oldRideRequest.waypoints,
rideOptionID: rideOptionID)
progress = .rideRequestReady(rideRequest: rideRequest)
view = .confirmRequest
} else {
fatalError()
}
}
// ...
}
The pick-me-up view model contains a PickMeUpView publisher, a shouldDisplayWhereTo publisher, and a PickMeUpRequestProgress variable.
The pickUpUser(in: RideOptionID) handles ride-option selection in three steps:
- Creates a
NewRideRequestwith the selected ride option and waypoints - Updates the internal progress state with the new data.
- Updates the
PickMeUpViewvariable to the.confirmRequeststate which publishes the new value.
The View reacts to the state change, and it displays the Confirm button.
Here’s a diagram showing the entire confirm request flow:
Let’s go through the steps one by one:
-
PickMeUpViewControllerinjects itsPickMeUpViewModelintoRideOptionPickerViewController.RideOptionPickerViewControllercreates aRideOptionSegmentedControlwith thePickMeUpViewModel. -
RideOptionSegmentedControltells itsRideOptionPickerViewModelwhen the user selects a ride option. -
RideOptionPickerViewModelcallspickUpUser(in rideOptionID: RideOptionID)on itsPickMeUpViewModel. -
PickMeUpViewModelchanges its state to.confirmRequestandPickMeUpViewControllerdisplays the Confirm button.
That’s it for the pick-me-up screen! PickMeUpViewController screen relies on its RideOptionPickerViewController child to signal out to the view model when the ride-option selection changes. This separation of responsibilities lets the PickMeUpViewController focus on higher-level tasks, such as laying out the children on screen and presenting the correct user interface when the PickMeUpViewModel state changes.
Navigating
This section is all about navigation. You’ll learn different techniques for driving navigation, how to manage initial view state on navigation and managing scopes when transitioning from onboarding to signed in.
Driving navigation
Koober uses three main techniques for driving navigation:
- Model-driven navigation: View model state changes drive transitions in the user interface.
- System-driven navigation: Built-in
UIKitcomponents drive navigation. - Combination of both model- and system-driven navigation.
Model-driven navigation
The transitions from the map to the drop-off selection screen and drop-off selection screen back to the map use model-driven navigation.
In the initial state, the pick-me-up screen shows your pick-up location, but no drop-off location. Tapping the Where to? button brings up a screen to select the drop-off location.
After you select a location, the screen dismisses, and the pick-me-up screen shows the selected drop-off location along with the ride-option picker.
You can find the pick-me-up view models in KooberKit/iOSApp/UILayer/SignedIn/PickMeUp and the user interface in Koober_iOS/iOSApp/SignedIn/PickMeUp.
The pick-me-up view model changes the current view state, and the view performs the navigation. You might remember the PickMeUpView enum from the Composing views section:
public enum PickMeUpView {
case initial
case selectDropoffLocation
case selectRideOption
case confirmRequest
case sendingRideRequest
case final
}
public class PickMeUpViewModel:
DropoffLocationDeterminedResponder,
RideOptionDeterminedResponder,
CancelDropoffLocationSelectionResponder {
// MARK: - Properties
// ...
@Published public private(set) var view: PickMeUpView
// ...
}
PickMeUpViewModel contains a PickMeUpView publisher that gets updated on view state changes. PickMeUpViewController observes the changes and reacts by navigating to the next screen.
The view model’s pick-me-up view starts in the initial state, and switches to selectDropoffLocation when the user taps the Where to? button.
class PickMeUpRootView: NiblessView {
// MARK: - Properties
let viewModel: PickMeUpViewModel
private var subscriptions = Set<AnyCancellable>()
let whereToButton: UIButton = {
// Create and return button here
// ...
}()
// ...
func bindWhereToButtonToViewModel() {
whereToButton.addTarget(
viewModel,
action: #selector(
PickMeUpViewModel.
showSelectDropoffLocationView),
for: .touchUpInside)
}
// ...
}
The View binds the Where to? button to the view model’s showSelectDropoffLocationView() method.
public class PickMeUpViewModel:
DropoffLocationDeterminedResponder,
RideOptionDeterminedResponder,
CancelDropoffLocationSelectionResponder {
// ...
@Published public private(set) var view: PickMeUpView
// ...
@objc
public func showSelectDropoffLocationView() {
view = .selectDropoffLocation
}
// ...
}
showSelectDropoffLocationView() updates the view model’s view to .selectDropoffLocation which publishes the new value.
Next, let’s look at how the view controller observes the state changes.
public class PickMeUpViewController: NiblessViewController {
// MARK: - Properties
// View Model
let viewModel: PickMeUpViewModel
// Child View Controllers
let mapViewController:
PickMeUpMapViewController
let rideOptionPickerViewController:
RideOptionPickerViewController
let sendingRideRequestViewController:
SendingRideRequestViewController
// State
private var subscriptions = Set<AnyCancellable>()
// Factories
let viewControllerFactory: PickMeUpViewControllerFactory
// MARK: - Methods
// ...
public override func viewDidLoad() {
addFullScreen(childViewController: mapViewController)
super.viewDidLoad()
subscribe(to: viewModel.$view.eraseToAnyPublisher())
observeErrorMessages()
}
func subscribe(to publisher:
AnyPublisher<PickMeUpView, Never>) {
publisher
.receive(on: DispatchQueue.main)
.sink { [weak self] view in
self?.present(view)
}.store(in: &subscriptions)
}
func present(_ view: PickMeUpView) {
switch view {
case .initial:
presentInitialState()
case .selectDropoffLocation:
presentDropoffLocationPicker()
case .selectRideOption:
dropoffLocationSelected()
// Handle other states
// ...
}
}
// ...
func presentDropoffLocationPicker() {
let viewController =
viewControllerFactory.
makeDropoffLocationPickerViewController()
present(viewController, animated: true)
}
// ...
}
PickMeUpViewController subscribes to the view publisher, and calls present(_ view: PickMeUpView) on state changes.
When view state switches to selectDropoffLocation, the view controller calls presentDropoffLocationPicker(). It creates and presents a new DropoffLocationPickerViewController.
That’s it! View models update the view’s current state, and the view reacts to state changes. Dismissing DropoffLocationPickerViewController follows the same model-driven navigation pattern:
class DropoffLocationPickerContentRootView: NiblessView {
// MARK: - Properties
let viewModel: DropoffLocationPickerViewModel
// ...
// MARK: - Methods
init(frame: CGRect = .zero,
viewModel: DropoffLocationPickerViewModel) {
// ...
}
// ...
}
// ...
extension DropoffLocationPickerContentRootView:
UITableViewDelegate {
func tableView(_ tableView: UITableView,
didSelectRowAt indexPath: IndexPath) {
let selectedLocation = searchResults[indexPath.row]
viewModel.select(dropoffLocation: selectedLocation)
}
}
DropoffLocationPickerContentRootView gets initialized with a DropoffLocationPickerViewModel.
The view model has a task method to select a drop-off location: select(dropoffLocation: NamedLocation). The view controller calls the task method when the user selects a drop-off location.
The pick-me-up view controller still needs to know to dismiss the select drop-off location screen. To accomplish this, the DropoffLocationPickerViewModel needs to notify the PickMeUpViewModel.
Take a look at the drop-off location-picker view model:
protocol DropoffLocationDeterminedResponder {
func dropOffUser(at location: Location)
}
public class DropoffLocationPickerViewModel {
// MARK: - Properties
// ...
// Injected Dependency
let dropoffLocationDeterminedResponder:
DropoffLocationDeterminedResponder
// ...
// MARK: - Methods
public init(pickupLocation: Location,
locationRepository: LocationRepository,
dropoffLocationDeterminedResponder:
DropoffLocationDeterminedResponder,
cancelDropoffLocationSelectionResponder:
CancelDropoffLocationSelectionResponder) {
// ...
}
// ...
public func select(dropoffLocation: NamedLocation) {
dropoffLocationDeterminedResponder.
dropOffUser(at: dropoffLocation.location)
}
// ...
}
DropoffLocationPickerViewModel gets initialized with a DropoffLocationDeterminedResponder. This is actually a PickMeUpViewModel that conforms to the responder protocol. This allows these two view models to communicate with each other.
The drop-off-location picker view model calls dropOffUser(at:) on its responder when the user selects a drop-off location.
Next, look at how the PickMeUpViewModel responds to the new drop-off location:
public class PickMeUpViewModel:
DropoffLocationDeterminedResponder,
RideOptionDeterminedResponder,
CancelDropoffLocationSelectionResponder {
// ...
func dropOffUser(at location: Location) {
guard case let .initial(pickupLocation) = progress
else {
fatalError()
}
let waypoints = NewRideWaypoints(
pickupLocation: pickupLocation,
dropoffLocation: location)
progress = .waypointsDetermined(waypoints: waypoints)
view = .selectRideOption
mapViewModel.dropoffLocation = location
}
// ...
}
When the user selects a new drop-off location, PickMeUpViewModel updates view to .selectRideOption:
public class PickMeUpViewController: NiblessViewController {
// ...
func subscribe(to publisher:
AnyPublisher<PickMeUpView, Never>) {
publisher
.receive(on: DispatchQueue.main)
.sink { [weak self] view in
self?.present(view)
}.store(in: &subscriptions)
}
func present(_ view: PickMeUpView) {
switch view {
case .initial:
presentInitialState()
case .selectDropoffLocation:
presentDropoffLocationPicker()
case .selectRideOption:
dropoffLocationSelected()
case .confirmRequest:
presentConfirmControl()
case .sendingRideRequest:
presentSendingRideRequestScreen()
case .final:
dismissSendingRideRequestScreen()
}
}
// ...
func dropoffLocationSelected() {
if presentedViewController is
DropoffLocationPickerViewController {
dismiss(animated: true)
}
presentRideOptionPicker()
}
// ...
}
Finally, the view controller reacts to the state change by dismissing the select drop-off location screen and showing the ride option picker.
Here’s a diagram showing the entire dropoff location selection flow:
Going through the steps one by one:
-
PickMeUpViewControllerinjects itsPickMeUpViewModelintoDropoffLocationPickerViewController.DropoffLocationPickerViewControllercreates aDropoffLocationPickerContentRootViewwith thePickMeUpViewModel. -
DropoffLocationPickerContentRootViewtells itsDropoffLocationPickerViewModelwhen the user selects a new drop-off location. -
DropoffLocationPickerViewModelcallsdropOffUser(at location: Location)on itsPickMeUpViewModel. -
PickMeUpViewModelchanges its state to.selectRideOptionandPickMeUpViewControllerdismisses the screen.
Model-driven navigation decouples child view controllers from the navigation flow. Children live on their own and signal user interactions by calling task methods on their view models. The container view controller handles high-level navigation.
System-driven navigation
Koober doesn’t use pure system-driven navigation anywhere in the app. So leave Koober land for a bit and take a look at a simple UITabBarController example.
Note: The Xcode project for this example is in TabBarExample/TabBarExample.xcodeproj.
let firstViewController = UIViewController()
firstViewController.tabBarItem =
UITabBarItem(
title: "Red",
image: nil,
selectedImage: nil)
firstViewController.view.backgroundColor = .red
let secondViewController = UIViewController()
secondViewController.tabBarItem =
UITabBarItem(
title: "Blue",
image: nil,
selectedImage: nil)
secondViewController.view.backgroundColor = .blue
let tabBarController = UITabBarController()
tabBarController.viewControllers =
[firstViewController, secondViewController]
tabBarController.selectedViewController = secondViewController
The UITabBarController contains two child view controllers. The tab bar controller sets its select view controller to the second child.
The tab bar uses system-driven navigation to switch between its child view controllers. The tab bar holds on to firstViewController and secondViewController for its entire lifecycle. When selectedViewController changes, the tab bar handles transitions to the correct view controller.
OK, back to Koober!
Combination
The onboarding screen uses model-driven navigation from the welcome screen to the sign-in screen, and it uses system-driven navigation backwards to the welcome screen.
You can find the onboarding view models in KooberKit/iOSApp/UILayer/Onboard and the UI in Koober_iOS/iOSApp/Onboarding.
// NavigationAction.swift
public enum NavigationAction<ViewModelType>: Equatable
where ViewModelType: Equatable {
case present(view: ViewModelType)
case presented(view: ViewModelType)
}
// OnboardingViewModel.swift
public typealias OnboardingNavigationAction =
NavigationAction<OnboardingView>
NavigationAction tells the view whether or not it needs to be presented or whether it’s finished presenting. This allows the view to update the user interface when the navigation transition completes.
public enum OnboardingView {
case welcome
case signin
case signup
// ...
}
OnboardingView has three possible states: welcome, sign in and sign up.
OnboardingViewController can present the welcome screen, sign-in screen or sign-up screen.
public class OnboardingViewModel:
GoToSignUpNavigator, GoToSignInNavigator {
// MARK: - Properties
@Published public private(set)
var navigationAction: OnboardingNavigationAction =
.present(view: .welcome)
// MARK: - Methods
public init() {}
func navigateToSignUp() {
navigationAction = .present(view: .signup)
}
func navigateToSignIn() {
navigationAction = .present(view: .signin)
}
public func uiPresented(onboardingView: OnboardingView) {
navigationAction = .presented(view: onboardingView)
}
}
OnboardingViewModel updates an OnboardingNavigationAction publisher when the view state changes. OnboardingViewController subscribes to this publisher to present the next screen:
public class OnboardingViewController:
NiblessNavigationController {
// MARK: - Properties
// View Model
let viewModel: OnboardingViewModel
var subscriptions = Set<AnyCancellable>()
// ...
public override func viewDidLoad() {
super.viewDidLoad()
let navigationActionPublisher =
viewModel.$navigationAction.eraseToAnyPublisher()
subscribe(to: navigationActionPublisher)
}
func subscribe(to publisher:
AnyPublisher<OnboardingNavigationAction, Never>) {
publisher
.receive(on: DispatchQueue.main)
.removeDuplicates()
.sink { [weak self] action in
guard let strongSelf = self else { return }
strongSelf.respond(to: action)
}.store(in: &subscriptions)
}
func respond(to navigationAction:
OnboardingNavigationAction) {
switch navigationAction {
case .present(let view):
present(view: view)
case .presented:
break
}
}
func present(view: OnboardingView) {
switch view {
case .welcome:
presentWelcome()
case .signin:
presentSignIn()
case .signup:
presentSignUp()
}
}
func presentWelcome() {
pushViewController(welcomeViewController,
animated: false)
}
func presentSignIn() {
pushViewController(signInViewController,
animated: true)
}
func presentSignUp() {
pushViewController(signUpViewController,
animated: true)
}
}
OnboardingViewController subscribes to the OnboardingViewModel current OnboardingNavigationAction publisher. The view controller calls present(view: OnboardingView) to push the next view controller onto the navigation stack.
On present, sign-in view controller gets pushed onto a UINavigationController stack. At this point, the system takes control of navigation.
The onboarding view controller doesn’t handle the navigation backwards when the user taps the Back button in the sign-in screen.
OnboardingView state still needs to update to .welcome after the sign-in screen gets dismissed. Onboarding view controller updates the state using UINavigationControllerDelegate methods:
extension OnboardingViewController:
UINavigationControllerDelegate {
// ...
public func navigationController(
_ navigationController: UINavigationController,
didShow viewController: UIViewController,
animated: Bool) {
guard let shownView =
onboardingView(associatedWith: viewController) else {
return
}
viewModel.uiPresented(onboardingView: shownView)
}
}
Onboarding view controller updates state anytime a view controller gets shown on the navigation stack. The onboardingView(associatedWith: UIViewController) method returns the view state depending on the view controller’s type.
After the backwards transition back to the welcome screen, the view model view state is set back to .welcome.
Managing state
When you navigate between screens, there are two ways to manage state:
-
Create a new view each time the application presents a new screen.
-
Reuse views anytime the application presents a screen.
New views on navigation
Creating a new view each time you present a new screen makes state management easier. You guarantee the screen starts from the initial state each time it’s presented.
The main app navigation that navigates from the getting-location screen to the pick-me-up screen to the waiting-for-pick-up screen is an example of creating new views on navigation.
The SignedInViewController drives navigation between GettingUsersLocationViewController, PickMeUpViewController, and WaitingForPickupViewController.
You can find the view controller file in Koober_iOS/iOSApp/Onboarding/SignIn:
protocol SignedInViewControllerFactory {
func makeGettingUsersLocationViewController() ->
GettingUsersLocationViewController
func makePickMeUpViewController(pickupLocation: Location) ->
PickMeUpViewController
func makeWaitingForPickupViewController() ->
WaitingForPickupViewController
}
public class SignedInViewController: NiblessViewController {
// ...
// MARK: Factories
let viewControllerFactory: SignedInViewControllerFactory
// ...
func present(_ view: SignedInView) {
switch view {
case .gettingUsersLocation:
let viewController = viewControllerFactory.
makeGettingUsersLocationViewController()
transition(to: viewController)
case .pickMeUp(let pickupLocation):
let viewController = viewControllerFactory.
makePickMeUpViewController(
pickupLocation: pickupLocation)
transition(to: viewController)
case .waitingForPickup:
let viewController = viewControllerFactory.
makeWaitingForPickupViewController()
transition(to: viewController)
}
}
// ...
func transition(to viewController: UIViewController) {
remove(childViewController: currentChildViewController)
addFullScreen(childViewController: viewController)
currentChildViewController = viewController
}
}
SignedInViewController uses a factory to create its child view controller. SignedInViewController calls the present(_: SignedInView) method each time the SignedInView state changes.
On state changes, SignedInViewController destroys and deallocates the current child. Then, it adds the next child on screen. Children are only instantiated when needed — no one holds a reference to previous child.
Note:
remove(_:)andaddFullScreen(_:)call view controller containment methods on the child view controllers.
Reusing views on navigation
Reusing views on navigation makes state management harder. Each time you present a new screen, you need to make sure the state is reset back to the original state. The onboarding flow is an example of reusing views on navigation.
OnboardingViewController drives navigation from WelcomeViewController to SignInViewController. As you saw in the Driving Navigation — Combination section above, OnboardingViewController initially shows a WelcomeViewController. OnboardingViewController pushes a SignInViewController onto the navigation stack when the user taps the Sign In button.
You can find the View Controller file in Koober_iOS/Onboarding:
public class OnboardingViewController:
NiblessNavigationController {
// ...
// Child View Controllers
let welcomeViewController: WelcomeViewController
let signInViewController: SignInViewController
let signUpViewController: SignUpViewController
// ...
func presentWelcome() {
pushViewController(welcomeViewController,
animated: false)
}
func presentSignIn() {
pushViewController(signInViewController,
animated: true)
}
func presentSignUp() {
pushViewController(signUpViewController,
animated: true)
}
}
UINavigationController reuses views when moving backwards. OnboardingViewController holds a reference to the welcome view controller while displaying the sign-in view controller. When the user taps the Back button, the navigation stack doesn’t create a new welcome screen.
The onboarding view controller must ensure the welcome screen is in the correct state when the navigation stack pops the sign-in screen.
Managing scopes: Onboarding to signed in
During the onboarding flow, no authenticated user exists. There’s no reason to create a map, since the map needs an authenticated user to work.
When the user signs in, you switch the scope from unauthenticated to authenticated. At this point, you can destroy and deallocate all onboarding screens and create a new map screen.
You can find the view controller files in Koober_iOS/iOSApp and Koober_iOS/iOSApp/Onboarding:
public class MainViewController: NiblessViewController {
// MARK: - Properties
// View Model
let viewModel: MainViewModel
// Child View Controllers
let launchViewController: LaunchViewController
var signedInViewController: SignedInViewController?
var onboardingViewController: OnboardingViewController?
// ...
}
MainViewController drives navigation between OnboardingViewController and SignedInviewController, moving the app from the onboarding to signed-in states.
OnboardingViewController contains the welcome, sign-in and sign-up screens.
SignedInviewController contains the map, ride-option picker, and displays the user-profile screen.
public class MainViewController: NiblessViewController {
// ...
public func presentOnboarding() {
let onboardingViewController =
makeOnboardingViewController()
onboardingViewController.modalPresentationStyle =
.fullScreen
present(onboardingViewController, animated: true) {
[weak self] in
guard let strongSelf = self else {
return
}
strongSelf.remove(childViewController:
strongSelf.launchViewController)
if let signedInViewController =
strongSelf.signedInViewController {
strongSelf.remove(childViewController:
signedInViewController)
strongSelf.signedInViewController = nil
}
}
self.onboardingViewController = onboardingViewController
}
// ...
}
In the onboarding flow, the signed-in screen doesn’t exist. That screen requires a valid user session as a dependency — during the onboarding flow, no valid user session exists.
After MainViewController presents a new OnboardingViewController, MainViewController removes and deallocates any previous SignedInViewController from the view hierarchy. This could happen on a sign out:
// ...
public func presentSignedIn(userSession: UserSession) {
remove(childViewController: launchViewController)
let signedInViewControllerToPresent:
SignedInViewController
if let vc = self.signedInViewController {
signedInViewControllerToPresent = vc
} else {
signedInViewControllerToPresent =
makeSignedInViewController(userSession)
self.signedInViewController =
signedInViewControllerToPresent
}
addFullScreen(childViewController:
signedInViewControllerToPresent)
if onboardingViewController?.
presentingViewController != nil {
onboardingViewController = nil
dismiss(animated: true)
}
}
// ...
When the user signs in, MainViewController creates a brand new SignInViewController. Then, MainViewController removes any previous OnboardingViewController from the view hierarchy.
After the app switches from non-authenticated to authenticated scope, only the SignedInViewController exists. For any UI that existed in the non-authenticated scope, the application tears it down and deallocates it.
Pros and cons of MVVM
Pros of MVVM
- View model logic is easy to test independently from the user interface code. View models contain zero UI — only business and validation logic.
- View and model are completely decoupled from each other. View model talks to the view and model separately.
- MVVM helps parallelize developer workflow. One team member can build a view while another team member builds the view model and model. Parallelizing tasks gives your team’s productivity a nice boost.
- While not inherently modular, MVVM does not get in the way of designing a modular structure. You can build out modular UI components using container view and child views, as long as your view models know how to communicate with each other.
- View models can be used across Apple platforms (iOS, tvOS, macOS, etc.) because they don’t import
UIKit. Especially if view models are granular.
Cons of MVVM
- There is a learning curve with
Combine(compared to MVC.) New team members need to learnCombineand how to properly use view models. Development time may slow down at first, until new team members get up to speed. - Typical implementation requires view models to collaborate. Managing memory and syncing state across your app is more difficult when using collaborating view models.
- Business logic is not reusable from different views, since business logic is inside view specific view models.
- It can be hard to trace and debug, because UI updates happen through binding instead of method calls.
- View models have properties for both UI state and dependencies. This means that view models can be difficult to read, because state management is mixed with side effects and dependencies.
Key points
-
The model layer reads and writes data to disk and tells the view model when data has changed.
-
The view model layer contains all the view layer’s state and handles user interactions. The view model listens for change in the model layer and updates its state.
-
The view layer reacts when view model state changes and tells the view model when the user interacts with its components.
-
Repositories are a façade for networking and persistence. View models use repositories for data access instead of performing the actions themselves.
-
The view layer and model layer are completely decoupled. They each only communicate with the view model layer.
Where to go from here?
Koober is meant to be a real-world use case, and there’s a ton of code in the example project we couldn’t cover in one chapter. Feel free to explore the codebase on your own.
Here’s a few places to look:
-
Check out how the signed-in dependency container gets created in Koober_iOS/iOSApp/SignedIn/KooberSignedInDependencyContainer.swift. The container creates all the screens that require an authenticated user session.
-
Before Koober shows the map, you have to fetch the user’s current location. Follow the flow showing the Getting Your Location screen, before navigating to the map. Check out:
- Koober_iOS/iOSApp/SignedIn/SignedInViewController.swift for the navigation.
- KooberKit/UILayer/SignedIn/GettingUsersLocation/GettingUsersLocationViewModel.swift for the fetching location logic.
- Koober_iOS/iOSApp/SignedIn/GettingUsersLocation/GettingUsersLocationRootView.swift for calling the view model’s task method.
- Look into how the drop-off-location picker search works. The drop-off-location picker view controller contains a custom observable search UI controller, and it binds the search input to the drop-off-location picker view model. The view model fetches new locations using a repository, and it updates its search results state. Check out:
- KooberKit/UILayer/SignedIn/PickMeUp/SelecDropoffLocation/DropoffLocationPickerViewModel.swift for the view model.
- Koober_iOS/iOSApp/SignedIn/PickMeUp/SelectDropoffLocation/DropoffLocationPickerContentViewController.swift for the view.