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 Catalina and macOS Big Sur. 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:

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:

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

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:

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.”

What you should see after completing Step 6

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:

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:

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.)

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!

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 get a runtime error.

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

I just started this blog, so if you like this, consider giving me a follow. I’m an iOS dev and will post stuff related to it, as well as other (fun) technical things.

iOS Engineer, Entrepreneur, CS Student at Georgia Tech