【问题标题】:How can I use Combine to track UITextField changes in a UIViewRepresentable class?如何使用 Combine 跟踪 UIViewRepresentable 类中的 UITextField 更改?
【发布时间】:2020-02-14 23:27:06
【问题描述】:

我创建了一个自定义文本字段,我想利用 Combine。为了在我的文本字段中的文本更改时收到通知,我目前使用自定义修饰符。它运行良好,但我希望此代码可以在我的 CustomTextField 结构中。

我的 CustomTextField 结构符合 UIViewRepresentable。在这个结构中,有一个名为 Coordinator 的 NSObject 类,它符合 UITextFieldDelegate。

我已经在使用其他 UITextField 委托方法,但找不到与我的自定义修饰符完全一样的方法。有些方法很接近,但并不完全按照我想要的方式行事。无论如何,我觉得最好将这个新的自定义 textFieldDidChange 方法放在 Coordinator 类中。

这是我的自定义修饰符

private let textFieldDidChange = NotificationCenter.default
    .publisher(for: UITextField.textDidChangeNotification)
    .map { $0.object as! UITextField}


struct CustomModifer: ViewModifier {

     func body(content: Content) -> some View {
         content
             .tag(1)
             .onReceive(textFieldDidChange) { data in

                //do something

             }
    }
}

我的 CustomTextField 用于 SwiftUI 视图,并附加了我的自定义修饰符。当文本字段发生变化时,我可以做一些事情。修改器也在使用组合。它工作得很好,但我不希望这个功能以修饰符的形式出现。我想在我的 Coordinator 类中使用它,以及我的 UITextFieldDelegate 方法。

这是我的自定义文本字段

struct CustomTextField: UIViewRepresentable {

    var isFirstResponder: Bool = false
    @EnvironmentObject var authenticationViewModel: AuthenticationViewModel

    func makeCoordinator() -> Coordinator {
        return Coordinator(authenticationViewModel: self._authenticationViewModel)
    }

    class Coordinator: NSObject, UITextFieldDelegate {

        var didBecomeFirstResponder = false
        @EnvironmentObject var authenticationViewModel: AuthenticationViewModel

        init(authenticationViewModel: EnvironmentObject<AuthenticationViewModel>)
        {
            self._authenticationViewModel = authenticationViewModel
        }

        // Limit the amount of characters that can be typed in the field
        func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {

            let currentText = textField.text ?? ""
            guard let stringRange = Range(range, in: currentText) else { return false }
            let updatedText = currentText.replacingCharacters(in: stringRange, with: string)
            return updatedText.count <= 14
        }

        /* I want to put my textFieldDidChange method right here */

        /* * * * * * * * * * * * * * * * * * * * * * * * * * * * */


        func textFieldDidEndEditing(_ textField: UITextField) {

            textField.resignFirstResponder()
            textField.endEditing(true)
        }

    }

    func makeUIView(context: Context) -> UITextField {

        let textField = UITextField()
        textField.delegate = context.coordinator
        textField.placeholder = context.coordinator.authenticationViewModel.placeholder
        textField.font = .systemFont(ofSize: 33, weight: .bold)
        textField.keyboardType = .numberPad

        return textField
    }

    func updateUIView(_ uiView: UITextField, context: Context) {

        let textField = uiView
        textField.text = self.authenticationViewModel.text
    }
}

struct CustomTextField_Previews: PreviewProvider {

    static var previews: some View {
        CustomTextField()
            .previewLayout(.fixed(width: 270, height: 55))
            .previewDisplayName("Custom Textfield")
            .previewDevice(.none)
    }
}

我一直在观看有关 Combine 的视频,我想开始在我正在构建的新应用中使用它。我真的认为在这种情况下使用它是正确的,但仍然不太确定如何实现它。我真的很感激一个例子。

总结一下:

我想在我的 Coordinator 类中添加一个名为 textFieldDidChange 的函数,并且每次我的文本字段发生更改时都应该触发它。它必须使用 Combine。

提前致谢

【问题讨论】:

  • 在 SwiftUI 中没有 UITextFields 或委托方法,所以这个问题有点令人困惑。
  • UIViewRepresentable 允许我们在 SwiftUI 中利用 UIKit。
  • 好的,到目前为止一切顺利。我不太明白你为什么不只使用本机 TextField,但我会一起玩。那么我们的目标到底是什么?我们要解决什么问题?
  • 我希望每当我的文本字段发生更改时触发一个名为 textFieldDidChange 的函数。我希望这个函数位于符合 UITextFieldDelegate 的类中。在我的例子中,它是我的自定义 UITextField 的 Coordinator 类。现在,我有我想要的功能,但是以修饰符的形式。原生 SwiftUI 文本字段非常有限。没有办法使用 becomeFirstResponder,所以我不得不创建这个自定义文本字段。

