Search code examples
swiftswiftuibindingcombine

Creating an Email Input Field in SwiftUI


I'm trying to create an email input field in SwiftUI 2 that allows only certain characters to be entered. The code here is based on a piece of code from https://stackoverflow.com/a/57829567/356105.

The code itself works but since this is a reusable view component I want to provide a text property from a parent view's view model to the EmailTextField which is updated when the text input changes ...

EmailTextField view:

import SwiftUI
import Combine

struct EmailTextField: View {
    private class EmailTextFieldViewModel: ObservableObject {
        @Published var text = String.Empty
        private var subCancellable: AnyCancellable!
        private var validCharSet = CharacterSet(charactersIn: "1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ._-+$!~&=#[]@")

        init() {
            subCancellable = $text.sink {
                value in

                /* Check if the new string contains any invalid characters. */
                if value.rangeOfCharacter(from: self.validCharSet.inverted) != nil {
                    /* Clean the string (do this on the main thread to avoid overlapping with the current ContentView update cycle). */
                    DispatchQueue.main.async {
                        self.text = String(self.text.unicodeScalars.filter {
                            self.validCharSet.contains($0)
                        })
                    }
                }
            }
        }

        deinit {
            subCancellable.cancel()
        }
    }

    @ObservedObject private var viewModel = EmailTextFieldViewModel()
    private let placeHolder: String

    var body: some View {
        TextField(placeHolder, text: $viewModel.text)
            .keyboardType(.emailAddress)
            .autocapitalization(.none)
            .disableAutocorrection(true)
    }

    init(_ placeHolder: String = .Empty, text: Binding<String>) {
        self.placeHolder = placeHolder
    }
}

In the parent view I'm trying this:

var body: some View {
    VStack {
        EmailTextField("Email", text: $viewModel.email)
            .onChange(of: viewModel.email, perform: onEmailInputChanged)
    }
}

private func onEmailInputChanged(changedEmail: String) {
    // Nothing happens here!
    print("\(changedEmail)")
}

How do I need to change the EmailTextField code to be able to bind the text variable in its view model to the text: Binding<String> argument in its constructor?


Solution

  • Here is an alternative to your (somewhat complicated) code. I think it is simpler, provides a reusable EmailInputView and calls to onEmailInputChanged, and works well for me:

    import SwiftUI
    import Combine
    
    class ViewModel: ObservableObject {
        @Published var email = ""
    }
    
    struct ContentView: View {
        @StateObject var viewModel = ViewModel()
    
        var body: some View {
            VStack {
                EmailInputView(placeHolder: "Email", txt: $viewModel.email)
                    .onChange(of: viewModel.email, perform: onEmailInputChanged)
            }
        }
        
        private func onEmailInputChanged(changedEmail: String) {
            print("-----> in onEmailInputChanged: \(changedEmail) ")
        }
    }
    
    struct EmailInputView: View {
        var placeHolder: String = ""
        @Binding var txt: String
        
        var body: some View {
            TextField(placeHolder, text: $txt)
                .keyboardType(.emailAddress)
                .onReceive(Just(txt)) { newValue in
                    let validString = newValue.filter { "1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ._-+$!~&=#[]@".contains($0) }
                    if validString != newValue {
                        self.txt = validString
                    }
            }
        }
    }