Home

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
        }
    }
}
  • makeUIView runs once. One-time configuration goes here.
  • updateUIView runs on every SwiftUI update. Treat it as a hot path.
  • makeCoordinator creates 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:

  1. User types → textViewDidChange → text changes
  2. SwiftUI re-renders → updateUIView runs → sets textView.text
  3. Setting text on a UITextView can 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 = selection

The 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.