Swift By Rahul

UIKit and SwiftUI Interoperability Patterns

As iOS developers, we often find ourselves working on projects that started in the UIKit era but are now embracing SwiftUI for new features and UI components. Or perhaps you're building a new app in SwiftUI but need to leverage a complex, existing UIKit component or a third-party library that doesn't yet have a SwiftUI equivalent. In these scenarios, knowing how to seamlessly integrate UIKit and SwiftUI is not just a "nice-to-have" skill – it's essential.

This article will guide you through the core patterns for interoperability, demonstrating how to embed SwiftUI views within UIKit view hierarchies and vice versa. We'll explore UIHostingController, UIViewRepresentable, and UIViewControllerRepresentable, along with strategies for handling data flow and events across the framework boundary.

Embedding SwiftUI in UIKit with UIHostingController

The most common scenario for integrating SwiftUI into an existing UIKit application is when you want to use a new SwiftUI-powered component within a UIViewController or UIView. Apple provides UIHostingController specifically for this purpose.

UIHostingController is a subclass of UIViewController that acts as a container for a SwiftUI view hierarchy. You simply initialize it with your root SwiftUI view, and then you can embed its view (hostingController.view) into any UIKit view hierarchy, just like any other UIView.

Here's how you can use it:

import UIKit
import SwiftUI

// Our SwiftUI View
struct MySwiftUIComponent: View {
    @State private var counter = 0
    let title: String
    var onIncrement: ((Int) -> Void)?

    var body: some View {
        VStack {
            Text(title)
                .font(.headline)
            Text("Counter: \(counter)")
                .font(.largeTitle)
                .padding()
            Button("Increment") {
                counter += 1
                onIncrement?(counter)
            }
            .buttonStyle(.borderedProminent)
        }
        .padding()
        .background(Color.green.opacity(0.1))
        .cornerRadius(10)
    }
}

// Our UIKit ViewController
class UIKitViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .systemBackground
        setupSwiftUIComponent()
    }

    private func setupSwiftUIComponent() {
        // 1. Create an instance of your SwiftUI view
        let swiftUIView = MySwiftUIComponent(title: "Hello from SwiftUI!") { newCount in
            print("SwiftUI counter incremented to: \(newCount)")
            // You can update UIKit UI here based on SwiftUI events
        }

        // 2. Create a UIHostingController with your SwiftUI view
        let hostingController = UIHostingController(rootView: swiftUIView)

        // 3. Add the hosting controller as a child view controller
        addChild(hostingController)

        // 4. Add the hosting controller's view to the UIKit view hierarchy
        view.addSubview(hostingController.view)

        // 5. Tell the hosting controller that it's moved to a parent
        hostingController.didMove(toParent: self)

        // 6. Set up Auto Layout constraints for the SwiftUI view
        hostingController.view.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            hostingController.view.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            hostingController.view.centerYAnchor.constraint(equalTo: view.centerYAnchor),
            hostingController.view.leadingAnchor.constraint(greaterThanOrEqualTo: view.leadingAnchor, constant: 20),
            hostingController.view.trailingAnchor.constraint(lessThanOrEqualTo: view.trailingAnchor, constant: -20)
        ])
    }
}

In this example, we create a simple MySwiftUIComponent that displays a counter. We then embed this component into a UIKitViewController using UIHostingController. Notice how we pass data (title) to the SwiftUI view via its initializer and receive events (onIncrement) via a closure. This is a robust way to communicate between the two frameworks when SwiftUI is nested inside UIKit.

Embedding SwiftUI in UIKit using UIHostingController UIKitViewController UIHostingController SwiftUI View Embed SwiftUI View Hierarchy SwiftUI Component UIHostingController Root SwiftUI View (@State, @Binding, etc.) Actions (Closures/Delegates) `view` Events Back to UIKit

Embedding UIKit in SwiftUI

Embedding UIKit views and view controllers within a SwiftUI hierarchy is slightly more involved but equally powerful. SwiftUI provides two specific protocols for this: UIViewRepresentable for UIView subclasses and UIViewControllerRepresentable for UIViewController subclasses.

UIViewRepresentable

When you need to integrate a custom UIView or a UIView-based control into SwiftUI, UIViewRepresentable is your go-to. You create a struct that conforms to this protocol, and it requires two methods:

  1. makeUIView(context:): This method is responsible for creating and configuring your UIKit view. It's called once when the view is first created.
  2. updateUIView(_:context:): This method is called whenever SwiftUI detects changes in the data you're passing to your Representable struct. You use it to update the UIKit view's properties.

Let's embed a UILabel with custom styling into a SwiftUI view:

import SwiftUI
import UIKit

