Skip to Content
Course content

189: UIKit and SwiftUI Interop with UIViewRepresentable

Click on the "Edit" button in the top corner of the screen to edit your slide content.

So, you've been using SwiftUI for a while now, and it's great—until it isn't. There are moments where you'll realize a specific UIKit component just does something better, or maybe there's a legacy view you can't afford to rewrite. For me, this usually happens with UITextView. While SwiftUI has TextEditor, it's often too simplistic when you need fine-grained control over the scroll behavior or specific delegate callbacks.

Trying to drop a UIKit view into SwiftUI

I wanted to build a simple text input that tells me exactly when the user changes the text, but I want the precision of the UIKit delegate. My first instinct was to just try and drop a UITextView into my VStack. Of course, that doesn't work. The compiler screams at me because UITextView is a subclass of UIView, and SwiftUI views have to conform to the View protocol. They speak entirely different languages: one is imperative (do this, then that), and the other is declarative (it should look like this).

To bridge this, Apple gave us UIViewRepresentable. Let's see if we can get a basic text view on the screen.

struct MyTextView: UIViewRepresentable {
    func makeUIView(context: Context) -> UITextView {
        let textView = UITextView()
        textView.font = .systemFont(ofSize: 18)
        return textView
    }

    func updateUIView(_ uiView: UITextView, context: Context) {
        // This is where we update the view when SwiftUI state changes
    }
}

I'll put this in my ContentView, and it works! I see the text box. But here's the problem: it's a black hole. I can type into it, but my SwiftUI state doesn't know anything is happening. It's an isolated island of UIKit inside my SwiftUI app.

Bridging the gap with the protocol

To make this useful, I need a binding. I want the text in the UITextView to sync with a @State variable in my parent view. I'll add a binding to the struct first.

struct MyTextView: UIViewRepresentable {
    @Binding var text: String

    func makeUIView(context: Context) -> UITextView {
        let textView = UITextView()
        textView.text = text
        return textView
    }

    func updateUIView(_ uiView: UITextView, context: Context) {
        uiView.text = text
    }
}

Now, if I change the text variable from a button in SwiftUI, the UITextView updates. Great. But the reverse still isn't happening. Typing in the box doesn't update the binding. Why? Because UITextView communicates changes through a delegate (UITextViewDelegate), and SwiftUI has no idea how to "listen" to a delegate automatically.

The missing link: Getting data back

This is where the Coordinator comes in. Think of the Coordinator as the "translator." It's a class that lives alongside your representable view and handles the UIKit-style callbacks, then pushes those changes back into the SwiftUI state.

I'll add a makeCoordinator method. I'll make the Coordinator the delegate of the text view, and inside the textViewDidChange method, I'll update the binding.

struct MyTextView: UIViewRepresentable {
    @Binding var text: String

    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }

    func makeUIView(context: Context) -> UITextView {
        let textView = UITextView()
        textView.delegate = context.coordinator // Tell UIKit to talk to the coordinator
        textView.text = text
        return textView
    }

    func updateUIView(_ uiView: UITextView, context: Context) {
        uiView.text = text
    }

    class Coordinator: NSObject, UITextViewDelegate {
        var parent: MyTextView

        init(_ parent: MyTextView) {
            self.parent = parent
        }

        func textViewDidChange(_ textView: UITextView) {
            self.parent.text = textView.text ?? ""
        }
    }
}

Taming the update loop

I ran this, and I immediately hit a classic bug: the cursor jumps to the end of the text every time I type a character. It's maddening.

Here is what's happening: I type a letter → textViewDidChange fires → the Coordinator updates the @Binding → SwiftUI detects a state change and triggers updateUIView → updateUIView sets uiView.text = text. Setting the text property on a UITextView resets the cursor position.

I only want to update the UIKit view if the text is actually different from what's already there. A simple if check in updateUIView fixes this entirely.

func updateUIView(_ uiView: UITextView, context: Context) {
    if uiView.text != text {
        uiView.text = text
    }
}

Now it's smooth. I've successfully wrapped a UIKit view, handled its delegate callbacks via a Coordinator, and prevented a feedback loop. This pattern is exactly how you'll handle almost every UIKit-to-SwiftUI integration, whether it's a MKMapView, a WKWebView, or a complex UISearchBar.




📋 Practical Task

Building a Real-Time Character Counter with UITextView

Your goal is to create a SwiftUI view that uses UIViewRepresentable to wrap a UITextView. The view must implement the following requirements:

  • Create a UIViewRepresentable struct called CharacterCountTextView that takes a Binding<String>.
  • Implement a Coordinator that conforms to UITextViewDelegate to update the binding whenever the text changes.
  • Ensure that updateUIView does not cause the cursor to jump by checking if the text is already equal to the binding value.
  • In your main ContentView, display this CharacterCountTextView and a Text view below it that displays the current character count (e.g., "Characters: 42").
  • Style the UITextView inside makeUIView to have a gray border and a small amount of padding so it's clearly visible on the screen.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.