SwiftUI Custom TextField with UIViewRepresentable Issue with ObservableObject and pushed View
Asked Answered
Z

2

6

I created a UIViewRepresentable to wrap UITextField for SwiftUI, so I can e.g. change the first responder when the enter key was tapped by the user.

This is my UIViewRepresentable (I removed the first responder code to keep it simple)

struct CustomUIKitTextField: UIViewRepresentable {

    @Binding var text: String
    var placeholder: String

    func makeUIView(context: UIViewRepresentableContext<CustomUIKitTextField>) -> UITextField {
        let textField = UITextField(frame: .zero)
        textField.delegate = context.coordinator
        textField.placeholder = placeholder
        return textField
    }

    func updateUIView(_ uiView: UITextField, context: UIViewRepresentableContext<CustomUIKitTextField>) {
        uiView.text = text
        uiView.setContentHuggingPriority(.defaultHigh, for: .vertical)
        uiView.setContentCompressionResistancePriority(.required, for: .vertical)
    }

    func makeCoordinator() -> CustomUIKitTextField.Coordinator {
        Coordinator(parent: self)
    }

    class Coordinator: NSObject, UITextFieldDelegate {
        var parent: CustomUIKitTextField

        init(parent: CustomUIKitTextField) {
            self.parent = parent
        }

        func textFieldDidChangeSelection(_ textField: UITextField) {
            parent.text = textField.text ?? ""
        }        

    }
}

The first screen of the app has a "Sign in with email" button which pushes MailView that displays a CustomUIKitTextField and uses a @Published property of an ObservableObject view model as the TextField's text.

struct MailView: View {

    @ObservedObject var viewModel: MailSignUpViewModel

    var body: some View {
        VStack {
            CustomUIKitTextField(placeholder: self.viewModel.placeholder,
                             text: self.$viewModel.mailAddress)
                .padding(.top, 30)
                .padding(.bottom, 10)

            NavigationLink(destination: UsernameView(viewModel: UsernameSignUpViewModel())) {
                Text("Next")
            }

            Spacer()            
        }
    }
}

Everything works fine until I push another view like MailView, say e.g. UsernameView. It is implemented exactly in the same way, but somehow the CustomUIKitTextField gets an updateUIView call with an empty string once I finish typing.

There is additional weird behavior like when I wrap MailView and UsernameView in another NavigationView, everything works fine. But that is obviously not the way to fix it, since I would have multiple NavigationViews then.

It also works when using an @State property instead of a @Published property inside a view model. But I do not want to use @State since I really want to keep the model code outside the view.

Is there anybody who faced the same issue or a similar one?

Zagreb answered 30/1, 2020 at 16:42 Comment(0)
O
6

I also needed a UITextField representation in SwiftUI, which reacts to every character change, so I went with the answer by binaryPilot84. While the UITextFieldDelegate method textField(_:shouldChangeCharactersIn:replacementString:) is great, it has one caveat -- every time we update the text with this method, the cursor moves to the end of the text. It might be desired behavior. However, it was not for me, so I implemented target-action like so:

public func makeUIView(context: Context) -> UITextField {
    let textField = UITextField()
    
    textField.addTarget(context.coordinator, action: #selector(context.coordinator.textChanged), for: .editingChanged)
    
    return textField
}


public final class Coordinator: NSObject {
    @Binding private var text: String
    
    public init(text: Binding<String>) {
        self._text = text
    }
    
    @objc func textChanged(_ sender: UITextField) {
        guard let text = sender.text else { return }
        self.text = text
    }
}
Ogive answered 6/6, 2022 at 9:1 Comment(4)
Thanks! Had the exact same problem with the cursor moving to the end, your solution worked perfectly!Cabinet
This is the best solution! I was having a problem too with insertion of new characters at the beggining and the middle of the string. Also never came to my mind that I can use the Coordinator which is an NSObject to contain @objc func.Quadriplegia
Yeah, Coordinators bridge basically every old iOS pattern be it target-action or delegation and everything that pertains to them.Ogive
Remember to implement makeCoordinator method inside UIViewRepresentableCristycriswell
T
5

It looks like you’re using the wrong delegate method. textFieldDidChangeSelection will produce some inconsistent results (which is what it sounds like you’re dealing with). Instead, I recommend using textFieldDidEndEditing which will also give you access to the passed in control, but it guarantees that you’re getting the object as it is resigning the first responder. This is important because it means you’re getting the object after the properties have been changed and it’s releasing the responder object.

So, I would change your delegate method as follows:


func textFieldDidEndEditing(_ textField: UITextField) {
    parent.text = textField.text ?? ""
}

For more info, see this link for the textFieldDidEndEditing method: https://developer.apple.com/documentation/uikit/uitextfielddelegate/1619591-textfielddidendediting

And this link for info on the UITextFieldDelegate object: https://developer.apple.com/documentation/uikit/uitextfielddelegate

EDIT

Based on the comment, if you're looking to examine the text everytime it changes by one character, you should implement this delegate function:

func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {

     // User pressed the delete key
     if string.isEmpty {
        // If you want to update your query string here after the delete key is pressed, you can rebuild it like we are below
        return true
     }

     //this will build the full string the user intends so we can use it to build our search
     let currentText = textField.text ?? ""
     let replacementText = (currentText as NSString).replacingCharacters(in: range, with: string)
     // You can set your parent.text here if you like, or you can fire the search function as well

     // When you're done though, return true to indicate that the textField should display the changes from the user

     return true
}
Thibeault answered 30/1, 2020 at 18:37 Comment(2)
Thanks for your help, but I don't think this will help since my view model would not get notified for each character the user is typing. Sure, in some cases this might be enough, but if I wanted to fire a request for a search feature, this solution would not suffice.Zagreb
@sinalco12, I updated my answer with the appropriate delegate function for examining the text as each character changes.Thibeault

© 2022 - 2024 — McMap. All rights reserved.