Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Swift Combine: subsequent Publisher that consumes other Publishers (using CombineLatest) doesn't "fire"

I am trying to replicate the "Wizard School Signup"-example which was given in the WWDC 2019 session "Combine in Practice" https://developer.apple.com/videos/play/wwdc2019/721/ starting at 22:50 using SwiftUI (as opposed to UIKit, which was used during the session).

I have created all the publishers from the example: validatedEMail, validatedPassword and validatedCredentials. While validatedEMail and validatedPassword work just fine, validatedCredentials, which consumes both publishers using CombineLatest, never fires

//
//  RegistrationView.swift
//
//  Created by Lars Sonchocky-Helldorf on 04.07.19.
//  Copyright © 2019 Lars Sonchocky-Helldorf. All rights reserved.
//

import SwiftUI
import Combine

struct RegistrationView : View {
    @ObjectBinding var registrationModel = RegistrationModel()

    @State private var showAlert = false
    @State private var alertTitle: String = ""
    @State private var alertMessage: String = ""

    @State private var registrationButtonDisabled = true

    @State private var validatedEMail: String = ""
    @State private var validatedPassword: String = ""

    var body: some View {
        Form {
            Section {
                TextField("Enter your EMail", text: $registrationModel.eMail)
                SecureField("Enter a Password", text: $registrationModel.password)
                SecureField("Enter the Password again", text: $registrationModel.passwordRepeat)
                Button(action: registrationButtonAction) {
                    Text("Create Account")
                }
                .disabled($registrationButtonDisabled.value)
                    .presentation($showAlert) {
                        Alert(title: Text("\(alertTitle)"), message: Text("\(alertMessage)"))
                }
                .onReceive(self.registrationModel.validatedCredentials) { newValidatedCredentials in
                    self.registrationButtonDisabled = (newValidatedCredentials == nil)
                }
            }

            Section {
                Text("Validated EMail: \(validatedEMail)")
                    .onReceive(self.registrationModel.validatedEMail) { newValidatedEMail in
                        self.validatedEMail = newValidatedEMail != nil ? newValidatedEMail! : "EMail invalid"
                }
                Text("Validated Password: \(validatedPassword)")
                    .onReceive(self.registrationModel.validatedPassword) { newValidatedPassword in
                        self.validatedPassword = newValidatedPassword != nil ? newValidatedPassword! : "Passwords to short or don't matchst"
                }
            }
        }
        .navigationBarTitle(Text("Sign Up"))
    }

    func registrationButtonAction() {
        let trimmedEMail: String = self.registrationModel.eMail.trimmingCharacters(in: .whitespaces)

        if (trimmedEMail != "" && self.registrationModel.password != "") {
            NetworkManager.sharedInstance.registerUser(NetworkManager.RegisterRequest(uid: trimmedEMail, password: self.registrationModel.password)) { (status) in
                if status == 200 {
                    self.showAlert = true
                    self.alertTitle = NSLocalizedString("Registration successful", comment: "")
                    self.alertMessage = NSLocalizedString("please verify your email and login", comment: "")
                } else if status == 400 {
                    self.showAlert = true
                    self.alertTitle = NSLocalizedString("Registration Error", comment: "")
                    self.alertMessage = NSLocalizedString("already registered", comment: "")
                } else {
                    self.showAlert = true
                    self.alertTitle = NSLocalizedString("Registration Error", comment: "")
                    self.alertMessage = NSLocalizedString("network or app error", comment: "")
                }
            }
        } else {
            self.showAlert = true
            self.alertTitle = NSLocalizedString("Registration Error", comment: "")
            self.alertMessage = NSLocalizedString("username / password empty", comment: "")
        }
    }
}

class RegistrationModel : BindableObject {
    @Published var eMail: String = ""
    @Published var password: String = ""
    @Published var passwordRepeat: String = ""

    public var didChange = PassthroughSubject<Void, Never>()

    var validatedEMail: AnyPublisher<String?, Never> {
        return $eMail
            .debounce(for: 0.5, scheduler: RunLoop.main)
            .removeDuplicates()
            .flatMap { username in
                return Future { promise in
                    self.usernameAvailable(username) { available in
                        promise(.success(available ? username : nil))
                    }
                }
        }
        .eraseToAnyPublisher()
    }

    var validatedPassword: AnyPublisher<String?, Never> {
        return Publishers.CombineLatest($password, $passwordRepeat)
            .debounce(for: 0.5, scheduler: RunLoop.main)
            .map { password, passwordRepeat in
                guard password == passwordRepeat, password.count > 5 else { return nil }
                return password
        }
        .eraseToAnyPublisher()
    }

    var validatedCredentials: AnyPublisher<(String, String)?, Never> {
        return Publishers.CombineLatest(validatedEMail, validatedPassword)
            .map { validatedEMail, validatedPassword in
                guard let eMail = validatedEMail, let password = validatedPassword else { return nil }
                return (eMail, password)
        }
        .eraseToAnyPublisher()
    }


    func usernameAvailable(_ username: String, completion: (Bool) -> Void) {
        let isValidEMailAddress: Bool = NSPredicate(format:"SELF MATCHES %@", "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}").evaluate(with: username)

        completion(isValidEMailAddress)
    }
}

#if DEBUG
struct RegistrationView_Previews : PreviewProvider {
    static var previews: some View {
        RegistrationView()
    }
}
#endif

I expected the form button to get enabled when a valid username (valid E-Mail-address) and two matching passwords with the right length are provided. The two Publishers responsible for those two tasks work, I can see the validatedEMail and the validatedPassword in the user interface in the two Texts which I added for debugging purposes.

Just the third Publisher (also compare to the code shown in the Video from above at 32:20) never fires. I did set breakpoints in those Publishers, in the validatedPassword Publisher at line:

