Crash in SwiftUI App using Combine (was: Using @Published in conjunction with @State in SwiftUI)

I know, this question is probably off topic for this forum and I am sorry, but I have no idea where else to ask it (if you have a better idea, please let me know).

Having watched "Combine in Practice" ( https://developer.apple.com/videos/play/wwdc2019/721/), especially the second part (starting at 22:50) I very much liked the username/password validation using Combine and UIKit.

Now I wonder how to replicate something similar using Combine and SwiftUI. I tried to put two Property Wrappers at once onto my properties. However, my naive approach did not work out:

import SwiftUI
import Combine

struct RegistrationView : View {
    @Published @State private var eMail: String = ""

    var body: some View {
        Form {
            TextField("Enter your EMail", text: $$eMail.value)
        }
    }
}

While I somehow get access to the inner Property Wrapper this way, it doesn't work as such or at least as I expected, I get the error message:

Cannot convert value of type 'State<String>' to expected argument type 'Binding<String>'

Is there a way to fix this and get something like this working (maybe I am overlooking something obvious) or will Combine and SwiftUI just not play well together?

regards,

Lars

1 Like

I found out that I can get access to the binding when I use

TextField("Enter your EMail", text: $$eMail.value.binding)

that takes me a little step further.

1 Like

To early to rejoice, I now get the following run time error:

Fatal error: Accessing State<String> outside View.body

any ideas?

Could you tell the purpose of @Published property in your case?

I suppose that you should "combine" signals from different views ( for example ) in your model, not in view.

@State is already a kind of "published property" - it is a binding property with "onChange" redrawing.

class RegistrationModel : BindableObject {
  var username: String = ""
  var password: String = ""
  var email: String = ""


  var didChange: PassthroughObject<Void, Never>
  init() {

  }
}

// MARK: TopView
struct TopRegistrationView {
  @State var model: RegistrationModel

  var body: some View {
    UsernameView($model.username)
    PasswordView($model.password)
    RegistrationView($model.email)
    AcceptButtonView(model.canAcceptData)
  }
}

As you can see, model also have canAcceptData - it is a property which indicates button availability.
You can calculate this property canAcceptData on-fly, because redrawing cycle will lift it to a view for you.

Please, check my attempt to demonstrate different approaches in SwiftUI.

2 Likes

Thanks for your inspiration @lolgear! As you suggested I moved the @States into a model class and got it working this way. My code looks now like this, it is still work in progress and somewhat simpler than the code from your GitHub example. I post it here so others with similar problems get an idea how to get things working:

//
//  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

Update: I updated the code, got the password validation working (there were subtle mistakes in the code presented at the WWDC session (missing Publishers. before CombineLatest and missing .map after CombineLatest) but now the third publisher validatedCredentials never fires. I did set breakpoints in the lines with the guard in both validatedPassword and validatedCredentials , the one in validatedPassword was reached just fine but the one in validatedCredentials was never reached.

What is going on here?

1 Like

use this code instead of onReceive

    init() {
        self.registrationModel.$validatedCredentials
              .map { newValidatedPassword != nil }
              .receive(on: RunLoop.main)
              .assign(to: \.validatedPassword, self)
    }

@IOOI well in swiftUI don't we have something called EnviromentObject which in theory is supposed to take care of our actual logic? as the following code suggests having a viewModel that contains all the actual logic, and separates it from our view. Correct me if I am wrong,

class ViewModel: BindableObject {
    
    var didChange = PassthroughSubject<ViewModel, Never>()
    var validated: String? {
        didSet {
            didChange.send(self)
        }
    }
    @Published var passwordPublisher = ""
    @Published var passwordConfirmationPublisher = ""
    
    var validatedCredentials: AnyPublisher<String?, Never> {
        return Publishers.CombineLatest($passwordPublisher, $passwordConfirmationPublisher)
            .map { arg in
                let (password, confirmPassword) = arg
                guard password == confirmPassword, password.count > 8 else { return nil }
                return password
            }.eraseToAnyPublisher()
    }
    
    init() {
        // bind the validated password to the validated field that we have
        validatedCredentials.receive(on: RunLoop.main).assign(to: \.validated, on: self)
    }
    
    func handleSignin() {
        // do something
    }
}


struct ContentView: View {
    
    @EnvironmentObject var viewModel: ViewModel
    
    var body: some View {
        VStack {
            TextField($viewModel.passwordPublisher, placeholder: Text("First password"))
            TextField($viewModel.passwordConfirmationPublisher, placeholder: Text("First password"))
            Button("Sign in") {
                self.viewModel.handleSignin()
            }.disabled(viewModel.validated == nil)
        }
    }
}

Today I downloaded Xcode 11 beta 5 (11M382q), adjusted my project to the latest changes of Combine (so that I don't get any warnings):

//
//  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.value)
                    .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 = ""
    
    public var willChange = PassthroughSubject<Void, Never>()
    
    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)
            .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

But now I get an

Thread 1: EXC_BAD_INSTRUCTION (code=EXC_I386_INVOP, subcode=0x0)

seconds after launching the app, without having any interaction with it. Am I missing something obvious?

For Apple employees: Feedback Assistant

Thanks in advance for your help,

Lars

Hi,
After several hours trial & error solution is just add below 1 line. Debugging is going to be nightmare.

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

Inside validatedCredentials

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()
}

Learn’t a lot. Nanu Jogi

3 Likes

Super! Thanks a lot! You found the solution for this problem! Works like a charm, I can confirm, no more crashes.

Btw. Do you have a stackoverflow-account? If so, you might want to answer my question there: swiftui - Swift Combine: subsequent Publisher that consumes other Publishers (using CombineLatest) doesn't "fire" - Stack Overflow . I'll then vote your answer up.

Kind regards,

Lars

Hi,
Glad could help. Like to thank you for showing how to use .onReceive in SwiftUI & updating @State property also function usernameAvailable with completion called from Future publisher etc.. very compact & precise codes.

I'm not on stackoverflow.

Thanks & Regards,
Nanu Jogi

1 Like