标签: swift uitextfield swiftui uitextfielddelegate combine


【解决方案1】:

我还需要在 SwiftUI 中使用 UITextField,所以我尝试了以下代码:

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()
    }
  }
}

【讨论】:

    【解决方案2】:

    更新答案

    查看您更新的问题后,我意识到我的原始答案可能需要一些清理。我已将模型和协调器合并为一个类,虽然它适用于我的示例,但并不总是可行或可取的。如果模型和协调器不能相同,则不能依赖模型属性的 didSet 方法来更新 textField。因此,我正在使用我们免费获得的 Combine 发布者,在我们的模型中使用 @Published 变量。

    我们需要做的关键事情是:

    1. 通过保持 model.texttextField.text 同步来建立单一的事实来源

      1. 使用@Published 属性包装器提供的发布者在model.text 更改时更新textField.text

      2. textField 上使用.addTarget(:action:for) 方法在textfield.text 更改时更新model.text

    2. 当我们的模型发生变化时,执行一个名为 textDidChange 的闭包。

    (我更喜欢在#1.2 中使用.addTarget 而不是通过NotificationCenter,因为它的代码更少,可以立即运行,并且为 UIKit 的用户所熟知)。

    这里是一个更新的例子,展示了这个工作:

    演示

    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()
        }
    }
    

    型号

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

    查看

    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
            }
        }
    }
    

    原答案

    听起来你有几个目标:

    1. 使用UITextField 这样您就可以使用.becomeFirstResponder() 之类的功能
    2. 文本更改时执行操作
    3. 通知其他 SwiftUI 视图文本已更改

    我认为您可以使用单个模型类和UIViewRepresentable 结构来满足所有这些要求。我以这种方式构造代码的原因是为了让您拥有单一的事实来源 (model.text),它可以与其他采用 StringBinding&lt;String&gt; 的 SwiftUI 视图互换使用。

    型号

    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
        }
    }
    

    查看

    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
        }
    }
    

    如果你不需要上面的#3,你可以替换

    @ObservedObject var model: MyTextFieldModel
    

    @ObservedObject private var model = MyTextFieldModel()
    

    演示

    这是一个演示视图,展示了所有这些工作

    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)\"")
            }
    
        }
    }
    

    【讨论】:

    • 嗨约翰,有没有办法让这个利用结合?我看到了很多使用 addTarget 的示例,但我知道这可以使用 Combine 的发布/订阅功能来完成。如果导入了 Combine,那么它将在您的 MyTextField 结构中工作。
    • 抱歉,刚刚注意到@Publisher 和 onReceive。问题是,我有一大堆代码要放在 onReceive 中,这就是为什么我想将它保留在协调器类中。我在帖子中添加了更多代码,以便为您提供更多上下文。
    • 我标记了这个答案,因为我越看它,它就越让我想重新组织我的代码。我喜欢你如何给 textField 它自己的模型。我会玩弄它,看看它是否适合我。
    • 很高兴有帮助!查看您更新的问题实际上也让我重新组织了我的代码。当我回到使用组合的计算机时,我将发布更新,并结合(不是双关语)我们两个代码的最佳部分。
    • 看看我更新的答案。我认为这可能会更好,并且确实显示了一些联合行动。
    【解决方案3】:

    我对你的问题有点困惑,因为你在谈论 UITextField 和 SwiftUI。

    这样的事情呢?它不使用UITextField,而是使用SwiftUI 的TextField 对象。

    每当您的ContentView 中的TextField 发生更改时,此类都会通知您。

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

    确保您在修饰符类上使用@ObservedObject,您将能够看到更改。

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

    如果这与您的要求完全不同,那么我可以建议以下文章,这可能会有所帮助吗?

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

    【讨论】:

    • Apple 有一个名为 Integrating SwiftUI 的 WWDC 视频,其中展示了我们如何在需要时回退到 UIKit。就我而言,SwiftUI 提供的 TextField 不允许我自动成为第一响应者。这就是为什么我创建了一个符合 UIViewRepresentable 的自定义文本字段。然后我可以在我的 SwiftUI 视图中使用这个自定义文本字段。我提到 UITextField 是因为那是我的自定义文本字段,我提到 SwiftUI 是因为它在 SwiftUI 视图中使用,还因为我的修饰符附加到它上面。我希望这已经解决了问题。
    • @LondGuy:绝对可以解决问题!我链接到的文章有任何帮助吗?
    • 这篇文章很有见地,但作者使用初始化代码设置初始状态并使用更改处理程序更新状态。我正在寻找一个使用 Binding 和 Combine 的示例,在我看来这更适合 SwiftUI。
    猜你喜欢
    • 1970-01-01
    • 2021-12-19
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2015-02-27
    相关资源
    最近更新 更多