27

An alternative question title could be "How to add an UIHostingController's view as subview for an UIView?".

I am creating a new piece of UI component and would love to give SwiftUI a try. The image below is the current view structure. The UIView is what I am using right now (top right), and SwiftUI view is what I try to use (bottom right).

enter image description here

After I watched all SwiftUI videos from WWDC 2019. I still have no clue on how can I use a SwiftUI view and put it at where a UIView instance should go.

I noticed from "Integrating SwiftUI" talk is that there is an NSHostingView for macOS, https://developer.apple.com/documentation/swiftui/nshostingview# which made me wonder if there is something similar to it or what is the alternative to achieve it.

I read questions like Include SwiftUI views in existing UIKit application mentioned that SwiftUI and UIKit can play together with UIHostingController. However, what I am trying to do is to only adopt one small piece of SwiftUI and put it inside of my existing UIKit view component, not use it as a controller.

I am new to iOS development, please leave a comment if there is a way I can use view controller as UIView view. Thank you.

X.Creates
  • 11,800
  • 7
  • 58
  • 98

2 Answers2

56

View controllers are not just for the top level scene. We often place view controllers within view controllers. It’s called “view controller containment” and/or “child view controllers”. (BTW, view controller containers are, in general, a great way to fight view controller bloat in traditional UIKit apps, breaking complicated scenes into multiple view controllers.)

So,

  • Go ahead and use UIHostingController:

    let controller = UIHostingController(rootView: ...)
    

    and;

  • Add the view controller can then add the hosting controller as a child view controller:

    addChild(controller)
    view.addSubview(controller.view)
    controller.didMove(toParent: self)
    

    Obviously, you’d also set the frame or the layout constraints for the hosting controller’s view.

    See the Implementing a Container View Controller section of the UIViewController documentation for general information about embedding one view controller within another.


For example, let’s imagine that we had a SwiftUI View to render a circle with text in it:

struct CircleView : View {
    @ObservedObject var model: CircleModel

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

And let’s say this was our view’s model:

import Combine

class CircleModel: ObservableObject {
    @Published var text: String

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

Then our UIKit view controller could add the SwiftUI view, set its frame/constraints within the UIView, and update its model as you see fit:

import UIKit
import SwiftUI

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)"
        }
    }
}
Senseful
  • 73,679
  • 56
  • 267
  • 405
Rob
  • 371,891
  • 67
  • 713
  • 902
  • 2
    I have the problem that the UIHostingController is shrinking to a size, smaller than the SwiftUI Views are. It's embedded into an ScrollView ContentView, so I think it has to "communicate" it's intrinsic / minimal size, but I'm not quite sure how. – Moritz Mahringer Jul 19 '19 at 12:44
  • @Moritz Mahringer You may need to constrain your `UIHostingController`'s view or simply set its frame. Note that Rob's example sets constraints for the hosting controller's view. – JWK Jul 20 '19 at 19:13
  • Thanks for addressing the problem of "how to update the view's state after it has been installed in the hierarchy." I was hoping there was a solution simpler than `@ObservedObject`. E.g. maybe defining something like `@State var text:String` on `CircleView` and obviating the need for `CircleModel` altogether, but alas if creating an `ObservableObject` is the only way to do it, then so be it. -- However, even **the `ObservableObject` solution does not seem to work**... The `text` property is updated correctly, `didChange.send()` gets called, but this never causes `body` to trigger again. – Senseful Dec 05 '19 at 23:40
  • I asked this as a separate question here: https://stackoverflow.com/questions/59219019/how-do-i-update-a-swiftui-view-that-was-embedded-into-uikit – Senseful Dec 06 '19 at 19:15
  • Great point about using view controller containment to break up large view controllers. I've seen some mad patterns people have invented to try to solve it in other ways! – malhal Mar 25 '20 at 11:07
2

I have some idea in mind.

  1. Wrap the SwiftUI with a UIHostingController
  2. Initialize the controller
  3. Add the new controller as a child view controller
  4. Add the controller view as a subview to where it should go

Thus:

addChild(hostingViewController)
hostingViewController.view.frame = ...
view.addSubview(hostingViewController.view)
hostingViewController.didMove(toParent: self)

A view controller always uses other view controllers as views.

Stanford CS193P, https://youtu.be/w7a79cx3UaY?t=679

Reference

Rob
  • 371,891
  • 67
  • 713
  • 902
X.Creates
  • 11,800
  • 7
  • 58
  • 98