swiftswiftuicombinepublisher

Why my views are not Rendered in SwiftUI even though the Publisher emits a value


I am having a username textField in swiftUI. I am trying to validate input with the help of publishers.

Here is my code:

View

struct UserView: View {
    @StateObject private var userViewModel = UserViewModel()

    init(){
        UITextField.appearance().semanticContentAttribute = .forceRightToLeft
        UITextField.appearance().keyboardAppearance = .dark
    
    }

    var body: some View {
        SecureField("", text: $userViewModel.passwordText)
        Text(userViewModel.passwordError).foregroundColor(.red)
            .frame(width: 264, alignment: .trailing)
    }
}

The View Model

ViewModel

final class UserViewModel: ObservableObject {

    private var cancellables = Set<AnyCancellable>()
    @Published var userText: String = ""
    @Published var userTextError = ""

    private var usernamevalidation: AnyPublisher<(username:String, isValid: Bool), Never> {
        return $userText
            .dropFirst()
            .map{(username:$0, isValid: !$0.isEmpty)}
            .eraseToAnyPublisher()
    }

    private var usernamevalidated: AnyPublisher<Bool,Never> {
        return usernamevalidation
            .filter{$0.isValid}
            .map{$0.username.isValidUserName()}
            .eraseToAnyPublisher()
    }


    init(){
        usernamevalidation.receive(on: RunLoop.main)
            .map{$0.isValid ? "": "Emptyusername "}
            .assign(to: \.userTextError, on: self)
            .store(in: &cancellables)
        usernamevalidated.receive(on: RunLoop.main)
            .map{$0 ? "" : "wrong username "}
            .assign(to: \.userTextError, on: self)
            .store(in: &cancellables)
    }
}

Extension

 extension String {

     func isValidUserName() -> Bool {
         let usernameRegex = "^[a-zA-Z0-9_-]*$"
         let usernamepred = NSPredicate(format:"SELF MATCHES %@", usernameRegex)
         return usernamepred.evaluate(with: self)
   }
}

In the usernamevalidated in the init() block in the ViewModel I am assigning the error to userTextError property which should be reflected in the textview. This should happens if a special character such as @ or % .. etc are entered. What happens is that sometimes the error appears in red and other no even though I try to print value of string after map operator i can see the string in printing fine. It is just the error is sometimes reflected in the view and sometimes not. Am I missing something or doing something fundamentally wrong


Solution

  • The problem is that both usernamevalidation and usernamevalidated are computed properties. Making them stored will solve the problem, but you can also simplify the view model by observing changes to userText, validating them and assigning to userTextError like so:

    final class UserViewModel: ObservableObject {
        
        @Published var userText: String = ""
        @Published private(set) var userTextError = ""
        
        init() {
            $userText
                .dropFirst()
                .map { username in
                    guard !username.isEmpty else {
                        return "Username is empty"
                    }
                    guard username.isValidUserName() else {
                        return "Username is invalid"
                    }
                    return ""
                }
                .receive(on: RunLoop.main)
                .assign(to: &$userTextError)
        } 
    }
    

    It's also worth mentioning that replacing .assign(to: \.userTextError, on: self) with .assign(to: &$userTextError) gets rid of memory leak and means you do need to store it in cancellables any more.