Arquitectura Model-View-ViewModel en Swift
Arquitectura Model-View-ViewModel en Swift

Arquitectura Model-View-ViewModel (MVVM) en Swift

La Arquitectura Model-View-ViewModel, también conocida como MVVM es una de la arquitecturas más usadas al crear una aplicación en Swift. Tenemos 3 componentes: El Model, la View, y el ViewModel. La View escucha cambios que ocurren en el ViewModel con Bindings.

SwiftBeta

Tabla de contenido


👇 SÍGUEME PARA APRENDER SWIFTUI, SWIFT, XCODE, etc 👇
Aprende a usar la Arquitectura MVVM en Swift
Aprende a usar la Arquitectura MVVM en Swift

Hoy en SwiftBeta vamos a aprender a usar la arquitectura Model-View-ViewModel, o también conocida como MVVM. Esta arquitectura está compuesta por 3 componentes, el Model, la View y el ViewModel. Y antes de repasar cada componente, te voy a mostrar la app que vamos a crear.

La app que vamos a crear es un formulario de Login. Donde un user añadirá su email y password. Si introduce un email y password correcto podrá navegar a una nueva pantalla, y si introduce cualquier otro error, tanto en el email o la password, se mantendrá en la vista LoginView y aparecerá el error. Incluso vamos a añadir validación, es decir, hasta que un user no añada texto en los 2 UITextFields, no activaremos el UIButton, crearemos una validación previa. Vamos a ver temas muy interesantes como Bindings, Combine, Navegación, etc, y va a ser un video muy completo donde vamos a ver esta arquitectura tan usada.

Lo primero que vamos hacer es repasar qué responsabilidad tiene cada uno de estos componentes, la arquitectura Model-View-ViewModel tiene 3 componentes bien diferenciados:

  • Model, El modelo representa los datos y la lógica de negocio de la app. El modelo se encarga de obtener los datos que se utilizan en la aplicación. Por ejemplo, imagina que tenemos un APIClient y realizamos una petición HTTP, al hacer esta petición HTTP y transformar el JSON a un modelo de nuestro dominio, toda esta parte se considera parte del modelo.
  • View, La vista es la representación visual de la información que obtenemos del ViewModel. Y tiene una doble función, por un lado se encarga de lanzar acciones al ViewModel y por otro lado se encarga de escuchar los cambios de modelo que recibe del ViewModel, una cambio en alguna de las propiedades del ViewModel, si la estamos escuchando desde la View, nos enteraremos y podremos realizar algún cambio en la View. Imagina que tenemos un formulario de login, con los campos rellenados de email y password, cuando un user toca el Button de Login en la View, esta acción se envía al ViewModel para ejecutar una tarea, en este caso validar que el login es correcto. Una vez el ViewModel realizar el login, actualiza sus propiedades para saber si el login ha sido correcto o hemos tenido un error. Estos cambios lo escucha la View, y se actualiza acorde al resultado obtenido.
  • ViewModel, El ViewModel es el mediador entre la View y el Model. El nombre ya nos da una pista View Model. Se encarga de conectar estos dos componentes pero de una manera especial que veremos en los próximos minutos. Por un lado nutre a la vista con la información que le llega de la capa inferior, del Modelo. Y por otro lado se encarga de recibir acciones de la View, y estas acciones pueden quedarse en el ViewModel o pueden pasar a la capa del Modelo, como por ejemplo realizar una petición HTTP, consulta a una base de datos, etc.

Al utilizar Model-View-ViewModel vemos que tenemos diferentes responsabilidades separadas en varios componentes. La vista se encarga de mostrar los datos en la pantalla, el View Model se encarga de recuperar información y manipularla y finalmente el modelo se encarga de recuperar y almacenar los datos. Al utilizar MVVM, podemos reutilizar el modelo en diferentes partes de nuestra app, también mejoramos la testabilidad, podemos testear un ViewModel sin necesidad de preocuparnos por la View o Model, y también tenemos una buena escalabilidad de nuestra app, a medida que creamos nueva funcionalidad podemos agregar nuevos componentes sin afectar a la View o al Model.

En el video de hoy vamos a crear un formulario de login, si el user se loguea correctamente navegaremos a una pantalla dentro de nuestra app, y sino, mostraremos un error en la misma pantalla de login. Vamos a crear una total de 2 pantallas, la primera pantalla es el Login y la segunda una pantalla muy simple que mostrará un mensaje de bienvenida.

