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