Tracking value sources to prevent recursive SwiftUI updates
When wrapping a UITextView or NSTextView for use in SwiftUI, our text view delegate receives each update as the user types, writing this back to a SwiftUI binding. Doing so, however, triggers an update from SwiftUI with the new value. Applying the value unconditionally adds unnecessary text layout work and can move the insertion point to the beginning while the user is typing. To avoid this, wrappers commonly compare the strings before updating the text view, but string comparison can be very costly.
In this post, we'll explore how custom transaction values can tag updates originating in our wrapped text views, allowing us to avoid costly string comparisons in updateUIView(_:context:) and updateNSView(_:context:).
Looking at a typical UIViewRepresentable, we will see something like this:
// UITextViewDelegate callback
func textViewDidChange(_ textView: UITextView) {
text = textView.text
}
// UIViewRepresentable property
@Binding var text: String
func updateUIView(_ textView: UITextView, context: Context) {
// Comparing large strings on every update can be expensive.
guard textView.text != text else { return }
textView.text = text
}
Comparing a potentially large string can become very expensive, but the comparison is necessary. Whenever the editor writes a new value to the binding in textViewDidChange(_:), the @State property or @Observable class that receives the change invalidates its consumers. Our UIViewRepresentable or NSViewRepresentable is one of those consumers through its binding, leading to the respective update method being called with the value we just wrote.
SwiftUI transactions let us record where a property update originated. We wrap a write to a binding or observable property in withTransaction(::_:) and attach a custom value such as an ObjectIdentifier. When SwiftUI sends the update back, the wrapper reads the custom value from the transaction.
func textViewDidChange(_ textView: UITextView) {
withTransaction(
\.originatingTextView,
ObjectIdentifier(textView)
) {
text = textView.text
}
}
func updateUIView(_ textView: UITextView, context: Context) {
// Comparing fixed-size identity values is inexpensive.
guard context.transaction.originatingTextView != ObjectIdentifier(textView) else {
return
}
textView.text = text
}
When defining a custom transaction value, much like a custom environment value, we can use SwiftUI's @Entry macro to declare the value and its default directly in an extension on Transaction.
extension Transaction {
@Entry var originatingTextView: ObjectIdentifier? = nil
}
This approach is not limited to wrapped text views. It is also useful for any wrapped UIView or NSView with a gesture that feeds up to SwiftUI. Marking the gesture-driven writes prevents the same values from returning on the next run loop cycle and overwriting further gesture updates.