Tendremos la View con el formulario, que lanzará la acción al ViewModel. Y el ViewModel llamará a un método del Modelo para simular una petición HTTP. Si el user utilizado es swiftbeta.blog@gmail.com con password 1234567890, la respuesta será un success, es decir, será correcta, y el modelo pasará esta información al ViewModel. En cambio, si utilizamos otro email que no sea swiftbeta.blog@gmail.com o otra password, el ViewModel recibirá el error y la View lo mostrará por pantalla. Con este ejemplo quería enseñarte como va a ser el flujo de nuestra app.

Un tema importante sobre la arquitectura MVVM es que el ViewModel comunica cambios de los datos a la View con Bindings. Es decir, no utilizamos protocolos, como el delegation pattern, en este caso, con MVVM lo que hacemos es crear este Binding desde la View, y vamos a usar el patrón Observer. El ViewModel se transformará en un objeto observable por la View. Es decir, la View esta escuchando cambios que ocurren en el ViewModel, y cuando hay una actualización de un valor, este cambio se ve reflejado automáticamente en la View. El ViewModel en ningún momento tiene una referencia de la View, el ViewModel cambia un valor en alguna de sus propiedades y si la View lo está escuchando, entonces actualiza la View para mostrar el dato actualizado. Este binding lo podemos hacer de diferentes maneras, usando KVO, combine, o librerías externas como RxSwift, o RxCocoa, etc.

Vamos a empezar a programar, y para hacerlo vamos a crear un nuevo proyecto en Xcode.

Pero antes, si quieres apoyar el contenido que subo cada semana, suscríbete. De esta manera seguiré publicando contenido completamente grautito en Youtube. También comentarte que tengo un libro de Swift con varios capítulos y ejercicios prácticos totalmente en castellano. Al adquirir el libro tendrás todo el código de cada capítulo en Playgrounds y también la solución de cada ejercicio, te dejo el enlace en la descripción del video.

Creamos proyecto en Xcode

Lo primero de todo que vamos hacer es crear un proyecto en Xcode. Al hacerlo es muy importante que en la sección de Interface escojamos Storyboard, ya que estamos usando el framework de UIKit.

Una vez tenemos creado el proyecto, creamos una carpeta en el listado de ficheros. Vamos a llamar a la nueva carpeta Login. Y dentro de ella vamos a mover el fichero ViewController (que en breves lo renombrareos), y creamos un fichero llamado LoginViewModel, y a continuación creamos una carpeta llamada Model. Para que sea más fácil de entender el video, voy a renombrar el ViewController a LoginView (de esta manera queda muy claro que tenemos el Model, View y ViewModel con el mismo prefijo Login).

Renombramos la clase, el nombre del fichero y vamos al Storyboard, en la sección de Inspector de Identidad, cambiamos ViewController por LoginView. Y ahora ya podemos continuar. Tenemos la estructura de nuestra arquitectura MVVM, vamos a empezar a crear los componentes de la arquitectura MVVM por la capa más baja, es decir por el Modelo.

Nos vamos dentro de la carpeta Model, y aquí vamos a crear una class llamada APIClient, pulsamos COMMAND+N.
Esta clase va a simular la petición HTTP a nuestro backend (a nuestro servidor). No vamos hacer una petición real, vamos a simular esta petición para no complicar el video de hoy.

Model

Creamos la clase  APIClient

final class APIClient {
...
}
Creamos nuestro APIClient en Swift

Para poder validar que nuestro login funciona correctamente, debemos pasar 2 parámetros que viajaran desde la View al Model. Estos dos parámetros son el email y la password. Y para simular que estamos haciendo una petición HTTP vamos a crear una Task con un delay de 2 segundos (esto simulará nuestra petición asíncrona al servidor):

final class APIClient {
    func login(withEmail email: String,
               withPassword password: String) async throws {
        // Simular petición HTTP y esperar 2 segundos
        try await Task.sleep(nanoseconds: NSEC_PER_SEC * 2)
    }
}
Método para realizar el login

Ahora vamos a crear un método que simula la validación de nuestro Login. Este código debería estar en el backend, pero repito, lo hacemos aquí para simplificar el video.

