Как я могу использовать Combine для отслеживания изменений UITextField в классе UIViewRepresentable?

Я создал настраиваемое текстовое поле и хотел бы воспользоваться преимуществами Combine. Чтобы получать уведомления об изменении текста в моем текстовом поле, я сейчас использую настраиваемый модификатор. Он работает хорошо, но я хочу, чтобы этот код мог быть внутри моей структуры CustomTextField.

Моя структура CustomTextField соответствует UIViewRepresentable. Внутри этой структуры есть класс NSObject под названием Coordinator, который соответствует 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

             }
    }
}

My CustomTextField используется в представлении SwiftUI с прикрепленным к нему моим настраиваемым модификатором. Я могу что-то делать, когда есть изменения в текстовом поле. Модификатор также использует Combine. Он отлично работает, но я не хочу, чтобы эта функция была в виде модификатора. Я хочу использовать его в своем классе координатора вместе с моими методами UITextFieldDelegate.

Это мое CustomTextField

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 и хотел бы начать использовать его в новом приложении, которое я создаю. Я действительно думаю, что это правильное решение в данной ситуации, но до сих пор не знаю, как это осуществить. Я был бы очень признателен за пример.

Подводя итог:

Я хочу добавить функцию textFieldDidChange в мой класс Coordinator, и она должна запускаться каждый раз, когда в моем текстовом поле происходит изменение. Он должен использовать Combine.

заранее спасибо


person LondonGuy    schedule 18.10.2019    source источник
comment
В SwiftUI нет UITextFields или методов делегата, поэтому этот вопрос немного сбивает с толку.   -  person matt    schedule 18.10.2019
comment
UIViewRepresentable позволяет нам использовать UIKit в SwiftUI.   -  person LondonGuy    schedule 18.10.2019
comment
Хорошо, пока все хорошо. Я не совсем понимаю, почему вы не можете просто использовать собственный TextField, но я подыграю. Так какова наша цель? Какую проблему мы пытаемся решить?   -  person matt    schedule 18.10.2019
comment
Я хочу, чтобы функция с именем textFieldDidChange запускалась каждый раз при изменении моего текстового поля. Я хочу, чтобы эта функция находилась в классе, соответствующем UITextFieldDelegate. В моем случае это класс координатора для моего пользовательского поля UITextField. Прямо сейчас у меня есть нужная мне функциональность, но в виде модификатора. Собственные текстовые поля SwiftUI весьма ограничены. Не было возможности использовать статьFirstResponder, поэтому мне пришлось создать это настраиваемое текстовое поле.   -  person LondonGuy    schedule 18.10.2019


Ответы (3)


Обновленный ответ

Посмотрев на ваш обновленный вопрос, я понял, что мой исходный ответ требует некоторой очистки. Я свернул модель и координатор в один класс, что, хотя и работало для моего примера, не всегда возможно или желательно. Если модель и координатор не могут быть одинаковыми, вы не можете полагаться на метод didSet свойства модели для обновления textField. Поэтому вместо этого я использую издатель Combine, который мы получаем бесплатно, используя переменную @Published внутри нашей модели.

Ключевые вещи, которые нам нужно сделать, это:

  1. Сделайте единый источник истины, синхронизируя model.text и textField.text

    1. Используйте издателя, предоставленного оболочкой свойств @Published, чтобы обновлять textField.text при model.text изменениях

    2. Используйте метод .addTarget(:action:for) на textField для обновления model.text при изменении textfield.text

  2. Выполните замыкание под названием textDidChange, когда наша модель изменится.

(Я предпочитаю использовать .addTarget для # 1.2, а не проходить 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), который можно использовать взаимозаменяемо с другими представлениями SwiftUI, которые принимают String или Binding<String>.

Модель

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

    }
}
person John M.    schedule 18.10.2019
comment
Привет, Джон, есть ли способ заставить это использовать Combine? Я видел много примеров с использованием addTarget, но я знаю, что это можно сделать с помощью функции публикации / подписки Combine. Если Combine импортирован, он будет работать внутри вашей структуры MyTextField. - person LondonGuy; 18.10.2019
comment
Ах, извините, только что заметил @Publisher и onReceive. Дело в том, что у меня есть большой набор кода, который нужно поместить внутрь onReceive, поэтому я хотел сохранить его внутри класса координатора. Я добавил больше кода в свой пост, чтобы дать вам больше контекста. - person LondonGuy; 18.10.2019
comment
Я отметил этот ответ, потому что чем больше я смотрю на него, тем больше мне хочется реорганизовать свой код. Мне нравится, как вы дали textField собственную модель. Я собираюсь поиграть с этим и посмотреть, сработает ли это для меня. - person LondonGuy; 19.10.2019
comment
Рад, что помог! Глядя на ваш обновленный вопрос, я на самом деле также реорганизовал свой код. Я опубликую обновление, когда вернусь к своему компьютеру, который использует Combine и объединяет (без каламбура) лучшие части обоих наших кодов. - person John M.; 19.10.2019
comment
Взгляните на мой обновленный ответ. Я думаю, что это, вероятно, лучше, и действительно демонстрирует некоторый Combine в действии. - person John M.; 19.10.2019
comment
Это замечательно, но я вижу проблему с размером текстового поля при установке его с существующими значениями. Если строка длиннее, то она умещается на экране, поле вытягивается за пределы экрана, обрезая края поля. Установите значение детали @Published var text на длинную строку, чтобы понять, о чем я говорю. Есть ли способ предотвратить это? - person radicalappdev; 04.01.2020
comment
@John M., можете ли вы посмотреть мой вопрос, пожалуйста, stackoverflow.com/questions/60693733/ - person Learn2Code; 15.03.2020
comment
Спасибо, спасибо, спасибо, спасибо! У меня это работает. - person Mikael Weiss; 28.05.2020

Мне также нужно было использовать UITextField в SwiftUI, поэтому я попробовал следующий код:

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()
    }
  }
}
person mani3    schedule 22.05.2020

Я немного запутался в том, о чем вы спрашиваете, потому что вы говорите о UITextField и SwiftUI.

Что насчет этого? Он не использует UITextField, а вместо этого использует объект TextField SwiftUI.

Этот класс будет уведомлять вас об изменении TextField в вашем ContentView.

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

Если это совершенно не соответствует тому, о чем вы спрашиваете, могу ли я предложить следующую статью, которая может помочь?

< / а>

person fulvio    schedule 18.10.2019
comment
У Apple есть видеоролик WWDC под названием «Интеграция SwiftUI», в котором показано, как можно вернуться к UIKit, когда это необходимо. В моем случае TextField SwiftUI не позволяет мне автоматически становиться первым респондентом. Вот почему я создал настраиваемое текстовое поле, соответствующее UIViewRepresentable. Затем я смог использовать это настраиваемое текстовое поле в моем представлении SwiftUI View. Я упомянул UITextField, потому что это мое настраиваемое текстовое поле, и я упомянул SwiftUI, потому что он используется в представлении SwiftUI, а также потому, что к нему прикреплен мой модификатор. Надеюсь, это прояснило ситуацию. - person LondonGuy; 18.10.2019
comment
@LondGuy: Определенно проясняет ситуацию! Помогла ли статья, на которую я ссылался, как-нибудь? - person fulvio; 18.10.2019
comment
Статья была содержательной, но автор использовал код инициализации для установки начального состояния и обработчик изменений для обновления состояния. Мне нужен пример, который использует Binding и Combine, который, на мой взгляд, больше подходит для SwiftUI. - person LondonGuy; 18.10.2019