I am writing a SwiftUI app using the MVVM pattern. I'm running into a situation where I am presenting an app setup screen and prompting the user for a password. To perform password validation, I have a series of Combine Publishers in my ViewModel that validate against numerous criteria. If the password validates successfully, a "Complete Registration" button is enabled in my View, and upon tapping it the account is established. If an error occurs while creating the account, an Alert is supposed to be displayed. However, I am finding in my testing that when the alert is displayed it immediately dismisses itself. Here's what my ViewModel looks like:
struct ApplicationSetupState {
}
enum ApplicationSetupInput {
case completeSetup
}
class ApplicationSetupViewModel: ViewModel {
typealias ViewModelState = ApplicationSetupState
typealias ViewModelInput = ApplicationSetupInput
enum PasswordValidation {
case valid
case empty
case noMatch
}
@Published var password: String = ""
@Published var confirmPassword: String = ""
@Published var useBiometrics: Bool = false
@Published var isValid: Bool = false
@Published var state: ApplicationSetupState
@Published var errorMessage: String? = nil
@Published var error: Bool = false
private var cancellables: [AnyCancellable] = []
private var isPasswordEmptyPublisher: AnyPublisher<Bool, Never> {
$password
.debounce(for: 0.8, scheduler: RunLoop.main)
.removeDuplicates()
.map { password in
DDLogVerbose("Mapping password=\(password)")
return password == ""
}
.eraseToAnyPublisher()
}
private var arePasswordsEqualPublisher: AnyPublisher<Bool, Never> {
Publishers.CombineLatest($password, $confirmPassword)
.debounce(for: 0.1, scheduler: RunLoop.main)
.map { password, confirmPassword in
DDLogVerbose("Mapping password=\(password), confirmPassword=\(confirmPassword)")
return password == confirmPassword
}
.eraseToAnyPublisher()
}
private var isPasswordValidPublisher: AnyPublisher<PasswordValidation, Never> {
Publishers.CombineLatest(isPasswordEmptyPublisher, arePasswordsEqualPublisher)
.map { isPasswordEmpty, arePasswordsEqual in
DDLogVerbose("Mapping isPasswordEmpty=\(isPasswordEmpty), arePasswordsEqual=\(arePasswordsEqual)")
if isPasswordEmpty {
return .empty
} else if !arePasswordsEqual {
return .noMatch
}
return .valid
}
.eraseToAnyPublisher()
}
private var isSetupValidPublisher: AnyPublisher<Bool, Never> {
isPasswordValidPublisher
.map { isPasswordValid in
return isPasswordValid == .valid
}
.eraseToAnyPublisher()
}
init() {
self.state = ApplicationSetupState()
self.isPasswordValidPublisher
.receive(on: RunLoop.main)
.map { passwordValidation in
DDLogVerbose("Mapping passwordValidation=\(passwordValidation)")
switch passwordValidation {
case .empty:
return "You must provide a master password"
case .noMatch:
return "The passwords do not match. Try again."
default:
return ""
}
}
.assign(to: \.errorMessage, on: self)
.store(in: &cancellables)
self.isSetupValidPublisher
.receive(on: RunLoop.main)
.assign(to: \.isValid, on: self)
.store(in: &cancellables)
}
func trigger(_ input: ApplicationSetupInput) {
switch input {
case .completeSetup:
self.completeApplicationSetup()
}
}
// MARK: - Private methods
private func completeApplicationSetup() {
DDLogInfo("Completing application setup...")
do {
let status = try ApplicationSetupController.shared.completeApplicationSetup(withPassword: self.password,
useBiometrics: self.useBiometrics)
DDLogVerbose("status=\(status)")
if status == true {
DDLogInfo("Application setup completed successfully. Posting notification...")
NotificationCenter.default.post(name: .didCompleteSetup, object: nil)
} else {
DDLogWarn("Application setup failed with no error message")
self.error = true
self.errorMessage = "An unknown error occurred during setup. Please try again."
}
} catch {
DDLogError("ERROR while completing application setup - \(error)")
self.error = true
self.errorMessage = error.localizedDescription
}
}
}
As you can see, I have a @State variable called error that indicates whether or not there is an error situation. My View looks something like this:
struct MasterPasswordSetupView: View {
@ObservedObject var viewModel: ApplicationSetupViewModel
var body: some View {
VStack(alignment: .center) {
...stuff...
}
.alert(isPresented: self.$viewModel.error) {
Alert(title: Text("Error"),
message: Text(self.viewModel.errorMessage ?? ""),
dismissButton: .default(Text("Ok")) {
DDLogVerbose("OK pressed!")
})
}
}
}
You'll notice that the Alert is displayed if error is true within my ViewModel. However, I am seeing that as soon as the Alert is displayed it is immediately dismissed, even though I have not pressed the OK button to dismiss it.
I've added some logging, including a didSet method on error, and see that at some point error is, in fact, toggled back to false. However, I am not explicitly doing this anywhere within my code, so I'm at a loss as to why it is happening. Any thoughts?
Have you tried to create @StateObject instead of @ObservedObject.
@StateObject var viewModel: ApplicationSetupViewModel
I think the issue is that your viewModel is reloading.
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