Este método lo podemos crear como una función libre, sin estar dentro de ningún tipo. Y también acepta 2 parámetros de entrada, el email y password, y representa la información que recibe el servidor. Si el email no es igual a swiftbeta.blog@gmail.com y la password no es igual a 1234567890 salimos del scope de la función.

func simulateBackendLogic(email: String,
                          password: String) {
    guard email == "swiftbeta.blog@gmail.com" else {
        print("El user no es swiftbeta")
        return
    }
    guard password == "1234567890" else {
        print("La password no es 1234567890")
        return
    }
    
    print("Success")
    return
}
Método para simular que estamos realizando una petición HTTP

Para hacer más real este ejemplo, vamos hacer que este método sea más inteligente, si todo va bien vamos a retornar un tipo User con 3 propiedades name, token y sessionStart.

Creamos un fichero nuevo en la carpeta Model, y creamos la siguiente struct:

struct User {
    let name: String
    let token: String
    let sessionStart: Date
}
Creamos el tipo User

Ahora volvemos a nuestro método y vamos a retornar un tipo User:

func simulateBackendLogic(email: String,
                          password: String) -> User {
    guard email == "swiftbeta.blog@gmail.com" else {
        print("El user no es swiftbeta")
        return
    }
    guard password == "1234567890" else {
        print("La password no es 1234567890")
        return
    }
    
    print("Success")
    return .init(name: "swiftbeta.blog@gmail.com",
                 token: "1234567890",
                 sessionStart: Date())
}
Creamos la implementación del método que se encarga de simular la petición HTTP

Fíjate que al hacerlo, obtenemos un error de compilación. Esto es normal ya que en los returns de nuestros guard debemos retornar también un User. Pero en este caso vamos a lanzar errores que recogeremos en una capa superior. Es decir, cuando obtengamos un error del email o password, no devolveremos un return, lanzaremos un error que recogeremos desde el ViewModel, y ahí decidiremos qué lógica hacer, pero no nos adelantemos.

Para hacerlo vamos a crear un tipo error muy sencillo, de esta manera podremos diferencias cuando es un error debido al correo electronico, y cuando un error debido a la password.

Nos vamos arriba del fichero y añadimos el siguiente código:

enum BackendError: String, Error{
    case invalidEmail = "Comprueba tu Email"
    case invalidPassword = "Comprueba tu Password"
}
Creamos un tipo nuevo que representa los errores que recibimos de Backend

Y ahora, vamos a actualizar nuestro método para poder lanzar errores en los guard correspondientes:

func simulateBackendLogic(email: String,
                          password: String) throws -> User {
    guard email == "swiftbeta.blog@gmail.com" else {
        print("El user no es swiftbeta")
        throw BackendError.invalidEmail
    }
    guard password == "1234567890" else {
        print("La password no es 1234567890")
        throw BackendError.invalidPassword
    }
    
    print("Success")
    return .init(name: "swiftbeta.blog@gmail.com",
                 token: "1234567890",
                 sessionStart: Date())
}
Retornamos los errores correspondientes dentro de los guard

Ya tenemos nuestra simulación del backend lista! ahora tenemos que llamar a este método justo debajo del Task.sleep que hemos añadido hace unos minutos, el método quedaría de la siguiente manera:

final class APIClient {
    func login(withEmail email: String,
               withPassword password: String) async throws -> User {
        // Simular petición HTTP y esperar 2 segundos
        try await Task.sleep(nanoseconds: NSEC_PER_SEC * 2)
        return try simulateBackendLogic(email: email,
                             password: password)
    }
}
Llamamos al nuevo método dentro del método login

ViewModel

Una vez hemos acabado con esta capa, nos vamos a la capa superior, nos vamos al LoginViewModel. Creamos la class:

class LoginViewModel {
...
}
Creamos nuestro ViewModel

Y aquí dentro vamos a crear una referencia a nuestro APIClient, vamos a crear una propiedad

class LoginViewModel {
    let apiClient: APIClient
    
    init(apiClient: APIClient) {
        self.apiClient = apiClient
    }
}
Creamos una referencias al APIClient que hemos creado en la sección anterior

A continuación, vamos a crear un método llamado userLogin. Este método se ejecutará cuando desde la View pulsemos el Button de Login. Al hacerlo la View se comunicará con el ViewModel y el ViewModel con el Model, de esta manera podremos comprobar si es correcto el email y password. Vamos a crear la implementación de este método:

