Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Communication between ViewModel + View in SwiftUI

Tags:

ios

mvvm

swiftui

I am new to Combine and am struggling with a few concepts around communication. I come from a web background and before that was UIKit, so a different landscape to SwiftUI.

I am very keen to use MVVM to keep business logic away from the View layer. This means that any view that is not a reusable component, has a ViewModel to handle API requests, logic, error handling etc.

The problem I have run into is what is the best way to pass events to the View when something happens in the ViewModel. I understand that a view should be a reflection of state, but for things that are event driven, it requires a bunch of variables that I think is messy and so keen to get other approaches.

The example below is a ForgotPasswordView. It is presented as a sheet, and when a successful reset occurs, it should close + show a success toast. In the case that it fails, there should be an error toast shown (for context the global toast coordinator is managed via an @Environment variable injected at the root of the app).

Below is a limited example

View

struct ForgotPasswordView: View {

    /// Environment variable to dismiss the modal
    @Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>

    /// The forgot password view model
    @StateObject private var viewModel: ForgotPasswordViewModel = ForgotPasswordViewModel()

    var body: some View {
        NavigationView {
            GeometryReader { geo in
                ScrollView {
                    // Field contents + button that calls method
                    // in ViewModel to execute the network method. See `sink` method for response
                }
            }
            .navigationBarTitle("", displayMode: .inline)
            .navigationBarItems(leading: self.closeButton /* Button that fires `closeSheet` */)
        }
    }

    /// Close the presented sheet
    private func closeSheet() -> Void {
        self.presentationMode.wrappedValue.dismiss()
    }
}

ViewModel

class ForgotPasswordViewModel: ObservableObject {

    /// The value of the username / email address field
    @Published var username: String = ""

    /// Reference to the reset password api
    private var passwordApi = Api<Response<Success>>()

    /// Reference to the password api for cancelling
    private var apiCancellable: AnyCancellable?

    init() {
        self.apiCancellable = self.passwordApi.$status
        .receive(on: DispatchQueue.main)
        .sink { [weak self] result in
            guard let result = result else { return }

            switch result {
            case .inProgress:
                // Handle in progress

            case let .success(response):
                // Handle success

            case let .failed(error):
                // Handle failure
            }
        }
    }
}

The above ViewModel has all the logic, and the View simply reflects the data and calls methods. Everything good so far.

Now, in order to handle the success and failed states of the server response, and get that information to the UI, is where I run into issues. I can think of a few ways, but I either dislike, or seem not possible.

With variables

Create individual @Published variables for each state e.g.

@Published var networkError: String? = nil

then set them is the different states

case let .failed(error):
   // Handle failure
   self.networkError = error.description
}

In the View I can then subscribe to this via onRecieve and handle the response

.onReceive(self.viewModel.$networkError, perform: { error in
    if error {
        // Call `closeSheet` and display toast
    }
})

