Reading Time: 9 minutes

Mac Catalyst: Interfacing Between UIKit and AppKit without Private APIs in Swift

Mac Catalyst is a great opportunity for iOS developers to bring their existing offerings to the Mac with minimal modification. However, once you dig deeper into what you can and can’t do, you’ll realize that you’re restricted to a small subset of functionality that Mac has to offer.

I will share my method of using both UIKit and AppKit in a single Catalyst application to provide the native experience. All of this works in both macOS Big Sur and macOS Monterey.

A word of caution: you’re just getting started with a new macOS project, my advice is to go with either AppKit or SwiftUI. They offer the native experience out of the box and are likely to be better documented. If your goal is to create a complex application that utilizes lots of native macOS features, definitely go with AppKit. The method I’m presenting here is primarily for developers who either a) have an existing Catalyst app, b) an iOS app they want to port to the Mac, or c) who have large iOS codebases.

In this article, I will discuss: - how Catalyst interfaces between UIKit and AppKit; - how we can call AppKit APIs from our Catalyst (UIKit) app; - how we can add native Mac UI elements (and more) to the UIKit view hierarchy.

UIWindow + NSWindow = UINSWindow

If you’ve done some iOS development, you’re familiar with UIWindow. On iOS, these generally do not represent anything UI-related and are used to separate different view hierarchies. NSWindow is a similar concept that has existed on the Mac since the beginning. Windows are native to the Mac and are a much more natural abstraction there, compared to iOS.

To bridge UIWindow to NSWindow when using Mac Catalyst and allow you to present your scenes as familiar Mac windows, Apple created a different class — called UINSWindow — that is used for Catalyst only. This is a private class, so you cannot use it directly — and even if you try, your app will get rejected by the App Store Review.

A UINSWindow instance conveniently inherits from NSWindow, so it supports all the functionality that a standard AppKit window supports. This suggests that there is a possibility to somehow mix the UIKit and the AppKit layers together in one view hierarchy.

And indeed, that’s possible. For this, we can use the following snippet:

func nsWindow(from window: UIWindow) -> AnyObject? {
    guard let nsWindows = NSClassFromString("NSApplication")?.value(forKeyPath: "sharedApplication.windows") as? [AnyObject] else { return nil }
    for nsWindow in nsWindows {
        let uiWindows = nsWindow.value(forKeyPath: "uiWindows") as? [UIWindow] ?? []
        if uiWindows.contains(window) {
            return nsWindow
        }
    }
    return nil
}

Note several things here. Obviously, we can’t access any internal properties directly, so we use key paths to access the NSApplication class. We know it exists, since it exists for every GUI-based Mac app, no matter what technology you’re using. One more thing: we return AnyObject without casting, since UIKit doesn’t know either NSWindow or UINSWindow exist. If you print out the value, you will see that it’s an instance of UINSWindow.

Next, we need a way to find the current window in UIKit. Here’s a foolproof way of doing that in iOS 13+. Note that I placed the computed property as a UIApplication extension, but you can place it anywhere you want:

extension UIApplication {
    var currentWindow: UIWindow? {
        UIApplication.shared.connectedScenes
                .filter({$0.activationState == .foregroundActive})
                .map({$0 as? UIWindowScene})
                .compactMap({$0})
                .first?.windows
                .filter({$0.isKeyWindow}).first
    }
}

To test this part out, navigate to any of your view controllers and add the following to your viewDidAppear(_:) method:

guard let uiWindow = UIApplication.shared.currentWindow else { return }
print(uiWindow)
guard let nsWindow = self.nsWindow(from: uiWindow) else { return }
print(nsWindow)

When you run the target, your output should be something like this:

<UIWindow: 0x102a299c0; frame = (0 0; 1026 797); gestureRecognizers = <NSArray: 0x600000cbeca0>; layer = <UIWindowLayer: 0x60000027dfc0>>
<UINSWindow: 0x102b0d1e0>

This is the “secret connection” between UIKit and AppKit that we will need to make the rest of this work!

Creating an AppKit Bundle

Part of the problem is that we can’t import AppKit from a Catalyst backed target, so we’ll have to get somewhat creative.

Luckily, we can add a new Mac target to our project to overcome this. We will then load it from our app and it will serve as the “link” between UIKit and AppKit.

Step 1. Go to File > New > Target… Step 2. Make sure “macOS” is selected, and choose “Bundle” (you can search for it, too). Enter the details and hit create. Name it what you want (I chose “AKTest” for this). Step 3. Now create a new Swift file inside the newly created bundle. The name again doesn’t matter, I chose “AKTest.” Step 4. Inside the file, declare a class and inherit from NSObject. Then add the following:

@objc class AKTest: NSObject {
    private var _nsWindow: NSWindow?
    private var window: AnyObject? {
        get {
            return _nsWindow
        }
        set {
            _nsWindow = nsWindowBridge(for: newValue)
        }
    }
    private var contentView: NSView? {
        return _nsWindow?.contentView
    }
    
    private func nsWindowBridge(for object: AnyObject?) -> NSWindow? {
        return object as? NSWindow
    }
    
    public override init() {
        super.init()
    }
    
    @objc public func setWindow(_ window: AnyObject?) {
        self.window = window
    }
    
    @objc public func toggleFullScreen() {
        _nsWindow?.toggleFullScreen(self)
    }
}