class LoginViewModel {
    let apiClient: APIClient
    
    init(apiClient: APIClient) {
        self.apiClient = apiClient
    }
    
    @MainActor
    func userLogin(withEmail email: String,
               withPassword password: String) {
        Task {
            do {
                let userModel = try await apiClient.login(withEmail: email,
                                                          withPassword: password)
            } catch let error as BackendError {
                print(error.localizedDescription)
            }
        }
    }
}
Método para realizar el login, en caso de recibir un error lo capturamos y lo printamos por consola

Muy fácil, llamamos al APIClient, y si el Login es correcto obtenemos un User que lo almacenamos en userModel, y si no es correcto el login printamos el error por consola (Aquí aparecerá si es un error del email o password).

Más tarde volveremos al ViewModel, ahora vamos a crear la View. Dentro de la View creamos la referencia al ViewModel. Importante destacar que la View si tiene una referencia al ViewModel, pero el ViewModel no tiene referencias a la View, es más no la conoce. El ViewModel actualiza sus propiedades (que crearemos en los próximos minutos) y la View escucha cualquier cambio en ellas para actualizar cualquier estado de la View.

View

Vamos a crear la referencia y añadimos 2 UITextField y un UIButton:

class LoginView: UIViewController {
    private let loginViewModel = LoginViewModel(apiClient: APIClient())
    
    private let emailTextField: UITextField = {
        let textField = UITextField()
        textField.placeholder = "Add Email"
        textField.borderStyle = .roundedRect
        textField.translatesAutoresizingMaskIntoConstraints = false
        return textField
    }()
    
    private let passwordTextField: UITextField = {
        let textField = UITextField()
        textField.placeholder = "Add Password"
        textField.borderStyle = .roundedRect
        textField.translatesAutoresizingMaskIntoConstraints = false
        return textField
    }()
    
    private lazy var loginButton: UIButton = {
        var configuration = UIButton.Configuration.filled()
        configuration.title = "Login"
        configuration.subtitle = "¡Suscríbete a SwiftBeta!"
        configuration.image = UIImage(systemName: "play.circle.fill")
        configuration.imagePadding = 8
        
        let button = UIButton(type: .system, primaryAction: UIAction(handler: { action in
            // self.startLogin()
        }))
        button.translatesAutoresizingMaskIntoConstraints = false
        button.configuration = configuration
        
        return button
    }()

    override func viewDidLoad() {
        super.viewDidLoad()
            
        [emailTextField,
         passwordTextField,
         loginButton
        ].forEach(view.addSubview)
        
        NSLayoutConstraint.activate([
            emailTextField.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            emailTextField.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 32),
            emailTextField.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -32),
            emailTextField.bottomAnchor.constraint(equalTo: passwordTextField.topAnchor, constant: -20),
            
            passwordTextField.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            passwordTextField.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 32),
            passwordTextField.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -32),
            passwordTextField.bottomAnchor.constraint(equalTo: loginButton.topAnchor, constant: -20),
            
            loginButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            loginButton.centerYAnchor.constraint(equalTo: view.centerYAnchor),
        ])
    }
}
View del formulario con 2 UITextField y 1 UIButton

Ahora vamos a crear la acción de que cuando se pulse el Button de Login. Se lance la acción de hacer el login. Para ello descomentamos el método que hemos creado en nuestro UIButton y creamos el método que se encargará de llamar al ViewModel:

private func startLogin() {
    print("Start login")
    loginViewModel.userLogin(withEmail: emailTextField.text?.lowercased() ?? "",
                             withPassword: passwordTextField.text?.lowercased() ?? "")
}
Conectamos el UIButton con el método que lanzará y recogemos el campo del email y password

Una vez hemos creado el método compilamos. Podemos añadir valores en nuestro UITextField y vemos como al apretar el UIButton, al cabo de 2 segundos obtenemos un error. Si añadimos el email swiftbeta.blog@gmail.com con password 1234567890, recibimos un tipo User, todo funciona perfectamente.

Ahora es cuando viene la parte interesante de esta arquitectura. Vamos a crear una serie de propiedades en el ViewModel, y desde la View crearemos el Binding. De esta manera conectaremos propiedades del ViewModel a la View y de la View al ViewModel.

