1

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

LuLuGaGa
  • 13,089
  • 6
  • 49
  • 57
Ahmed
  • 1,229
  • 2
  • 20
  • 45

3 Answers3

1

Instead of computable use stored properties for your publishers (to keep them alive), like

private lazy var usernamevalidation: AnyPublisher<(username:String, isValid: Bool), Never> = {
    return $userText
        .dropFirst()
        .map{(username:$0, isValid: !$0.isEmpty)}
        .eraseToAnyPublisher()
}()
Asperi
  • 228,894
  • 20
  • 464
  • 690
  • still not working particularly usernamevalidated publisher it sometimes show the error sometimes not – Ahmed Jun 30 '21 at 10:23
  • I found my problem after dropfirst in each publisher I need to receive these on the main thread. Adding the following .receive(on: DispatchQueue.main) solved my problem – Ahmed Jun 30 '21 at 11:05
1

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.

LuLuGaGa
  • 13,089
  • 6
  • 49
  • 57
  • this works fine but why when making publishers private it doesnot work – Ahmed Jun 30 '21 at 10:32
  • UserText publisher cannot be private because the view needs to both read and write to it. UserTextError can be private(set) because the view needs to be able to read it, but doesn't need to write to it. – LuLuGaGa Jun 30 '21 at 12:57
0

Previous answer (see edits if wanted) was incorrect because then the Emptyusername error would be overwritten, which is not what we needed (my mistake).

Turns out, the issue is iOS 14 updating the UI! The fix, which I've had to use before for TextField, looks a bit unusual but does the job.

Just add this in the view body:

var body: some View {
    let _ = userViewModel.userTextError

    /* Rest of view as before */
}
George
  • 25,988
  • 10
  • 79
  • 133
  • The problem still persists although what you say make sense. The problem is the error is shown sometimes and sometimes not. I dont know if this is a bug in xcode or what but it painful – Ahmed Jun 30 '21 at 01:11
  • @Ahmed After this change, it worked for me. Tested with Xcode 13b2 'Version 13.0 beta (13A5155e)' – George Jun 30 '21 at 01:17
  • let me check my xcode version. Can you try multiple times because it works sometimes and sometimes not. like try 6-10 times and can you confirm with me – Ahmed Jun 30 '21 at 01:18
  • @Ahmed Yep, tried again multiple times and it's still working as intended. It may be due to odd view updates from your outer views. I'm not sure as I don't have other context and I am assuming, like me, you just have `ContentView` with `UserView()` as the body. Also I did have to rename in `UserView` 2 variables so they were correct, assuming that is also a typo and you aren't accidentally using different properties. – George Jun 30 '21 at 01:27
  • true ContentView has UserView() as body. I will try to update xcode and check. One final question did you try on simulator or real device – Ahmed Jun 30 '21 at 01:28
  • 1
    @Ahmed Aha! Mine works on simulator & real device on iOS 15, but not simulator on iOS 14. – George Jun 30 '21 at 01:31
  • Good Now we know it is ios 14 – Ahmed Jun 30 '21 at 01:31
  • 1
    @Ahmed I'll be honest I'm not sure why that's different, but I'll leave you to investigate. Looks like it's an iOS version issue, that's all I know so far – George Jun 30 '21 at 01:32
  • @Ahmed Found the reason! Didn't notice but my combine code was overwriting the first error, your original code was fine. As you said, the problem was just the view updates. Now it works on iOS 14 & iOS 15 though! – George Jun 30 '21 at 01:40