[SC]()

iOS. Apple. Indies. Plus Things.

Send Events from SwiftUI to UIKit and Vice Versa

// Written by Jordan Morgan // May 2nd, 2022 // Read it in about 5 minutes // RE: SwiftUI

This post is brought to you by Emerge Tools, the best way to build on mobile.

If anything, Apple has been a fantastic steward of technological transitions. In the last year, we’ve seen software that was built for Intel chips and their x86-64 processors run on Apple Silicon by way of Rosetta II seamlessly. And, to wit, the first version of Rosetta was built for the very same purpose, only that was to bridge software over to Intel from PowerPC. From a hardware standpoint, they’ve nailed it — and it’s no different with their software.

The day Swift was announced, Objective-C interop was a not only a feature, but in many ways a selling point. Millions of apps ran on #DinoNotDead (and still do) and we’re only recently seeing the emergence of Swift-only frameworks such as WidgetKit. Of course, the same is true of SwiftUI, but even more so. It fully supports chatting it up with AppKit and UIKit.

But how?

That’s our focus today. Just as battle-hardened Objective-C developers have incrementally added Swift to their projects over the years, so too have many opted to gradually bake in SwiftUI-built interfaces into their UIKit+AppKit apps. And with that, you might have asked yourself these questions:

  • How do I let a UIKit object know something happened in SwiftUI?
  • Or, how do I let SwiftUI know something happened in UIKit?

There’s several options to choose from, so let’s check it out. Here’s our example:

A simple UIKit view with a label that shows a tap count, and a SwiftUI view below it with a button to increment it.

Our goal here is to:

  1. Tap the SwiftUI button to increment a count.
  2. Let the UIKit world know about it, and update the label showing the count.

Combine and Observable Objects
One way to tackle it is to simply share an observable object:

class EventMessenger: ObservableObject {
    @Published var tapCount: Int = 0
}

Then, make sure both the relevant UIKit control and SwiftUI view have it. You can create a property on the controller and pass it over to the SwiftUI view:

// UIKit
class SwiftUIViewController: UIViewController {
    private var notifier: EventMessenger = EventMessenger()
}

// SwiftUI
struct TapView: View {
    @EnvironmentObject var notifier: EventMessenger
    
    var body: some View {
        VStack {
            Text("This is a SwiftUI Control.")
            Button("Tap Me") {
                notifier.tapCount += 1
            }
            .buttonStyle(.borderedProminent)
        }
        .padding()
        .background(Color.gray.opacity(0.3))
        .cornerRadius(8)
    }
}

From there, you inject the object over to SwiftUI and use a Combine subscriber to handle it:

class SwiftUIViewController: UIViewController {
    private var subs: [AnyCancellable] = []
    private var notifier: EventMessenger = EventMessenger()
    private let countLabel = UILabel(frame: .zero)
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        // Our communication bridge
        notifier.$tapCount.sink { [weak self] count in
            self?.countLabel.text = "This is a UIKit Control.\nYou tapped \(count) times"
        }
        .store(in: &subs)
        
        // Truncated: Code to add label to view
        // countLabel.translatesAutoresizingMaskIntoConstraints = false
        // view.addSubview(countLabel) etc etc...
        
        // Now, give the object over to the SwiftUI View
        let tapController = UIHostingController(rootView: TapView().environmentObject(notifier))

        // Truncated: Code to add SwiftUI to view
        // view.addSubview(tapController.view)
        // addChild(tapController)
        // tapController.didMove(toParent: self) etc etc...
    }
    
}

And you’re all set:

Tapping a button from SwiftUI increments a label in UIKit.

A Good Ol’ Closure
Keeping the same setup as above, we could change the SwiftUI view to take in a closure:

struct TapView: View {
    private var handler: () -> ()
    
    internal init(handler: @escaping () -> ()) {
        self.handler = handler
    }
    
    var body: some View {
        VStack {
            Text("This is a SwiftUI Control.")
            Button("Tap Me") {
                handler()
            }
            .buttonStyle(.borderedProminent)
        }
        .padding()
        .background(Color.gray.opacity(0.3))
        .cornerRadius(8)
    }
}

And then, we simply edit the tap count within it upon initialization:

// Add a local tap count property
private var tapCount: Int = 0

// Later, in viewDidLoad
let tapView: TapView = TapView() { [weak self] in
    self?.tapCount += 1
    self?.countLabel.text = "This is a UIKit Control.\nYou tapped \(self?.tapCount ?? 0) times"
}
        