De momento, vamos a conectar los 2 UITextFields a 2 propiedades del ViewModel. Cada cambio que se haga en el UITextField lo recibirá el ViewModel. De esta manera los datos siempre estarán actualizado. Vas a ver lo potente que es esto cuando añadamos la validación al formulario. Lo primero de todo es añadir estas dos propiedades al ViewModel.

class LoginViewModel {
    @Published var email = ""
    @Published var password = ""
    ...
}
Creamos la propiedad email y password en el ViewModel

También vamos a añadir una propiedad para poder guardar la referencia cuando nos suscribamos a los valores de estas dos propiedades, creamos la siguiente propiedad:

var cancellables = Set<AnyCancellable>()
Set de AnyCancellables

Al hacerlo obtenemos un error, vamos a importar el framework Combine. Combine es un framework de Apple para crear programación reactiva. Este framework es el que nos va a crear el binding desde la View. De esta manera podremos escuchar cambios que ocurran en las propiedades de tipo @Published. Para demostrarlo, vamos a crear un método llamado formValidation() que tenga varios prints, así podremos identificar que el Binding desde la View se ha creado correctamente.
El método formValidation() lo llamamos desde el init del ViewModel:

init(apiClient: APIClient) {
    self.apiClient = apiClient
    
    formValidation()
}

func formValidation() {
    $email.sink { value in
        print("Email: \(value)")
    }.store(in: &cancellables)
    
    $password.sink { value in
        print("Password: \(value)")
    }.store(in: &cancellables)
}
Suscripción de las dos propiedades que hemos creado en el ViewModel

Ahora cada cambio que hamos desde la View, se verá reflejado en el ViewModel. Pero antes hay que volver a la View y crear este Binding, esta conexión con las propiedades que acabamos de crear.

Volvemos a nuestra View, y vamos a crear un método llamado createBindingViewWithViewModel()

Dentro de este método vamos a conectar primero el emailTextField con la propiedad email del ViewModel, y a continuación crearemos el passwordTextField con la propiedad password del ViewModel. Para poder crear esta conexión vamos a usar Combine. Vamos a importar este framework en la View. La idea es que cada vez que añadamos una letra en nuestro UITextField, el print que hemos añadido en el ViewModel se ejecutará. Para hacerlo vamos a crear una extensión en nuestra View, así podremos utilizar Combine y el UITextField:

extension UITextField {
    var textPublisher: AnyPublisher<String, Never> {
        return NotificationCenter.default
            .publisher(for: UITextField.textDidChangeNotification, object: self)
            .map { notification in
                return (notification.object as? UITextField)?.text ?? ""
            }
            .eraseToAnyPublisher()
    }
}

Esta extensión es muy sencilla, lo único que hace es publicar una notificación cada vez que se cambia el valor del UITextField. Se recoge la notificación y solo se extrae el Text que contiene. Una vez tenemos la extensión creada dentro del método createBindingViewWithViewModel, creamos el siguiente binding:

emailTextField.textPublisher
    .assign(to: \LoginViewModel.email, on: loginViewModel)
    
passwordTextField.textPublisher
    .assign(to: \LoginViewModel.password, on: loginViewModel)

Aquí lo que indicamos es que estamos asignando el valor que estamos escribiendo en nuestro UITextfield a la propiedad del ViewModel. Un cambio aquí se ve reflejado automáticamente en la propiedad del ViewModel. En un caso es la propiedad email y en el otro la password. Si compilamos, fíjate que obtenemos un warning, vamos a arreglarlo. Añadimos la siguiente propiedad en la View:

var cancellables = Set<AnyCancellable>()

Cuando trabajamos con Combine, debemos guardar una referencia en un tipo llamado AnyCancellable. Al usar assign, a continuación usamos store, y pasamos como parámetro el Array de Cancellables que hemos creado:

private func createBindingViewWithViewModel() {
    emailTextField.textPublisher
        .assign(to: \LoginViewModel.email, on: loginViewModel)
        .store(in: &cancellables)
        
    passwordTextField.textPublisher
        .assign(to: \LoginViewModel.password, on: loginViewModel)
        .store(in: &cancellables)
}

Y ahora, antes de compilar vamos a llamar al método Binding al inicio del método viewDidLoad:

override func viewDidLoad() {
    super.viewDidLoad()
    
    createBindingViewWithViewModel()
    ...
}