struct CustomUILabel: UIViewRepresentable {
    var text: String
    var textColor: UIColor
    var font: UIFont

    func makeUIView(context: Context) -> UILabel {
        let label = UILabel()
        label.textAlignment = .center
        label.numberOfLines = 0
        return label
    }

    func updateUIView(_ uiView: UILabel, context: Context) {
        uiView.text = text
        uiView.textColor = textColor
        uiView.font = font
    }
}

struct ContentView: View {
    @State private var message = "Hello from UIKit!"
    @State private var fontSize: CGFloat = 20

    var body: some View {
        VStack {
            CustomUILabel(text: message, textColor: .red, font: .systemFont(ofSize: fontSize, weight: .bold))
                .frame(height: 100) // Give the UIKit view a frame in SwiftUI

            Slider(value: $fontSize, in: 10...40) {
                Text("Font Size")
            }
            .padding()

            Button("Change Message") {
                message = "Updated message at \(Date().formatted(date: .omitted, time: .shortened))"
            }
            .buttonStyle(.bordered)
        }
        .navigationTitle("UIKit in SwiftUI")
    }
}

In this example, CustomUILabel wraps a UILabel. The updateUIView method ensures that any changes to text, textColor, or font in the SwiftUI parent (ContentView) are reflected in the underlying UIKit UILabel.

UIViewControllerRepresentable

Similar to UIViewRepresentable, UIViewControllerRepresentable allows you to embed entire UIViewController hierarchies within SwiftUI. This is particularly useful for complex UIKit controllers like UIImagePickerController, UINavigationController, or custom view controllers that manage their own complex layouts and lifecycles.

It also requires makeUIViewController(context:) and updateUIViewController(_:context:).

import SwiftUI
import UIKit

// A simple UIKit view controller to embed
class MyCustomUIKitVC: UIViewController {
    var titleText: String = "Default Title" {
        didSet {
            label.text = titleText
        }
    }
    private let label = UILabel()

    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .systemTeal
        label.textAlignment = .center
        label.font = .preferredFont(forTextStyle: .title1)
        label.text = titleText
        label.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(label)

        NSLayoutConstraint.activate([
            label.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            label.centerYAnchor.constraint(equalTo: view.centerYAnchor)
        ])
    }
}

// SwiftUI wrapper for MyCustomUIKitVC
struct MyRepresentedViewController: UIViewControllerRepresentable {
    @Binding var dynamicTitle: String

    func makeUIViewController(context: Context) -> MyCustomUIKitVC {
        let vc = MyCustomUIKitVC()
        vc.titleText = dynamicTitle
        return vc
    }

    func updateUIViewController(_ uiViewController: MyCustomUIKitVC, context: Context) {
        uiViewController.titleText = dynamicTitle
    }
}

struct RepresentedVCView: View {
    @State private var currentTitle = "Hello from a UIKit VC!"

    var body: some View {
        VStack {
            MyRepresentedViewController(dynamicTitle: $currentTitle)
                .frame(height: 200) // SwiftUI frame for the UIKit VC's view

            TextField("Enter new title", text: $currentTitle)
                .textFieldStyle(.roundedBorder)
                .padding()
        }
        .navigationTitle("Represented UIViewController")
    }
}

Here, MyRepresentedViewController wraps MyCustomUIKitVC. We use a @Binding to pass currentTitle from SwiftUI to the UIViewController, ensuring that changes in the TextField are reflected in the embedded UIKit view controller.

Handling Events and Callbacks with Coordinator

When UIKit components need to communicate events or data back to their SwiftUI host, the Coordinator pattern within Representable types becomes invaluable. A Coordinator is a custom class that you define inside your Representable struct. It acts as a delegate or target for UIKit events and translates them into SwiftUI-friendly actions (e.g., updating @Binding properties, calling closures).

import SwiftUI
import UIKit

// A UIKit view that has an action
class MyInteractiveUIView: UIView {
    var onButtonTap: (() -> Void)?

    private lazy var button: UIButton = {
        let btn = UIButton(type: .system)
        btn.setTitle("Tap Me (UIKit)", for: .normal)
        btn.addTarget(self, action: #selector(buttonTapped), for: .touchUpInside)
        return btn
    }()

    override init(frame: CGRect) {
        super.init(frame: frame)
        setupView()
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    private func setupView() {
        backgroundColor = .systemYellow.withAlphaComponent(0.2)
        addSubview(button)
        button.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            button.centerXAnchor.constraint(equalTo: centerXAnchor),
            button.centerYAnchor.constraint(equalTo: centerYAnchor)
        ])
    }

    @objc private func buttonTapped() {
        onButtonTap?()
    }
}

