I'm trying to use Combine with a view but am getting this error message at runtime:
Publishing changes from background threads is not allowed; make sure to publish values from the main thread (via operators like receive(on:)) on model updates.
I tried adding ".receive(on: RunLoop.main)" before each ".eraseToAnyPublisher()", but that didn't resolve the issue.
Here is my model code:
import Foundation
import Combine
import Regex
let postcodeRegex = "[A-Z]{1,2}[0-9]{1,2}[A-Z]?\\s?[0-9][A-Z]{2}"
let emailRegex = "^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$"
struct EmailAvailResponse: Decodable {
var available: Bool
}
@MainActor
class SignupModel: ObservableObject {
// Published Properties
@Published var firstName = ""
@Published var lastName = ""
@Published var email = ""
@Published var country = "+1 (US)"
@Published var phoneNumber = ""
@Published var password = ""
@Published var confirmPassword = ""
// Validation Publishers
lazy var firstNameValidation: ValidationPublisher = {
$firstName.nonEmptyValidator("First name is required!")
}()
lazy var lastNameValidation: ValidationPublisher = {
$lastName.nonEmptyValidator("Last name is required!")
}()
lazy var emailValidationRequired: ValidationPublisher = {
$email.nonEmptyValidator("Email is required!")
}()
lazy var emailValidationMatches: ValidationPublisher = {
$email.matcherValidator(emailRegex, "Email is not valid!")
}()
lazy var phoneNumberValidation: ValidationPublisher = {
$phoneNumber.nonEmptyValidator("Phone is required!")
}()
lazy var passwordValidation: ValidationPublisher = {
$password.nonEmptyValidator("Password is required!")
}()
lazy var confirmPasswordValidation: ValidationPublisher = {
$confirmPassword.combineLatest($password) { confirmPassword, password in
guard confirmPassword.count > 0 else {
return .failure(message: "Confirm Password is required!")
}
guard confirmPassword.count > 6 else {
return .failure(message: "Passwords must be > 6 chars!")
}
guard password == confirmPassword else {
return .failure(message: "Passwords do not match!")
}
return .success
}
.dropFirst()
.receive(on: RunLoop.main) // <<—— run on main thread
.eraseToAnyPublisher()
}()
func emailAvailable(_ email: String, completion: @escaping (Bool) -> ()) -> () {
if email != "" {
print("SignupModel: emailAvailable(): Validating email '" + email + "'...")
let pattern = try! Regex(pattern: emailRegex)
if pattern.matches(email) {
guard let url = URL(string: "https://api.myapi.network/users/email/availability?email=" + email.lowercased()) else { fatalError("emailAvailable(): Missing URL") }
var request = URLRequest(url: url)
request.setValue(K.apiKey, forHTTPHeaderField: "x-api-key")
let dataTask = URLSession.shared.dataTask(with: request) { (data, response, error) in
if let error = error {
print("SignupModel: emailAvailable(): Request error: ", error)
completion(false)
}
guard let response = response as? HTTPURLResponse else { return }
if response.statusCode == 200 {
guard let data = data else {
print("SignupModel: emailAvailable(): Response contained no data!")
return
}
DispatchQueue.main.async {
do {
let decodedResp = try JSONDecoder().decode(EmailAvailResponse.self, from: data)
if (decodedResp.available) {
print("SignupModel: emailAvailable(): Email available, returning true!")
completion(true)
} else {
print("SignupModel: emailAvailable(): Email NOT available, returning false!")
completion(false)
}
} catch let error {
print("SignupModel: emailAvailable(): Error decoding: ", error)
completion(false)
}
}
} else {
print("SignupModel: emailAvailable(): Response status = \(response.statusCode)")
completion(true)
}
}
dataTask.resume()
} else {
print("SignupModel: emailAvailable(): Exiting gracefully because email '" + email + "' is not a valid email!")
completion(true)
}
} else {
print("SignupModel: emailAvailable(): Exiting because email is nil!")
completion(true)
}
}
lazy var emailValidationAvailable: ValidationPublisher = {
return $email
.debounce(for: 0.5, scheduler: RunLoop.main)
.removeDuplicates()
.flatMap { email in
return Future { promise in
self.emailAvailable(email) { available in
promise(.success(available ? .success : .failure(message: "Email is already registered!")))
}
}
}
.dropFirst()
.receive(on: RunLoop.main) // <<—— run on main thread
.eraseToAnyPublisher()
}()
// Combined Publishers
// These are split up by section as CombineLatest only supports
// a maximum of 4 input publishers maximum.
lazy var emailValidation: ValidationPublisher = {
Publishers.CombineLatest3(
emailValidationRequired,
emailValidationMatches,
emailValidationAvailable
).map { v1, v2, v3 in
print("SignupModel: emailValidation: emailValidationRequired: \(v1)")
print("SignupModel: emailValidation: emailValidationMatches: \(v2)")
print("SignupModel: emailValidation: emailValidationAvailable: \(v3)")
return [v1, v2, v3].allSatisfy { $0.isSuccess } ? .success : (
[v1, v2].allSatisfy { $0.isSuccess } ? v3 : (
v1.isSuccess ? v2 : v1
)
)
}.receive(on: RunLoop.main) // <<—— run on main thread
.eraseToAnyPublisher()
}()
lazy var allNamesValidation: ValidationPublisher = {
Publishers.CombineLatest4(
firstNameValidation,
lastNameValidation,
emailValidation,
phoneNumberValidation
).map { v1, v2, v3, v4 in
print("SignupModel: allNamesValidation: firstNameValidation: \(v1)")
print("SignupModel: allNamesValidation: lastNamesValidation: \(v2)")
print("SignupModel: allNamesValidation: emailValidation: \(v3)")
print("SignupModel: allNamesValidation: phoneNumberValidation: \(v4)")
return [v1, v2, v3, v4].allSatisfy { $0.isSuccess } ? .success : .failure(message: "")
}.receive(on: RunLoop.main) // <<—— run on main thread
.eraseToAnyPublisher()
}()
lazy var allPassword: ValidationPublisher = {
Publishers.CombineLatest(
passwordValidation,
confirmPasswordValidation
).map { v1, v2 in
print("SignupModel: allPassword: passwordValidation: \(v1)")
print("SignupModel: allPassword: confirmPasswordValidation: \(v2)")
return [v1, v2].allSatisfy { $0.isSuccess } ? .success : .failure(message: "")
}.receive(on: RunLoop.main) // <<—— run on main thread
.eraseToAnyPublisher()
}()
lazy var allValidationRules: ValidationPublisher = {
Publishers.CombineLatest(
allNamesValidation,
allPassword
).map { v1, v2 in
print("SignupModel: allValidation: allNamesValidation: \(v1)")
print("SignupModel: allValidation: allPassword: \(v2)")
return [v1, v2].allSatisfy { $0.isSuccess } ? .success : .failure(message: "")
}.receive(on: RunLoop.main) // <<—— run on main thread
.eraseToAnyPublisher()
}()
}
And the corresponding View code:
import SwiftUI
import Combine
struct LabelStyle: ViewModifier {
func body(content: Content) -> some View {
content
.font(.body)
.foregroundColor(Color.black)
}
}
extension Text {
func textStyle<Style: ViewModifier>(_ style: Style) -> some View {
ModifiedContent(content: self, modifier: style)
}
}
extension Text {
func label() -> Text {
self
.foregroundColor(Color.black)
.fontWeight(.bold)
}
}
struct CustomTextFieldStyle: TextFieldStyle {
func _body(configuration: TextField<_Label>) -> some View {
configuration
.padding(10)
.background(Color(red: 239.0/255.0, green: 243.0/255.0, blue: 244.0/255.0, opacity: 1.0))
.cornerRadius(5.0)
}
}
struct ActionButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.foregroundColor(.white)
.font(Font.body.bold())
.padding(10)
.padding(.horizontal, 20)
.background(Color.blue)
.cornerRadius(10)
}
}
struct SignupView: View {
let countryList = ["+1 (US)", "+1 (CA)"]
@ObservedObject var model = SignupModel()
@State var isSaveDisabled = true
@State var cardShown = false
@State var cardDismissal = false
var body: some View {
ZStack {
ScrollView {
VStack {
HStack {
CustomTextField(label: "First Name", validation: model.firstNameValidation, textValue: $model.firstName)
CustomTextField(label: "Last Name", validation: model.lastNameValidation, textValue: $model.lastName)
}
CustomTextField(label: "Email", validation: model.emailValidation, forceLowerCase: true, textValue: $model.email)
CustomSecureField(label: "Password", validation: model.passwordValidation, textValue: $model.password)
CustomSecureField(label: "Confirm Password", validation: model.confirmPasswordValidation, textValue: $model.confirmPassword)
Button(action: {
if !self.isSaveDisabled {
print("SignupView: Do Rest Call Here Dude")
}
}) {
Text("Sign Up").frame(width: UIScreen.main.bounds.width - 30, alignment: .center)
}.buttonStyle(CustomButtonStyle(backgroundColor: Color("Color-Accent"), foregroundColor: Color.white, isDisabled: self.isSaveDisabled))
.onReceive(model.allValidationRules) { validation in
print("SignupView: Button onReceiving...")
self.isSaveDisabled = !validation.isSuccess
}
DisclaimerView()
}
}.padding()
.onReceive(model.allValidationRules) { validation in
print("SignupView: Form onReceiving...")
self.isSaveDisabled = !validation.isSuccess
}
}
}
}
struct SignupView_Previews: PreviewProvider {
static var previews: some View {
SignupView()
}
}
Anyone know what I'm missing? Thanks in advance!
Aaron