Al compilar, si escribimos en los textfields vemos los prints que aparecen por consola. Esto es muy potente, ya que ahora podemos crear lógica en nuestro ViewModel, como la validación de nuestro formulario. Podemos decir, que solo si hay más de 5 caracteres en el texfield de email y password, habilita el UIButton.

Vamos a crear esta validación, lo primero de todo es crear una propiedad en nuestro ViewModel de tipo Bool.

@Published var isEnabled = false

Al iniciar la View nuestro UIButton tendrá el estado de deshabilitado, solo cuando escribamos 5 caracteres en ambos TextFields se habilitará para poder realizar el login. Dentro del método form validation, vamos a probar primero con el texfield del email, podemos añadir el siguiente código:

func formValidation() {
    $email
        .filter { $0.count > 5 }
        .receive(on: DispatchQueue.main)
        .sink { value in
            self.isEnabled = true
        }.store(in: &cancellables)
}

Una vez tenemos la lógica en el ViewModel, debemos ir a la View y escuchar cualquier cambio en esta propiedad. Para hacerlo añadimos el Binding, mira que sencillo es:

private func createBindingViewWithViewModel() {
    emailTextField.textPublisher
        .assign(to: \LoginViewModel.email, on: loginViewModel)
    .store(in: &cancellables)
    
    passwordTextField.textPublisher
        .assign(to: \LoginViewModel.password, on: loginViewModel)
    .store(in: &cancellables)
    
    loginViewModel.$isEnabled
        .assign(to: \.isEnabled, on: loginButton)
        .store(in: &cancellables)
    ...
}

Fíjate que directamente hemos hecho un Binding de la propiedad isEnabled del UIButton. Una propiedad que existe dentro de este componente. Con la propiedad isEnabled de nuestro ViewModel. Vamos a compilar y probar si funciona

Al compilar el UIButton aparece deshabilitado, pero al escribir más de 5 caracteres en el UITextField del email, se habilita. Vamos a añadir la misma validación para la password, pero en lugar de copiar y pegar, vamos a utilizar el potencial de Combine y vamos a combinar estas dos propiedades en una:

func formValidation() {
    Publishers.CombineLatest($email, $password)
        .filter { email, password in
            return email.count >= 5 && password.count >= 5
        }
        .sink { value in
            self.isEnabled = true
    }.store(in: &cancellables)
}

Mucho mejor, vamos a compilar y vamos a probar que hasta que no añadimos más de 5 caracteres en ambos UITextFields, el UIButton no se habilita.

Super potente, vamos a continuar. Estaría muy chulo poder mostrar un spinner, un loading en nuestro UIButton cuando se está realizando la petición HTTP, y eliminar el loading una vez hemos recibido una respuesta de la petición HTTP. Vamos a controlarlo y vamos a añadir una nueva propiedad en nuestro ViewModel, de esta manera podremos crear un Binding desde la View:

@Published var showLoading = false

Ahora debemos actualizar esta propiedad antes de realizar la petición y cuando hayamos obtenido la respuesta. Nos vamos al método del ViewModel userLogin y modificamos los valores de esta propiedad:

@MainActor
func userLogin(withEmail email: String,
           withPassword password: String) {
    showLoading = true
    Task {
        do {
            let userModel = try await apiClient.login(withEmail: email,
                                              withPassword: password)
        } catch let error as BackendError {
            print(error.localizedDescription)
        }
        showLoading = false
    }
}

Una vez hemos hecho esta parte, ahora que toca? debemos ir a la View y crear el Binding de esta nueva propiedad, de esta manera podremos actualizar la UI. Añadimos el siguiente Binding a continuación de todos los demás.

private func createBindingViewWithViewModel() {
    emailTextField.textPublisher
        .assign(to: \LoginViewModel.email, on: loginViewModel)
    .store(in: &cancellables)
    
    passwordTextField.textPublisher
        .assign(to: \LoginViewModel.password, on: loginViewModel)
    .store(in: &cancellables)
    
    loginViewModel.$isEnabled
        .assign(to: \.isEnabled, on: loginButton)
        .store(in: &cancellables)
    
    loginViewModel.$showLoading
        .assign(to: \.configuration!.showsActivityIndicator, on: loginButton)
        .store(in: &cancellables)
...
}