// SwiftUI wrapper with Coordinator
struct InteractiveUIKitView: UIViewRepresentable {
    @Binding var tapCount: Int

    func makeUIView(context: Context) -> MyInteractiveUIView {
        let uiView = MyInteractiveUIView()
        uiView.onButtonTap = context.coordinator.buttonTapped // Assign SwiftUI callback to UIKit
        return uiView
    }

    func updateUIView(_ uiView: MyInteractiveUIView, context: Context) {
        // No updates needed from SwiftUI to UIKit in this specific example,
        // but this is where you'd update properties of uiView if needed.
    }

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

    class Coordinator: NSObject {
        var parent: InteractiveUIKitView

        init(parent: InteractiveUIKitView) {
            self.parent = parent
        }

        @objc func buttonTapped() {
            parent.tapCount += 1 // Update SwiftUI state via binding
        }
    }
}

struct InteractiveViewContainer: View {
    @State private var taps = 0

    var body: some View {
        VStack {
            InteractiveUIKitView(tapCount: $taps)
                .frame(height: 150)
                .padding()

            Text("Taps from UIKit: \(taps)")
                .font(.title2)
        }
        .navigationTitle("UIKit Events in SwiftUI")
    }
}

In InteractiveUIKitView, the Coordinator class acts as the target for the MyInteractiveUIView's button tap. When the UIKit button is tapped, Coordinator.buttonTapped() is called, which then updates the SwiftUI @Binding property tapCount. This is the canonical way to send events from UIKit back to SwiftUI.

┌───────────────────────────┐     ┌────────────────────────────┐
│   SwiftUI Representable   │     │      Coordinator           │
│ (e.g., InteractiveUIKitView)│     │ (e.g., InteractiveUIKitView.Coordinator) │
├───────────────────────────┤     ├────────────────────────────┤
│ - @Binding tapCount       │◄────┤ - updates parent.tapCount  │
│ - makeUIView()            │     │ - receives UIKit events    │
│ - updateUIView()          │     └────────────────────────────┘
│ - makeCoordinator()       │
└───────────────────────────┘
          │ (assigns)
          │ onButtonTap = context.coordinator.buttonTapped
          ▼
┌───────────────────────────┐
│     UIKit View/VC         │
│ (e.g., MyInteractiveUIView) │
├───────────────────────────┤
│ - var onButtonTap: (() -> Void)? │
│ - buttonTapped() calls onButtonTap? │
└───────────────────────────┘
Data Flow and Event Handling in Representable Views SwiftUI Parent View (@State, @Binding) Data Source Representable Struct (UIViewRepresentable / UIViewControllerRepresentable) makeUIView/VC(context:) updateUIView/VC(_:context:) makeCoordinator() Coordinator (Delegate, Target) Event Handlers UIKit View / ViewController Data Configure UI Events Update @Binding Update

Best Practices and Considerations

  • Choose the right Representable: Use UIViewRepresentable for individual views and UIViewControllerRepresentable for entire view controllers or those that manage their own navigation.
  • Minimalism: Only wrap the necessary UIKit components. Avoid wrapping entire complex UIKit screens in Representable when UIHostingController might be a better fit for a new SwiftUI screen in an old app.
  • Data Flow is Key: Clearly define how data flows into your Representable (via regular properties, @Binding) and how events flow out (via Coordinator, closures, or delegates).
  • Performance: While interoperability is generally efficient, deeply nested Representable views or frequently updating large UIKit views can impact performance. Profile your app if you suspect issues.
  • Lifecycle Differences: Remember that UIKit and SwiftUI have different lifecycles. makeUIView/VC is called once, updateUIView/VC can be called many times. Coordinator instances are created and managed by SwiftUI.
  • Environment: UIHostingController allows you to pass SwiftUI Environment values to its root view, which is convenient. When embedding UIKit in SwiftUI, UIKit views don't automatically get SwiftUI environment values, but you can pass them down explicitly via properties.
  • Gradual Migration: These interoperability patterns are excellent for a gradual migration strategy, allowing you to rewrite parts of your app in SwiftUI without a complete overhaul.

Summary

UIKit and SwiftUI interoperability is a cornerstone of modern iOS development, enabling developers to build powerful hybrid applications. UIHostingController allows you to bring the elegance of SwiftUI into existing UIKit codebases, while UIViewRepresentable and UIViewControllerRepresentable empower you to leverage the vast ecosystem of UIKit components within your SwiftUI views. By mastering these patterns and understanding how to manage data flow and events, you can seamlessly blend the best of both worlds, ensuring a smooth transition and a robust user experience.

Happy Swifting!