In the previous episode, we built a sign in form using SwiftUI. In this episode, we create and integrate a view model that drives the sign in form.
The view layer of your application is responsible for presenting data to the user and responding to user input. Views are dumb and that is a good thing. A view shouldn't perform network requests or validate user input. A view doesn't know what it is displaying. It only knows how to display what it is given. A view model is an object that drives a view. Let's create one for the sign in form.
Creating a View Model
Create a Swift file, name it SignInViewModel.swift, and define a final class with name SignInViewModel. The SignInViewModel class conforms to the ObservableObject protocol. In a nutshell, by conforming to the ObservableObject protocol, the sign in view can observe the view model and update itself to reflect changes of the view model.
import Foundation
final class SignInViewModel: ObservableObject {
}
Open SignInView.swift and declare a variable property, viewModel, of type SignInViewModel. We prefix the property declaration with the ObservedObject property wrapper. The ObservedObject property wrapper subscribes to the view model and invalidates the sign in view every time the view model changes. This is only possible because SignInViewModel conforms to the ObservableObject protocol.
import SwiftUI
struct SignInView: View {
// MARK: - Properties
@ObservedObject var viewModel: SignInViewModel
@State private var email = ""
@State private var password = ""
// MARK: - View
...
}
We inject the view model into the view during initialization. Navigate to the static computed previews property and update the initialization of the SignInView objects.
struct SignInView_Previews: PreviewProvider {
static var previews: some View {
Group {
SignInView(viewModel: SignInViewModel())
.previewDevice(PreviewDevice(rawValue: "iPhone 13"))
SignInView(viewModel: SignInViewModel())
.previewDevice(PreviewDevice(rawValue: "iPad Pro (12.9-inch) (5th generation)"))
}
}
}
Open CocoacastsApp.swift and update the initialization of the SignInView object in the computed body property of the CocoacastsApp struct.
import SwiftUI
@main
struct CocoacastsApp: App {
var body: some Scene {
WindowGroup {
SignInView(viewModel: SignInViewModel())
}
}
}
Defining Publishers
With the view model in place, we can move the email and password properties of SignInView to SignInViewModel. This is a welcome change because the fewer pieces of state a view manages the better. Add the property declarations to SignInViewModel.swift and replace the State property wrappers with Published property wrappers.
import Foundation
final class SignInViewModel: ObservableObject {
// MARK: - Properties
@Published var email = ""
@Published var password = ""
}
Revisit the SignInView struct and update the references to email and password. The syntax is similar. The difference is that the view model manages state instead of the view.
import SwiftUI
struct SignInView: View {
// MARK: - Properties
@ObservedObject var viewModel: SignInViewModel
@State private var email = ""
@State private var password = ""
// MARK: - View
var body: some View {
HStack {
Spacer()
VStack {
VStack(alignment: .leading) {
Text("Email")
TextField("Email", text: $viewModel.email)
.autocapitalization(.none)
.keyboardType(.emailAddress)
.disableAutocorrection(true)
Text("Password")
SecureField("Password", text: $viewModel.password)
}
Button("Sign In") {
print("Sign In")
}
.buttonBorderShape(.capsule)
.buttonStyle(.borderedProminent)
Spacer()
}
.padding()
.frame(maxWidth: 400.0)
.textFieldStyle(.roundedBorder)
Spacer()
}
}
}
View models have a number of compelling benefits. One of those benefits is keeping the view focused and lightweight. Let me show you what I mean by disabling the Sign In button if either of the fields is empty. This is easy to accomplish. Define a computed property, canSignIn, of type Bool. The computed property returns false if either of the fields is empty.
import Foundation
final class SignInViewModel: ObservableObject {
// MARK: - Properties
@Published var email = ""
@Published var password = ""
// MARK: -
var canSignIn: Bool {
!email.isEmpty && !password.isEmpty
}
}
Revisit SignInView.swift and locate the Sign In button. Apply the disabled modifier to the Sign In button, passing in the value of the view model's canSignIn computed property prefixed with the logical not operator, !.
// MARK: - View
var body: some View {
HStack {
Spacer()
VStack {
VStack(alignment: .leading) {
Text("Email")
TextField("Email", text: $viewModel.email)
.autocapitalization(.none)
.keyboardType(.emailAddress)
.disableAutocorrection(true)
Text("Password")
SecureField("Password", text: $viewModel.password)
}
Button("Sign In") {
print("Sign In")
}
.buttonBorderShape(.capsule)
.buttonStyle(.borderedProminent)
.disabled(!viewModel.canSignIn)
Spacer()
}
.padding()
.frame(maxWidth: 400.0)
.textFieldStyle(.roundedBorder)
Spacer()
}
}
Click the play button of the top preview to make the preview live. Notice that the Sign In button is disabled by default because both fields are empty. The Sign In button is enabled as soon as both fields have a value.

When the user taps the Sign In button, we need to visualize that the application is making a request to a remote server. This isn't difficult to implement. Open SignInViewModel.swift and declare a variable property, isSigningIn, of type Bool and prefix the declaration with the Published property wrapper.
import Foundation
final class SignInViewModel: ObservableObject {
// MARK: - Properties
@Published var email = ""
@Published var password = ""
@Published var isSigningIn = false
// MARK: -
var canSignIn: Bool {
!email.isEmpty && !password.isEmpty
}
}
Before we integrate the isSigningIn property into SignInView, we declare a method with name signIn(). The view invokes this method when the user taps the Sign In button. For now, we invoke the asyncAfter(deadline:qos:flags:execute:) method to mimic a network request. The view model sets its isSigningIn property to true before making the network request. In the closure that is passed to the asyncAfter(deadline:qos:flags:execute:) method, isSigningIn is set to false.
// MARK: - Public API
func signIn() {
isSigningIn = true
DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(5)) {
self.isSigningIn = false
}
}
Open SignInView.swift. We need to make two changes. First, we apply the disabled modifier to the VStack that includes the form fields, passing in the value of the view model's isSigningIn property. The user shouldn't be able to change the values of the form fields while the view model is performing a network request.
VStack(alignment: .leading) {
Text("Email")
TextField("Email", text: $viewModel.email)
.autocapitalization(.none)
.keyboardType(.emailAddress)
.disableAutocorrection(true)
Text("Password")
SecureField("Password", text: $viewModel.password)
}
.disabled(viewModel.isSigningIn)
Second, we use an if-else statement to replace the Sign In button with a progress view as long as the view model is waiting for the response of the network request. We apply the progressViewStyle modifier to the progress view, passing in circular as the argument. We also replace the print statement in the action handler of the button, invoking the signIn() method of the view model instead.
if viewModel.isSigningIn {
ProgressView()
.progressViewStyle(.circular)
} else {
Button("Sign In") {
viewModel.signIn()
}
.buttonBorderShape(.capsule)
.buttonStyle(.borderedProminent)
.disabled(!viewModel.canSignIn)
}
Use the live preview to test the implementation. Fill out the sign in form and tap the Sign In button. The Sign In button is replaced with a progress view and the form fields are disabled while the network request is in flight. After five seconds, the Sign In button reappears and the form fields are enabled.
What's Next?
SwiftUI and the Model-View-ViewModel pattern work very well together. The view model manages state and handles the business logic, keeping the view focused and lightweight. In the next episode, you learn how to make the network request, parse the response, and alert the user if something went wrong.