Si ahora compilamos, hemos conseguido una UI muy fácil de entender. Al pulsar el UIButton, aparece un Loading dentro del UIButton indicando al user que algo está ocurriendo, y al cabo de 2 segundos desaparece el Loading.

Vamos a continuar y vamos a crear otro Binding desde nuestra View, en este caso quiero mostrar el mensaje de error en la View. Así el user puede saber qué error a cometido y puede intentarlo de nuevo. Para hacerlo, nos vamos al ViewModel y allí vamos a crear una nueva propiedad @Published llamada errorMessage de tipo String:

class LoginViewModel {
    @Published var email = ""
    @Published var password = ""
    @Published var isEnabled = false
    @Published var showLoading = false
    @Published var errorModel: String = ""
...
}

Y ¿dónde vamos a asignarle un valor? justo en el método que hemos creado al inicio del video llamado userLogin. Aquí dentro asignamos el error en caso de capturarlo dentro del catch:

} catch let error as BackendError {
     errorModel = error.rawValue
}

Y cada vez que se llame al método vamos a resetear su valor a cadena vacía:

@MainActor
func userLogin(withEmail email: String,
           withPassword password: String) {
    errorModel = ""
    ...
}

Ahora volvemos a la View, y ¿dónde mostramos este error? vamos a crear un UILabel y lo vamos a añadir a la View.

private let errorLabel: UILabel = {
    let label = UILabel()
    label.text = ""
    label.numberOfLines = 0
    label.textColor = .red
    label.font = .systemFont(ofSize: 20,
                             weight: .regular,
                             width: .condensed)
    label.translatesAutoresizingMaskIntoConstraints = false
    return label
}()

Y lo añadimos a la View, nos debería de quedar el siguiente viewDidLoad:

override func viewDidLoad() {
    super.viewDidLoad()
    
    createBindingViewWithViewModel()

    [emailTextField,
     passwordTextField,
     loginButton,
     errorLabel].forEach(view.addSubview)
    
    NSLayoutConstraint.activate([
        emailTextField.centerXAnchor.constraint(equalTo: view.centerXAnchor),
        emailTextField.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 32),
        emailTextField.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -32),
        emailTextField.bottomAnchor.constraint(equalTo: passwordTextField.topAnchor, constant: -20),
        
        passwordTextField.centerXAnchor.constraint(equalTo: view.centerXAnchor),
        passwordTextField.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 32),
        passwordTextField.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -32),
        passwordTextField.bottomAnchor.constraint(equalTo: loginButton.topAnchor, constant: -20),
        
        loginButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
        loginButton.centerYAnchor.constraint(equalTo: view.centerYAnchor),
        
        errorLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor),
        errorLabel.topAnchor.constraint(equalTo: loginButton.bottomAnchor, constant: 20)
    ])
    
...
}

Y lo siguiente que vamos hacer es crear el Binding de la View que acabamos de añadir, con la propiedad del ViewModel errorModel:

private func createBindingViewWithViewModel() {
    emailTextField.textPublisher
        .assign(to: \LoginViewModel.email, on: loginViewModel)
        .store(in: &cancellables)
        
    passwordTextField.textPublisher
        .assign(to: \LoginViewModel.password, on: loginViewModel)
        .store(in: &cancellables)
    
    loginViewModel.$isEnabled
        .assign(to: \.isEnabled, on: loginButton)
        .store(in: &cancellables)
    
    loginViewModel.$showLoading
        .assign(to: \.configuration!.showsActivityIndicator, on: loginButton)
        .store(in: &cancellables)
    
    loginViewModel.$errorModel
        .assign(to: \UILabel.text!, on: errorLabel)
        .store(in: &cancellables)
}

Es curioso como el ViewModel no tiene ni idea de la View, pero al crear estos Bindings escuchamos todos los cambios de las propiedades del ViewModel, y en base a esos cambios actualizamos la UI, es decir, nuestra View.

Vamos a compilar y vamos a ver el resultado, si añadimos un email y password incorrectos ¿qué ocurre? Pues que al cabo de 2 segundos se muestra un Label con el mensaje de error. Vamos a añadir el email correcto pero vamos a añadir una password incorrecta.

¡Perfecto! antes de continuar, vamos a añadir un campo al UITextField de la password para que no se vea, de esta manera añadimos seguridad.

