KISS

Keep It Simple Stupid

Communicate UIKit to SwiftUI

| comments

I needed to do a quick proof-of-concept for a project and instead of dealing with UITableViews (whose API looks so archaic now, you need both a datasource and a delegate, and a separate piece of state to hold the items for rows) and UIKit in general, I decided to try out SwiftUI for the first time to build a small piece of the PoC UI. Specifically, I needed to embed a SwiftUI view in a storyboard and be able to update its state. I’ve found a working solution after a lot of searching online because there were different pieces in different places. Here’s a short post and a sample repository on how to do that.

Static counter

As an example for this post, I implemented a primitive counter here (later I added support for a missing counter, but it doesn’t really matter):

1
2
3
4
5
6
7
struct CounterView: View {
    var counter = 0

    var body: some View {
        Text("Counter: \(counter)")
    }
}

Then I added a UIButton and a hosting controller to the storyboard. To display the CounterView there, the storyboard needs to know what controller to load, but you can’t use a UIHostingController<CounterView> in the storyboard directly; instead I had to use a new CounterHostingController, which is a dummy controller for now because it doesn’t do much:

1
2
3
4
5
class CounterHostingController: UIHostingController<CounterView> {
    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder, rootView: CounterView(counter: 0))
    }
}

Capturing the hosting controller

You need to capture the storyboard’s hosting controller in the ViewController first. It’s done in a funky, non-type-safe way by getting the instance from prepare(for:sender:) after checking for the correct segue (oh those ugly implicitly-unwrapped optionals, but that’s the life with UIViewControllers):

1
2
3
4
5
6
7
8
9
10
class ViewController: UIViewController {
    private var counterController: CounterHostingController!

    override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
        switch segue.identifier {
        case "counterSegue": counterController = segue.destination as? CounterHostingController;
        default: break;
        }
    }
}

Dynamic counter

Finally we reach the step of updating the embedded counter. CounterHostingController now stores CounterView.State as a private property that it can update on request:

1
2
3
4
5
6
7
8
9
10
11
class CounterHostingController: UIHostingController<CounterView> {
    private var state = CounterView.State(counter: nil)

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder, rootView: CounterView(state: state))
    }

    func incrementCounter() {
        state.counter = state.counter.map { $0 + 1 } ?? 0
    }
}

Updating state.counter is all that’s needed here.

The CounterView becomes more complicated. A new class is needed to keep track of its mutable state, here called State:

1
2
3
4
5
6
7
8
9
10
11
12
struct CounterView: View {
    class State: ObservableObject {
        @Published var counter: Int?
    }

    @ObservedObject var state: State

    var body: some View {
        Text(state.counter.map { "Counter: \($0)" }
            ?? "No counter")
    }
}

The main point of the article is to remember these three entities:

  • the state class needs to be an ObservableObject, …
  • and its mutable properties triggering updates should have the @Published decorator;
  • an instance of this state should be a property in the CounterView with the @ObservedObject decorator.

ViewController button’s IBAction can now ask the counter hosting controller to increment the counter:

1
2
3
@IBAction func didPressIncreaseButton(_ sender: UIButton) {
    counterController.incrementCounter()
}

Now the application starts up with “No counter”, clicking the Increment button updates it to “Counter: 0”, then “Counter: 1”, and so on.

The SwiftUI experience was fine in general, quick previews are great, even though Xcode was failing with previews all the time: “Automatic preview updating disabled” and some mysterious build errors. The project where I implemented this initially also had issues with previews when my SwiftUI file imported some models from another target, but Xcode previews did not like that at all, so I had to extract this one SwiftUI view into its own target and move the necessary models there too.

Sources

Here’s a list of articles I’ve used to figure it out:

Comments