// Now, give the object over to the SwiftUI View
let tapController = UIHostingController(rootView: tapView)

// Truncated: Code to add SwiftUI to view
// view.addSubview(tapController.view)
// addChild(tapController)
// tapController.didMove(toParent: self) etc etc...

You can also make this generic and flexible by using a enumeration with relevant actions and associated values, since Swift’s enums have a lot of utility compared to many other languages.

Tell The Entire Universe With Notifications
If you don’t mind everyone and everything knowing what’s going on, you could post a notification. This isn’t what I typically advise, but sometimes - you want to test things out with what’s easiest, maybe not with what’s most practical (or scalable).

In the SwiftUI view:

extension NSNotification.Name {
    static let tapViewTapped = NSNotification.Name("tapViewTapped")
}

struct TapView: View {
    var body: some View {
        VStack {
            Text("This is a SwiftUI Control.")
            Button("Tap Me") {
                NotificationCenter.default.post(name: .tapViewTapped, object: nil)
            }
            .buttonStyle(.borderedProminent)
        }
        .padding()
        .background(Color.gray.opacity(0.3))
        .cornerRadius(8)
    }
}

And over in UIKit, it looks similar:

NotificationCenter.default.publisher(for: .tapViewTapped).sink { [weak self] tapEvent in
    self?.tapCount += 1
    self?.countLabel.text = "This is a UIKit Control.\nYou tapped \(self?.tapCount ?? 0) times"
}
.store(in: &subs)

But remember, if you’re on iPadOS or macOS then you’ve got multiple windows. In those cases, you probably don’t want all of them updating for every situation. For example, if you used this approach to select an item in a list, then every window would select it.

Wait, What About UIKit to SwiftUI?

That’s the great thing about grokking how all of this fits - every single one of these approaches can work either way. For example, using the same technique as our first example, we could update both ways like this:

Tapping a button from SwiftUI increments a label in UIKit and vice-versa.

The only tweak we’d need is to locally mutate our EventMessenger in UIKit. Remember, in the first example, SwiftUI was already mutating it. So it looks like this:

// UIKit
class SwiftUIViewController: UIViewController {
    private var subs: [AnyCancellable] = []
    private var notifier: EventMessenger = EventMessenger()
    private let countLabel = UILabel(frame: .zero)
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        // Our communication bridge from SwiftUI -> UIKit
        notifier.$tapCount.sink { [weak self] count in
            self?.countLabel.text = "This is a UIKit Control.\nYou tapped \(count) times"
        }
        .store(in: &subs)
        
        // Truncated: Code to add label to view
        // countLabel.translatesAutoresizingMaskIntoConstraints = false
        // view.addSubview(countLabel) etc etc...
        
        // Add in a button to mutate the tap count
        // This updates SwiftUI too
        let button = UIButton(configuration: .borderedProminent(), primaryAction: UIAction() { [weak self] _ in
            self?.notifier.tapCount += 1
        })

        // Now, give the object over to the SwiftUI View
        let tapController = UIHostingController(rootView: TapView().environmentObject(notifier))

        // Truncated: Code to add SwiftUI to view
        // view.addSubview(tapController.view)
        // addChild(tapController)
        // tapController.didMove(toParent: self) etc etc...
    }
    
}

// SwiftUI
struct TapView: View {
    @EnvironmentObject var notifier: EventMessenger
    
    var body: some View {
        VStack {
            Text("This is a SwiftUI Control.\nTotal taps are \(notifier.tapCount)")
            Button("Tap Me") {
                notifier.tapCount += 1
            }
            .buttonStyle(.borderedProminent)
        }
        .padding()
        .background(Color.gray.opacity(0.3))
        .cornerRadius(8)
    }
}

The closure approach works too, and the notification scenario is almost identical save for using the .onReceive(_ :perform:) modifier in SwiftUI.

Final Thoughts

Keep in mind, there are many approaches to solving these types of things that developers have come up with, but these are some obvious ones. To me, that’s the beauty of software engineering. A thousand ways to solve one problem, each with their own mix of benefits and trade-offs. Mixing the new with the old (or, I guess — current) has long been a strong suit of Cupertino & Friends™️. It’s doubly so with SwiftUI.

So, go forth and mix and match the frameworks with your newfound comms savvy.

Until next time ✌️

···

Spot an issue, anything to add?

Reach Out.