UIViewRepresentable done properly
Wrapping a UIKit view for SwiftUI is four lines of protocol and one genuinely dangerous mistake. Here is the shape that works, and the failure mode that took me a day.
The three parts
struct TextEditor: UIViewRepresentable {
@Binding var text: String
func makeUIView(context: Context) -> UITextView {
let textView = UITextView()
textView.delegate = context.coordinator
textView.font = .preferredFont(forTextStyle: .body)
return textView
}
func updateUIView(_ textView: UITextView, context: Context) {
if textView.text != text { // the guard that matters
textView.text = text
}
}
func makeCoordinator() -> Coordinator {
Coordinator(text: $text)
}
final class Coordinator: NSObject, UITextViewDelegate {
@Binding var text: String
init(text: Binding<String>) {
_text = text
}
func textViewDidChange(_ textView: UITextView) {
text = textView.text
}
}
}
makeUIViewruns once. One-time configuration goes here.updateUIViewruns on every SwiftUI update. Treat it as a hot path.makeCoordinatorcreates the object that owns delegates, targets and closures — anything UIKit needs a persistent reference for.
The infinite loop
That if textView.text != text guard is not defensive tidiness. Without it:
- User types →
textViewDidChange→textchanges - SwiftUI re-renders →
updateUIViewruns → setstextView.text - Setting
texton aUITextViewcan fire the delegate again → back to 1
The symptom is either a frozen app, a “Modifying state during view update” runtime warning, or —
worst — the cursor jumping to the end of the text on every keystroke, because assigning text
resets the selection.
Always compare before assigning in updateUIView. Every property, every time. It is the single
most important rule in this protocol.
Warning
Even with the guard, assigning text moves the cursor. If the values genuinely differ but the
user is mid-edit, save and restore the selection:
let selection = textView.selectedRange
textView.text = text
textView.selectedRange = selectionThe Coordinator is the persistent object
Your struct is recreated constantly — it is a SwiftUI view. The Coordinator is created once per
view identity and survives, which makes it the right home for anything with a lifetime:
final class Coordinator: NSObject, MKMapViewDelegate {
var parent: MapView
private var cancellables: Set<AnyCancellable> = []
private var lastRegion: MKCoordinateRegion?
init(_ parent: MapView) {
self.parent = parent
}
}
Note var parent reassigned in updateUIView — because the struct is new each time, a coordinator
holding the old struct is holding stale bindings and stale closures. This is the second most common
bug in this protocol:
func updateUIView(_ mapView: MKMapView, context: Context) {
context.coordinator.parent = self // refresh the captured struct
…
}
Sizing
By default a representable is greedy — it takes all the space offered. sizeThatFits gives you
control:
func sizeThatFits(_ proposal: ProposedViewSize,
uiView: UITextView,
context: Context) -> CGSize? {
let width = proposal.width ?? UIView.layoutFittingCompressedSize.width
let size = uiView.sizeThatFits(CGSize(width: width, height: .greatestFiniteMagnitude))
return CGSize(width: width, height: size.height)
}
Returning nil means “use the default behaviour”. This method is what makes a wrapped UILabel
behave like a Text instead of filling the screen.
Teardown
dismantleUIView is static and runs when the view is removed:
static func dismantleUIView(_ uiView: MKMapView, coordinator: Coordinator) {
uiView.delegate = nil
coordinator.stopUpdates()
}
Use it for anything that will otherwise keep running — a timer, a location manager, an observer. It is static because the struct is long gone by the time it is called, which is a good reminder of what these types actually are.
When not to wrap
Two cases where the wrapper is the wrong answer.
A whole screen. Wrapping a UIViewController for a full screen means SwiftUI navigation,
lifecycle and state are fighting UIKit’s. UIHostingController in the other direction is usually
cleaner — keep the screen in UIKit and embed SwiftUI views inside it.
Something SwiftUI now has. TextEditor, Map, PhotosPicker, ShareLink and WebView all
exist natively. Wrapping the UIKit equivalent for a feature the native one covers is inheriting a
maintenance burden for no benefit.
The good cases are the ones with no SwiftUI equivalent and a small API surface: PDFView,
MFMailComposeViewController, AVPlayerLayer, a third-party control. Small wrapper, one
coordinator, guarded updates.