This works, but this is a single example and would require me to create a @Published variable for every state. Also, these variables would have to be cleaned up too (setting them back to nil.

This could be made more graceful by using an enum with associated values, so that there is only a single listener + variable that needs to be used. The enum doesn't however deal with the fact that the variable has to be cleaned up.

With PassthroughSubject

Building on this, I went looking at PassthroughSubject thinking that if I create a single @Publisher like

@Publisher var events: PassthoughSubject = PassthroughSubject<Event, Never>

And publish events like this:

.sink { [weak self] result in
            guard let result = result else { return }

            switch result {
            case let .success(response):
                // Do any processing of success response / call any methods
                self.events.send(.passwordReset)

            case let .failed(error):
            // Do any processing of error response / call any methods
                self.events.send(.apiError(error)
            }
        }

Then I could listen to it like this

.onReceive(self.viewModel.$events, perform: { event in
    switch event {
    case .passwordReset:
      // close sheet and display success toast
    case let .apiError(error):
      // show error toast
})

This is better that the variable, as the events are sent with .send and so the events variable doesn't need cleaning up.

Unfortunately though, it doesn't seem that you can use onRecieve with a PassthroughSubject. If i made it a Published variable but with the same concept, then I would run into the having to clean it up again issue that the first solution had.

With everything in the view

The last scenario, that I have been trying to avoid is to handle everything in the View

struct ForgotPasswordView: View {

    /// Environment variable to dismiss the modal
    @Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>

    /// Reference to the reset password api
    @StateObject private var passwordApi = Api<Response<Success>>()

    var body: some View {
        NavigationView {
            GeometryReader { geo in
                ScrollView {
                    // Field contents + button that all are bound/call
                    // in the view.
                }
            }
            .navigationBarTitle("", displayMode: .inline)
            .navigationBarItems(leading: self.closeButton /* Button that fires `closeSheet` */)
            .onReceive(self.passwordApi.$status, perform: { status in
                guard let result = result else { return }
                    switch result {
                    case .inProgress:
                        // Handle in progress
                    case let .success(response):
                        // Handle success via closing dialog + showing toast
                    case let .failed(error):
                        // Handle failure via showing toast
                    }
            })
        }
    }
}

The above is a simple example, but if there is more complex processing or data manipulating that needs to happen, I don't want that to be in the View as it is messy. Additionally, in this case the success / failure event perfectly matches the events that are needed to be handled in the UI, not every view would fall into that category though, so even more processing could need to be done.

I have run into this conundrum for nearly every view that has a model, if something happens in the ViewModel that is a basic event, how should it be communicated to the View. I feel like there should be a nicer way to do this, which also leads me to think I am doing it wrong.

That was a large wall of text, but I am keen to ensure that the architecture of the app in maintainable, easily testable, and views focus on displaying data and calling mutations (but not at the expense of there being lots of boilerplate variables in the ViewModel)

Thanks

like image 302
Eli Avatar asked Sep 11 '25 18:09

Eli


1 Answers

You can have the result of your reset password request delivered to a @Published property of your view model. SwiftUI will automatically update the associated view when the state changes.

In the following I wrote a password reset form similar to yours, with a view and an underlying view model. The view model has a state with four possible values from a nested State enum:

  • idle as initial state or after the username has been changed.
  • loading when the reset request is being performed.
  • success and failure when the result of the reset request is known.

I simulated the password reset request with a simple delayed publisher which fails when an invalid username is detected (for simplicity only usernames containing @ are considered valid). The publisher result is directly assigned to the published state property using .assign(to: &$state), a very convenient way to connect publishers together:

import Combine
import Foundation

final class ForgotPasswordViewModel: ObservableObject {
    enum State {
        case idle
        case loading
        case success
        case failed(message: String)
    }
    
    var username: String = "" {
        didSet {
            state = .idle
        }
    }
    
    @Published private(set) var state: State = .idle
    
    // Simulate some network request to reset the user password
    private static func resetPassword(for username: String) -> AnyPublisher<State, Never> {
        return CurrentValueSubject(username)
            .delay(for: .seconds(.random(in: 1...2)), scheduler: DispatchQueue.main)
            .map { username in
                return username.contains("@") ? State.success : State.failed(message: "The username does not exist")
            }
            .eraseToAnyPublisher()
    }
    
    func resetPassword() {
        state = .loading
        Self.resetPassword(for: username)
            .receive(on: DispatchQueue.main)
            .assign(to: &$state)
    }
}

The view itself instantiates and stores the view model as @StateObject. The user can enter their name and trigger ask for a password reset. Each time the view model state changes a body update is automatically triggered, which allows the view to adjust appropriately:

import SwiftUI

struct ForgotPasswordView: View {
    @StateObject private var model = ForgotPasswordViewModel()
    
    private var statusMessage: String? {
        switch model.state {
        case .idle:
            return nil
        case .loading:
            return "Submitting"
        case .success:
            return "The password has been reset"
        case let .failed(message: message):
            return "Error: \(message)"
        }
    }
    
    var body: some View {
        VStack(spacing: 40) {
            Text("Password reset")
                .font(.title)
            TextField("Username", text: $model.username)
            Button(action: resetPassword) {
                Text("Reset password")
            }
            if let statusMessage = statusMessage {
                Text(statusMessage)
            }
            Spacer()
        }
        .padding()
    }
    
    private func resetPassword() {
        model.resetPassword()
    }
}

The code above can easily be tested in an Xcode project.

like image 58
defagos Avatar answered Sep 14 '25 09:09

defagos