How can I use Combine to track UITextField changes in a UIViewRepresentable class?

前端 未结 3 2019
说谎
说谎 2020-12-15 14:37

I have created a custom text field and I\'d like to take advantage of Combine. In order to be notified whenever text changes in my text field, I currently use a custom modif

相关标签:
3条回答
  • 2020-12-15 14:52

    I also needed to use a UITextField in SwiftUI, so I tried the following code:

    struct MyTextField: UIViewRepresentable {
      private var placeholder: String
      @Binding private var text: String
      private var textField = UITextField()
    
      init(_ placeholder: String, text: Binding<String>) {
        self.placeholder = placeholder
        self._text = text
      }
    
      func makeCoordinator() -> Coordinator {
        Coordinator(textField: self.textField, text: self._text)
      }
    
      func makeUIView(context: Context) -> UITextField {
        textField.placeholder = self.placeholder
        textField.font = UIFont.systemFont(ofSize: 20)
        return textField
      }
    
      func updateUIView(_ uiView: UITextField, context: Context) {
      }
    
      class Coordinator: NSObject {
        private var dispose = Set<AnyCancellable>()
        @Binding var text: String
    
        init(textField: UITextField, text: Binding<String>) {
          self._text = text
          super.init()
    
          NotificationCenter.default
            .publisher(for: UITextField.textDidChangeNotification, object: textField)
            .compactMap { $0.object as? UITextField }
            .compactMap { $0.text }
            .receive(on: RunLoop.main)
            .assign(to: \.text, on: self)
            .store(in: &dispose)
        }
      }
    }
    
    struct ContentView: View {
      @State var text: String = ""
    
      var body: some View {
        VStack {
          MyTextField("placeholder", text: self.$text).padding()
          Text(self.text).foregroundColor(.red).padding()
        }
      }
    }
    
    0 讨论(0)
  • 2020-12-15 14:56

    Updated Answer

    After looking at your updated question, I realized my original answer could use some cleaning up. I had collapsed the model and coordinator into one class, which, while it worked for my example, is not always feasible or desirable. If the model and coordinator cannot be the same, then you can't rely on the model property's didSet method to update the textField. So instead, I'm making use of the Combine publisher we get for free using a @Published variable inside our model.

    The key things we need to do are to:

    1. Make a single source of truth by keeping model.text and textField.text in sync

      1. Use the publisher provided by the @Published property wrapper to update textField.text when model.text changes

      2. Use the .addTarget(:action:for) method on textField to update model.text when textfield.text changes

    2. Execute a closure called textDidChange when our model changes.

    (I prefer using .addTarget for #1.2 rather than going through NotificationCenter, as it's less code, worked immediately, and it is well known to users of UIKit).

    Here is an updated example that shows this working:

    Demo

    import SwiftUI
    import Combine
    
    // Example view showing that `model.text` and `textField.text`
    //     stay in sync with one another
    struct CustomTextFieldDemo: View {
        @ObservedObject var model = Model()
    
        var body: some View {
            VStack {
                // The model's text can be used as a property
                Text("The text is \"\(model.text)\"")
                // or as a binding,
                TextField(model.placeholder, text: $model.text)
                    .disableAutocorrection(true)
                    .padding()
                    .border(Color.black)
                // or the model itself can be passed to a CustomTextField
                CustomTextField().environmentObject(model)
                    .padding()
                    .border(Color.black)
            }
            .frame(height: 100)
            .padding()
        }
    }
    

    Model

    class Model: ObservableObject {
        @Published var text = ""
        var placeholder = "Placeholder"
    }
    

    View

    struct CustomTextField: UIViewRepresentable {
        @EnvironmentObject var model: Model
    
        func makeCoordinator() -> CustomTextField.Coordinator {
            Coordinator(model: model)
        }
    
        func makeUIView(context: UIViewRepresentableContext<CustomTextField>) -> UITextField {
            let textField = UITextField()
    
            // Set the coordinator as the textField's delegate
            textField.delegate = context.coordinator
    
            // Set up textField's properties
            textField.text = context.coordinator.model.text
            textField.placeholder = context.coordinator.model.placeholder
            textField.autocorrectionType = .no
    
            // Update model.text when textField.text is changed
            textField.addTarget(context.coordinator,
                                action: #selector(context.coordinator.textFieldDidChange),
                                for: .editingChanged)
    
            // Update textField.text when model.text is changed
            // The map step is there because .assign(to:on:) complains
            //     if you try to assign a String to textField.text, which is a String?
            // Note that assigning textField.text with .assign(to:on:)
            //     does NOT trigger a UITextField.Event.editingChanged
            let sub = context.coordinator.model.$text.receive(on: RunLoop.main)
                             .map { Optional($0) }
                             .assign(to: \UITextField.text, on: textField)
            context.coordinator.subscribers.append(sub)
    
            // Become first responder
            textField.becomeFirstResponder()
    
            return textField
        }
    
        func updateUIView(_ textField: UITextField, context: UIViewRepresentableContext<CustomTextField>) {
            // If something needs to happen when the view updates
        }
    }
    

    View.Coordinator

    extension CustomTextField {
        class Coordinator: NSObject, UITextFieldDelegate, ObservableObject {
            @ObservedObject var model: Model
            var subscribers: [AnyCancellable] = []
    
            // Make subscriber which runs textDidChange closure whenever model.text changes
            init(model: Model) {
                self.model = model
                let sub = model.$text.receive(on: RunLoop.main).sink(receiveValue: textDidChange)
                subscribers.append(sub)
            }
    
            // Cancel subscribers when Coordinator is deinitialized
            deinit {
                for sub in subscribers {
                    sub.cancel()
                }
            }
    
            // Any code that needs to be run when model.text changes
            var textDidChange: (String) -> Void = { text in
                print("Text changed to \"\(text)\"")
                // * * * * * * * * * * //
                // Put your code here  //
                // * * * * * * * * * * //
            }
    
            // Update model.text when textField.text is changed
            @objc func textFieldDidChange(_ textField: UITextField) {
                model.text = textField.text ?? ""
            }
    
            // Example UITextFieldDelegate method
            func textFieldShouldReturn(_ textField: UITextField) -> Bool {
                textField.resignFirstResponder()
                return true
            }
        }
    }
    

    Original Answer

    It sounds like you have a few goals:

    1. Use a UITextField so you can use functionality like .becomeFirstResponder()
    2. Perform an action when the text changes
    3. Notify other SwiftUI views that the text has changed

    I think you can satisfy all these using a single model class, and the UIViewRepresentable struct. The reason I structured the code this way is so that you have a single source of truth (model.text), which can be used interchangeably with other SwiftUI views that take a String or Binding<String>.

    Model

    class MyTextFieldModel: NSObject, UITextFieldDelegate, ObservableObject {
        // Must be weak, so that we don't have a strong reference cycle
        weak var textField: UITextField?
    
        // The @Published property wrapper just makes a Combine Publisher for the text
        @Published var text: String = "" {
            // If the model's text property changes, update the UITextField
            didSet {
                textField?.text = text
            }
        }
    
        // If the UITextField's text property changes, update the model
        @objc func textFieldDidChange() {
            text = textField?.text ?? ""
    
            // Put your code that needs to run on text change here
            print("Text changed to \"\(text)\"")
        }
    
        // Example UITextFieldDelegate method
        func textFieldShouldReturn(_ textField: UITextField) -> Bool {
            textField.resignFirstResponder()
            return true
        }
    }
    

    View

    struct MyTextField: UIViewRepresentable {
        @ObservedObject var model: MyTextFieldModel
    
        func makeUIView(context: UIViewRepresentableContext<MyTextField>) -> UITextField {
            let textField = UITextField()
    
            // Give the model a reference to textField
            model.textField = textField
    
            // Set the model as the textField's delegate
            textField.delegate = model
    
            // TextField setup
            textField.text = model.text
            textField.placeholder = "Type in this UITextField"
    
            // Call the model's textFieldDidChange() method on change
            textField.addTarget(model, action: #selector(model.textFieldDidChange), for: .editingChanged)
    
            // Become first responder
            textField.becomeFirstResponder()
    
            return textField
        }
    
        func updateUIView(_ textField: UITextField, context: UIViewRepresentableContext<MyTextField>) {
            // If something needs to happen when the view updates
        }
    }
    

    If you don't need #3 above, you could replace

    @ObservedObject var model: MyTextFieldModel
    

    with

    @ObservedObject private var model = MyTextFieldModel()
    

    Demo

    Here's a demo view showing all this working

    struct MyTextFieldDemo: View {
        @ObservedObject var model = MyTextFieldModel()
    
        var body: some View {
            VStack {
                // The model's text can be used as a property
                Text("The text is \"\(model.text)\"")
                // or as a binding,
                TextField("Type in this TextField", text: $model.text)
                    .padding()
                    .border(Color.black)
                // but the model itself should only be used for one wrapped UITextField
                MyTextField(model: model)
                    .padding()
                    .border(Color.black)
            }
            .frame(height: 100)
            // Any view can subscribe to the model's text publisher
            .onReceive(model.$text) { text in
                    print("I received the text \"\(text)\"")
            }
    
        }
    }
    
    0 讨论(0)
  • 2020-12-15 14:59

    I'm a little confused with what you're asking because you're talking about UITextField and SwiftUI.

    What about something like this? It doesn't use UITextField instead it uses SwiftUI's TextField object instead.

    This class will notify you whenever there's a change to the TextField in your ContentView.

    class CustomModifier: ObservableObject {
        var observedValue: String = "" {
            willSet(observedValue) {
                print(observedValue)
            }
        }
    }
    

    Ensure that you use @ObservedObject on your modifier class and you'll be able to see the changes.

    struct ContentView: View {
        @ObservedObject var modifier = CustomModifier()
    
        var body: some View {
            TextField("Input:", text: $modifier.observedValue)
        }
    }
    

    If this is completely off track with what you're asking then can I suggest the following article, which may help?

    https://medium.com/@valv0/textfield-and-uiviewrepresentable-46a8d3ec48e2

    0 讨论(0)
提交回复
热议问题