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
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.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With