Now let’s do some setup to make sure we can load the bundle from our main app.

Step 5. In the bundle’s Info.plist, locate the “Principal class” property. Set its value to be something like $(PRODUCT_BUNDLE_IDENTIFIER).AKTest, where instead of AKTest you should use the name of the class you’ve just created.

Step 6. Now go to the project settings and drag the bundle (it should be in the Products group) to Frameworks and Libraries section of the General tab of your main target. Finally, choose the platform to be “macOS.”

Frameworks and Libraries section of the General tab of your main target

Step 7. Now let’s make sure our bundle can be loaded. Navigate back to the viewDidAppear(_:) method, where we referenced our UINSWindow. Together with the existing code, your method should look like this:

var akInterface: NSObject!

override func viewDidAppear(_ animated: Bool) {
    super.viewDidAppear(animated)
    
    guard let uiWindow = UIApplication.shared.currentWindow,
          let nsWindow = self.nsWindow(from: uiWindow) else { return }
    
    let path: String = Bundle.main.builtInPlugInsURL!.appendingPathComponent("AKTest.bundle").path // replace the name of the bundle
    let bundle = Bundle(path: path)
    if let isLoaded = bundle?.load(), isLoaded {
        let principalClass: NSObject.Type = bundle?.principalClass as! NSObject.Type
        akInterface = principalClass.init()
        akInterface.perform(Selector("setWindow:"), with: nsWindow)
        akInterface.perform(Selector("toggleFullScreen"))
        // Insert more calls here
    }
}

In the first line, replace AKTest.bundle with your bundle’s name. Note that the interfacing object (akInterface) is stored as an instance variable. This is done in order to prevent it from being deallocated once control reaches the end of the method. We will need to keep this object for later.

When you build and run, your window will go to full screen mode automatically. This was done by calling an AppKit API from our bundle!

Adding Native UI

Now let’s use the fact that we can interface between UIKit and AppKit to do something more interesting. I have included the contentView computed property to the sample class I provided above. It will represent the base NSView of your window, and you can add the controls to it directly.

In the demo app, I have a UILabel. Note that you will need to have the label created in your hierarchy — I am not covering that basic setup here. If you are having trouble following these steps, consider cloning/downloading the demo project that is provided at the end.

Let’s pretend I want to insert an NSSlider (available only in AppKit) into my UIKit view hierarchy, and then update the text of that UILabel whenever the value changes. This use case should cover the majority of potential use cases for interfacing UIKit and AppKit.

I will add some new properties and methods into my AKTest class located inside the AppKit bundle, like this:

private var slider = NSSlider(frame: NSRect(x: 0, y: 0, width: 500, height: 500))
private var target: NSObject?
private var sliderValueChangedCallback: String?

@objc public func setTarget(_ target: NSObject) {
    self.target = target
}

@objc public func setupSlider(_ callback: String) {
    contentView?.addSubview(slider)
    slider.isContinuous = true
    slider.target = self
    slider.action = #selector(self.sliderValueChanged(_:))
    sliderValueChangedCallback = callback
}

@objc func sliderValueChanged(_ sender: NSSlider) {
    if let callback = sliderValueChangedCallback {
        target?.perform(Selector(callback), with: sender.stringValue)
    }
}

I have decided to follow the familiar target-action pattern — it is common for event handling in both UIKit and AppKit. First, I will set the target using the setTarget(_:) method to be the object that is going to handle the callbacks. Then, I will set up the slider and pass the identifier of the selector that I want to handle the callbacks from the slider. Next, I am going to set up the basic event handling and forward the call whenever the value of the slider changes — this happens inside sliderValueChanged(_:).

Inside our view controller on the UIKit side of things, I am going to add two calls to the AppKit bundle interface: one to set the target, and one to set up the slider:

akInterface.perform(Selector("setTarget:"), with: self)
akInterface.perform(Selector("setupSlider:"), with: "uiSliderValueChanged:")

And, of course, I need to create the method that is going to handle the change of the value on the slider. (It should be located in the same class.)

@objc public func uiSliderValueChanged(_ newValue: String) {
    let intValue: Int = Int((Double(newValue) ?? 0) * 100)
    sliderValueLabel.text = "\(intValue)"
}

If I build and run the app, I can see that my UIKit label updates whenever I change the value on the AppKit slider. We have successfully used UIKit and AppKit in one view hierarchy — even more than that, we’ve been able to handle events too!

The label updates whenever I change the value on the AppKit slider

Next Steps

Now that we have a common ground between the two frameworks, we’re free to use the best of both! I’m going to assume that you would like to add your own functionality and new methods — after all, as exciting as adding a slider sounds, I can think of many more possible applications. Here’s the general calling convention and workflow. Whenever you want to call a method in the bundle, you have to perform the selector instead of calling the method itself. If you have a method that takes no arguments, like toggleFullScreen, use the following:

object.perform(Selector("toggleFullScreen"))

If you do have arguments, like the setWindow(_:) method, do this:

object.perform(Selector("setWindow:"), with: nsWindow)

Now you can go ahead and create new methods in the AppKit bundle. Make sure any new methods you add have @objc in front of them — otherwise, you’ll hit a runtime error.

Finally, here’s the link to the demo project.

Tagged with:

Comments