15

I'm looking for an equivalent of AppKit's NSHostingView for UIKit so that I can embed a SwiftUI view in UIKit. Unfortunately, UIKit does not have an equivalent class to NSHostingView. The closest we have as an equivalent of NSHostingController, named UIHostingController. Since a view controller contains a view, we should be able to call the appropriate UIViewController embedding methods, and then grab the view and use it directly.

There are many articles that explain that this is the way to embed a SwiftUI view inside UIKit. However, they typically fall short in explaining how you would communicate from UIKit ➡️ SwiftUI. For example, imagine I implemented a SwiftUI view that acts as a progress bar, periodically, I'd like the progress to be updated. I want my legacy/UIKit code to update the SwiftUI view to display the new progress.

The only article I found that came close to explaining how to manipulate an embedded view's content suggested we do so by using @ObservedObject:

import UIKit
import SwiftUI
import Combine

class CircleModel: ObservableObject {
    var didChange = PassthroughSubject<Void, Never>()

    var text: String { didSet { didChange.send() } }

    init(text: String) {
        self.text = text
    }
}

struct CircleView : View {
    @ObservedObject var model: CircleModel

    var body: some View {
        ZStack {
            Circle()
                .fill(Color.blue)
            Text(model.text)
                .foregroundColor(Color.white)
        }
    }
}

class ViewController: UIViewController {
    private weak var timer: Timer?
    private var model = CircleModel(text: "")

    override func viewDidLoad() {
        super.viewDidLoad()

        addCircleView()
        startTimer()
    }

    deinit {
        timer?.invalidate()
    }
}

private extension ViewController {
    func addCircleView() {
        let circleView = CircleView(model: model)
        let controller = UIHostingController(rootView: circleView)
        addChild(controller)
        controller.view.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(controller.view)
        controller.didMove(toParent: self)

        NSLayoutConstraint.activate([
            controller.view.widthAnchor.constraint(equalTo: view.widthAnchor, multiplier: 0.5),
            controller.view.heightAnchor.constraint(equalTo: view.heightAnchor, multiplier: 0.5),
            controller.view.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            controller.view.centerYAnchor.constraint(equalTo: view.centerYAnchor)
        ])
    }

    func startTimer() {
        var index = 0
        timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
            index += 1
            self?.model.text = "Tick \(index)"
        }
    }
}

This seems to make sense as the timer should trigger a chain of events that update the view:

  1. self?.model.text = "Tick 1" (In ViewController.startTimer()).
  2. didChange.send() (In CircleModel.text.didSet)
  3. Text(model.text) (In CircleView.body)

As you can see by the indicators (which specify if something was run or not), the problem is that didChange.send() never triggers a re-run of CircleView.body.

How do I communicate from UIKit > SwiftUI to manipulate a SwiftUI view that was embedded in UIKit?

Senseful
  • 86,719
  • 67
  • 308
  • 465
  • Your information is out of date. `ObservableObject` was changed (whilst iOS 13.0 was still in beta) to require an `objectWillChange` publisher instead of a `didChange` publisher. Asperi’s answer shows an easier way to implement your `ObservableObject`, by let it synthesize its own publisher from its `@Published` properties. – rob mayoff Dec 06 '19 at 19:39

2 Answers2

11

All you need is to throw away that custom subject, and use standard @Published, as below

class CircleModel: ObservableObject {

    @Published var text: String

    init(text: String) {
        self.text = text
    }
}

Tested on: Xcode 11.2 / iOS 13.2

Martijn Pieters
  • 1,048,767
  • 296
  • 4,058
  • 3,343
Asperi
  • 228,894
  • 20
  • 464
  • 690
  • 2
    For anyone else who wonders how to get model.text as a binding for read/write access inside your SwiftUI view, but synchronizing it with the variable outside of SwiftUI, instead of model.text, it would just be $model.text since \@ObservableObject's wrapped value is a binding which can be used the same as a binding to a wrapped \@State property. No changes to your binding-based SwiftUI view code. (CircleModel would be declared with wrapper \@ObservedObject instead of \@State or instead of read-only normal property.) (Ignore \ characters.) – Jared Updike Jul 24 '21 at 18:50
  • Thank you very much. I found both Asperi answer and Jared comment to work flawlessly. – Andres Jan 07 '22 at 03:42
  • Will share something that tripped me up. Only the `text` property on CircleModel has the `@Published` annotation, so you must update that property only in the view controller. Replacing the whole model, for example by writing `self?.model = CircleModel(text: "Tick \(index)")` will not work. – wrightak Aug 16 '22 at 01:48
0

My solution that works for updates from SwiftUI view is.

let hostingController = UIHostingController(rootView: contentView)
        if #available(iOS 16.0, *) {
            hostingController.sizingOptions = .intrinsicContentSize
        } else {
       
        cancellable = state.objectWillChange
            .receive(on: DispatchQueue.main)
            .sink { [weak self] _ in
                  self?.hostingController?.view.invalidateIntrinsicContentSize()
            }
    }
        
        if let hostingView = hostingController.view {
            addSubview(hostingView)
            hostingView.translatesAutoresizingMaskIntoConstraints = false
            NSLayoutConstraint.activate([
                hostingView.leadingAnchor.constraint(equalTo: leadingAnchor),
                hostingView.trailingAnchor.constraint(equalTo: trailingAnchor),
                hostingView.topAnchor.constraint(equalTo: topAnchor),
                hostingView.bottomAnchor.constraint(equalTo: bottomAnchor),
            ])
        }
        
        self.hostingController = hostingController

If you are updating view from UIKit and are resetting rootView then this approach seems to be sufficient


if let hostingController {
            hostingController.rootView = contentView
            hostingController.view.invalidateIntrinsicContentSize()
        } else {
            let hostingController = UIHostingController(rootView: contentView)
            
            if let hostingView = hostingController.view {
                addSubview(hostingView)
                hostingView.translatesAutoresizingMaskIntoConstraints = false
                NSLayoutConstraint.activate([
                    hostingView.leadingAnchor.constraint(equalTo: leadingAnchor),
                    hostingView.trailingAnchor.constraint(equalTo: trailingAnchor),
                    hostingView.topAnchor.constraint(equalTo: topAnchor),
                    hostingView.bottomAnchor.constraint(equalTo: bottomAnchor),
                ])
            }
            
            self.hostingController = hostingController
        }
Michał Ziobro
  • 10,759
  • 11
  • 88
  • 143