guard password == passwordRepeat, password.count > 5 else { return nil }

which stopped there just fine but a similar breakpoint in the validatedCredentials Publisher at line:

guard let eMail = validatedEMail, let password = validatedPassword else { return nil }

was never reached.

What did I do wrong?

Edit:

In order to make the above code run under Xcode-beta 11.0 beta 4 didChange needs to be replaced with willChange

like image 664
SqAR.org Avatar asked Jul 05 '19 23:07

SqAR.org


2 Answers

I've got this question answered here: https://forums.swift.org/t/crash-in-swiftui-app-using-combine-was-using-published-in-conjunction-with-state-in-swiftui/26628/9 by the very friendly and helpful Nanu Jogi, who is not on stackoverflow.

It is rather straight forward:

add this line:

        .receive(on: RunLoop.main) // run on main thread 

in validatedCredentials so that it looks like this:

var validatedCredentials: AnyPublisher<(String, String)?, Never> {
    return Publishers.CombineLatest(validatedEMail, validatedPassword)

        .receive(on: RunLoop.main) // <<—— run on main thread

        .map { validatedEMail, validatedPassword in
            print("validatedEMail: \(validatedEMail ?? "not set"), validatedPassword: \(validatedPassword ?? "not set")")

            guard let eMail = validatedEMail, let password = validatedPassword else { return nil }

            return (eMail, password)

    }
    .eraseToAnyPublisher()

This is all what is needed.

And here one more time the whole code for reference (updated for Xcode 11.0 beta 5 (11M382q)):

//
//  RegistrationView.swift
//  Combine-Beta-Feedback
//
//  Created by Lars Sonchocky-Helldorf on 09.07.19.
//  Copyright © 2019 Lars Sonchocky-Helldorf. All rights reserved.
//

import SwiftUI
import Combine

struct RegistrationView : View {
    @ObservedObject var registrationModel = RegistrationModel()

    @State private var registrationButtonDisabled = true

    @State private var validatedEMail: String = ""
    @State private var validatedPassword: String = ""

    var body: some View {
        Form {
            Section {
                TextField("Enter your EMail", text: $registrationModel.eMail)
                SecureField("Enter a Password", text: $registrationModel.password)
                SecureField("Enter the Password again", text: $registrationModel.passwordRepeat)
                Button(action: registrationButtonAction) {
                    Text("Create Account")
                }
                .disabled($registrationButtonDisabled.wrappedValue)
                    .onReceive(self.registrationModel.validatedCredentials) { newValidatedCredentials in
                        self.registrationButtonDisabled = (newValidatedCredentials == nil)
                }
            }

            Section {
                Text("Validated EMail: \(validatedEMail)")
                    .onReceive(self.registrationModel.validatedEMail) { newValidatedEMail in
                        self.validatedEMail = newValidatedEMail != nil ? newValidatedEMail! : "EMail invalid"
                }
                Text("Validated Password: \(validatedPassword)")
                    .onReceive(self.registrationModel.validatedPassword) { newValidatedPassword in
                        self.validatedPassword = newValidatedPassword != nil ? newValidatedPassword! : "Passwords to short or don't match"
                }
            }
        }
        .navigationBarTitle(Text("Sign Up"))
    }

    func registrationButtonAction() {

    }
}

class RegistrationModel : ObservableObject {

    @Published var eMail: String = ""
    @Published var password: String = ""
    @Published var passwordRepeat: String = ""

    var validatedEMail: AnyPublisher<String?, Never> {
        return $eMail
            .debounce(for: 0.5, scheduler: RunLoop.main)
            .removeDuplicates()
            .map { username in
                return Future { promise in
                    print("username: \(username)")
                    self.usernameAvailable(username) { available in
                        promise(.success(available ? username : nil))
                    }
                }
        }
        .switchToLatest()
            .eraseToAnyPublisher()
    }

    var validatedPassword: AnyPublisher<String?, Never> {
        return Publishers.CombineLatest($password, $passwordRepeat)
            .debounce(for: 0.5, scheduler: RunLoop.main)
            .map { password, passwordRepeat in
                print("password: \(password), passwordRepeat: \(passwordRepeat)")
                guard password == passwordRepeat, password.count > 5 else { return nil }
                return password
        }
        .eraseToAnyPublisher()
    }

    var validatedCredentials: AnyPublisher<(String, String)?, Never> {
        return Publishers.CombineLatest(validatedEMail, validatedPassword)
            .receive(on: RunLoop.main)
            .map { validatedEMail, validatedPassword in
                print("validatedEMail: \(validatedEMail ?? "not set"), validatedPassword: \(validatedPassword ?? "not set")")
                guard let eMail = validatedEMail, let password = validatedPassword else { return nil }
                return (eMail, password)
        }
        .eraseToAnyPublisher()
    }


    func usernameAvailable(_ username: String, completion: (Bool) -> Void) {
        let isValidEMailAddress: Bool = NSPredicate(format:"SELF MATCHES %@", "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}").evaluate(with: username)

        completion(isValidEMailAddress)
    }
}

#if DEBUG
struct RegistrationView_Previews : PreviewProvider {
    static var previews: some View {
        RegistrationView()
    }
}
#endif
like image 52
SqAR.org Avatar answered Nov 15 '22 07:11

SqAR.org


Just replace

.debounce(for: 0.5, scheduler: RunLoop.main)

with

.throttle(for: 0.5, scheduler: RunLoop.main, latest: true)

Since there is no expensive code in the publishers subscription no deferred processing would be basically needed. Throttling the key events with latest: true will do the job almost in the same way.

I'm not such a Reactive programming expert that I can judge what is the reason behind, I assume a design choice.

like image 29
berni Avatar answered Nov 15 '22 07:11

berni