How can I use Combine to track UITextField changes in a UIViewRepresentable class?
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:
Make a single source of truth by keeping
model.text
andtextField.text
in syncUse the publisher provided by the
@Published
property wrapper to updatetextField.text
whenmodel.text
changesUse the
.addTarget(:action:for)
method ontextField
to updatemodel.text
whentextfield.text
changes
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:
- Use a
UITextField
so you can use functionality like.becomeFirstResponder()
- Perform an action when the text changes
- 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)\"")
}
}
}
UITextField not updating to change in ObservableObject (SwiftUI)
You should not create UITextField
as property, because AddressTextField
struct can be recreated during parent update. The makeUIView
is exactly the place to create UI-instances, the representable is handling its reference on your behalf.
So here is fixed variant. Tested with Xcode 11.4 / iOS 13.4
struct AddressTextField: UIViewRepresentable {
var commit: () -> Void = {}
@EnvironmentObject var userData: UserDataModel
func makeUIView(context: UIViewRepresentableContext<AddressTextField>) -> UITextField {
let textField = UITextField(frame: .zero)
textField.text = self.userData.currentDisplayedAddress
textField.delegate = context.coordinator
return textField
}
func updateUIView(_ textField: UITextField, context: UIViewRepresentableContext<AddressTextField>) {
if textField.text != self.userData.currentDisplayedAddress {
textField.text = self.userData.currentDisplayedAddress
}
}
func makeCoordinator() -> Coordinator { Coordinator(self) }
class Coordinator: NSObject, UITextFieldDelegate {
var addressTextField: AddressTextField
init(_ addressTextField: AddressTextField) {
self.addressTextField = addressTextField
}
func textFieldShouldReturn(_ textField: UITextField) -> Bool { //delegate method
textField.resignFirstResponder()
addressTextField.userData.currentDisplayedAddress = textField.text ?? String()
addressTextField.commit()
return true
}
}
}
textFieldDidChangeSelection: Modifying state during view update, this will cause undefined behavior
Try to perform modification asynchronously:
func textFieldDidChangeSelection(_ textField: UITextField) {
DispatchQueue.main.async {
self.parent.text = self.textField.text ?? ""
}
}
Related Topics
Localizewithformat and Variadic Arguments in Swift
How to Create Several Cached Uicolor
Arkit - Viewport Size VS Real Screen Resolution
Swift Error Handling for Methods That Do Not Throw
Get an Array of Dates of the Current Week Starting on Monday
Convert a Custom Object to Data to Be Saved in Nsuserdefauts
Uicollectionviewlayout Not Working with Uiimage in Swift 5 and Xcode 11
Implementing a Function with a Default Parameter Defined in a Protocol
Sprite Kit Physicsbody.Resting Behavior
How to Detect Absent Network Connection When Setting Firestore Document
How to Rewrite Swift ++ Operator in : Ternary Operator
Swift 4.0 Mapview Running Slow
Creating a Countableclosedrange<Character>
Non-Strong References Not Working in Playground
Rotate an Object in Its Direction of Motion
iOS 8 Beta Today Extension Widget Not Showing in a Swift App