IOOI
(Lars Sonchocky-Helldorf)
1
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
IOOI
(Lars Sonchocky-Helldorf)
2
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
IOOI
(Lars Sonchocky-Helldorf)
3
To early to rejoice, I now get the following run time error:
Fatal error: Accessing State<String> outside View.body
any ideas?
lolgear
(Dmitry Lobanov)
4
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
IOOI
(Lars Sonchocky-Helldorf)
5
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
filimo
(VictorK)
6
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)
}
}
}
IOOI
(Lars Sonchocky-Helldorf)
8
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
NanuJogi
(NanuJogi)
9
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
IOOI
(Lars Sonchocky-Helldorf)
10
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
NanuJogi
(NanuJogi)
11
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