SqAR.org
SqAR.org

Reputation: 607

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

Upvotes: 5

Views: 9084

Answers (3)

SqAR.org
SqAR.org

Reputation: 607

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

Upvotes: 8

berni
berni

Reputation: 2789

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.

Upvotes: 1

Dan Favano
Dan Favano

Reputation: 118

You might need to group some of these publisher's validation into one consumer. There is a cool playground outlining the combine framework and this is how they do a similar use case. In the example they are validating the user name and password within the same subscriber. The subscriber does not execute until something has been published to the user name and password publishers.

If you wanted to keep them separate then you would need to add some more publishers that basically outline the state of whether the password is valid and the user name is valid. Then have subscribers listening to when both the user name and password publishers are valid.

Upvotes: 0

Related Questions