private let passwordTextField: UITextField = {
    let textField = UITextField()
    textField.placeholder = "Add Password"
    textField.borderStyle = .roundedRect
    textField.isSecureTextEntry = true
    textField.translatesAutoresizingMaskIntoConstraints = false
    return textField
}()

Lo siguiente que vamos a ver es la navegación, es decir, cuando un user realice correctamente el Login, lo que haremos será navegar a otra pantalla. Simularemos que le damos acceso al contenido de nuestra app. Vamos a crear esta nueva View, creamos una nueva carpeta y la vamos a llamar Home.

Dentro de Home, vamos a crear una View llamada HomeView, y va a ser una subclase de UIViewController, pulsamos COMMAND+N. Y seguimos todos los pasos para crearla. Y añadimos el siguiente mensaje de bienvenida:

class HomeView: UIViewController {
    
    private let messageLabel: UILabel = {
        let label = UILabel()
        label.numberOfLines = 0
        label.textColor = .white
        label.font = .systemFont(ofSize: 40,
                                 weight: .bold,
                                 width: .standard)
        label.text = "¿Quieres seguir aprendiendo? 🤩\n\n¡Suscríbete a SwiftBeta para no perderte ningún video! 🚀"
        label.translatesAutoresizingMaskIntoConstraints = false
        return label
    }()

    override func viewDidLoad() {
        super.viewDidLoad()

        view.backgroundColor = .blue
        view.addSubview(messageLabel)
        
        NSLayoutConstraint.activate([
            messageLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor, constant: -60),
            messageLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            messageLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 32),
            messageLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -32)
        ])
    }
}

Una vez hemos creado la View, ¿cómo creamos esta navegación? es decir, una vez hemos añadido el login correcto, ¿quién se encarga de navegar? La LoginView tendrá esta lógica, y para escuchar que hemos realizado el login correctamente, vamos a crear una propiedad en el ViewModel llamada userModel.

class LoginViewModel {
    @Published var email = ""
    @Published var password = ""
    @Published var isEnabled = false
    @Published var showLoading = false
    @Published var errorModel: String = ""
    @Published var userModel: User?
...
}

Esta propiedad la asignamos igual que hemos hecho con el errorMessage. Al hacerlo ¿cúal es el siguiente paso? Es ir a la View a crear el Binding. Fïjate que es siempre el mismo proceso, creamos una propiedad que se actualiza en el ViewModel y desde la View creamos el Binding para escuchar cuando ocurre un cambio y así poder lanzar una acción. Que en este caso será escuchar un cambio en userModel para poder Navegar a HomeView.

Vamos a LoginView y creamos un nuevo Binding:

private func createBindingViewWithViewModel() {
    emailTextField.textPublisher
        .assign(to: \LoginViewModel.email, on: loginViewModel)
    .store(in: &cancellables)
    
    passwordTextField.textPublisher
        .assign(to: \LoginViewModel.password, on: loginViewModel)
    .store(in: &cancellables)
    
    loginViewModel.$isEnabled
        .assign(to: \.isEnabled, on: loginButton)
        .store(in: &cancellables)
    
    loginViewModel.$userModel.sink { [weak self] _ in
        print("Success, navigate to home view controller")
        // Esta lógica la podría realizar el ViewModel
        let homeView = HomeView()
        self?.present(homeView, animated: true)
    }.store(in: &cancellables)
    
    loginViewModel.$showLoading
        .assign(to: \.configuration!.showsActivityIndicator, on: loginButton)
        .store(in: &cancellables)
    
    loginViewModel.$errorModel
        .assign(to: \UILabel.text!, on: errorLabel)
        .store(in: &cancellables)
}

Ahora vamos a compilar y probar nuestro Login. ¡Funciona tal y como esperamos! 🚀

Conclusión

Hoy hemos aprendido a usar una arquitectura muy usada al crear aplicaciones en Swift. Esta arquitectura llamada Model-View-ViewModel tiene diferentes responsabilidades, por un lado tenemos la View para representar los datos en la pantalla, luego tenemos el Modelo que se encaraga de obtener la información y así nuestra app funcione correctamente y en el medio tenemos el ViewModel, el ViewModel realiza todas las tareas que le solicita la View y actualiza su propio estado, la View se entera de estos cambios con unos Bindings a las propiedades del ViewModel.