NSToolbar Customisation in UIKit for Mac (Marzipan/Catalyst)

As I’m sure many of my fellow developers have been, following the recent announcements from WWDC 2019, I have been playing around with Catalyst, Apple’s new infrastructure designed to make it easy to bring iOS (or more specifically iPadOS) apps over to macOS. Unlike many developers, however, most of my apps make use of tab bar based interfaces, for which Apple recommends moving to toolbar segmented control navigation when porting to the Mac.

In this post I’ll briefly run through what I’ve figured out thus far, trying to implement this (I will hopefully keep updating as I go). If this isn’t quite what you’re looking for but still want to see what’s possible with Catalyst, then I recommend reading Beyond the Checkbox with Catalyst and AppKit by the always inspirational Steve Troughton-Smith.

iOS Tab Bar Navigation

iOS Tab Bar Navigation

Apple’s Recommended Mac Equivalent

Apple’s Recommended Mac Equivalent

To gain access to the toolbar you will first need to move your code base over to using the new UIScene APIs. This first involves adding the UISceneSession lifecycle methods to your App Delegate:

func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
        // Called when a new scene session is being created.
        // Use this method to select a configuration to create 
        // the new scene with.
        return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
}

func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set<UISceneSession>) {
        // Called when the user discards a scene session.
        // If any sessions were discarded while the application 
        // was not running, this will be called shortly after
        // application:didFinishLaunchingWithOptions.
  
        // Use this method to release any resources that were 
        // specific to the discarded scenes, as they will not return.
}

You then need to create a scene delegate that conforms to UIResponder and UIWindowSceneDelegate, like so:

import UIKit

class SceneDelegate: UIResponder, UIWindowSceneDelegate {

    var window: UIWindow?

    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        // Use this method to optionally configure and attach the 
        // UIWindow `window` to the provided UIWindowScene `scene`.
      
        // If using a storyboard, the `window` property will 
        // automatically be initialized and attached to the scene.
      
        // This delegate doesn't imply the connecting scene or session are new
        // (see `application:configurationForConnectingSceneSession` instead).
        guard let _ = (scene as? UIWindowScene) else { return }
    }

    func sceneDidDisconnect(_ scene: UIScene) {}
    func sceneDidBecomeActive(_ scene: UIScene) {}
    func sceneWillResignActive(_ scene: UIScene) {}
    func sceneWillEnterForeground(_ scene: UIScene) {}
    func sceneDidEnterBackground(_ scene: UIScene) {}
}

Once your scene delegate is in place, your Info.plist needs to be edited to reference it:

<key>UIApplicationSceneManifest</key>
<dict>
    <key>UIApplicationSupportsMultipleScenes</key>
    <false/>
    <key>UISceneConfigurations</key>
    <dict>
        <key>UIWindowSceneSessionRoleApplication</key>
        <array>
            <dict>
                <key>UILaunchStoryboardName</key>
                <string>LaunchScreen</string>
                <key>UISceneConfigurationName</key>
                <string>Default Configuration</string>
                <key>UISceneDelegateClassName</key>
                <string>SceneDelegate</string>
                <key>UISceneStoryboardFile</key>
                <string>Main</string>
            </dict>
        </array>
    </dict>
</dict>

From there you should be able to get a reference to and make adjusts to your app’s NSToolbar instance, from the scene delegate’s willConnectTo session method, like so:

func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
    guard let _ = (scene as? UIWindowScene) else { return }

    #if targetEnvironment(UIKitForMac)
        if let windowScene = scene as? UIWindowScene {
            if let titlebar = windowScene.titlebar {
                let toolbar = NSToolbar(identifier: "testToolbar")
                
                titlebar.toolbar = toolbar
            }
        }
    #endif
 }

If you get a “Value of type ‘UIWindowScene’ has no member ‘titlebar‘" error, you will need to import NSToolbar+UIKitAdditions.h as detailed in the macOS 10.15 Beta Release Notes. This is likely a temporary requirement. Thanks to Sebastian in the comments for helping me over this hurdle!

Screen Shot 2019-06-10 at 12.38.57 am.png

You can import this directly if you’re using Objective-C, otherwise in Swift you can import them in a bridging header like so:

#import <Foundation/Foundation.h>
#import <UIKit/NSToolbar+UIKitAdditions.h>

Once you have access to the toolbar you configure it using the available NSToolbar properties, and set its contents using NSToolbarDelegate methods.

let toolbar = NSToolbar(identifier: "testToolbar")

toolbar.delegate = self
toolbar.allowsUserCustomization = false
toolbar.centeredItemIdentifier = NSToolbarItem.Identifier(rawValue: "testGroup")
titlebar.titleVisibility = .hidden

titlebar.toolbar = toolbar

To add a centred, segmented control in the toolbar implement the following delegate methods (ensure your scene delegate conforms to NSToolbarDelegate).

func toolbar(_ toolbar: NSToolbar, itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier, willBeInsertedIntoToolbar flag: Bool) -> NSToolbarItem? {
    if (itemIdentifier == NSToolbarItem.Identifier(rawValue: "testGroup")) {
        let group = NSToolbarItemGroup.init(itemIdentifier: NSToolbarItem.Identifier(rawValue: "testGroup"), titles: ["Solver", "Resistance", "Settings"], selectionMode: .selectOne, labels: ["section1", "section2", "section3"], target: self, action: #selector(toolbarGroupSelectionChanged))
            
        group.setSelected(true, at: 0)
            
        return group
    }

    return nil
}
    
@objc func toolbarGroupSelectionChanged(sender: NSToolbarItemGroup) {
    print("testGroup selection changed to index: \(sender.selectedIndex)")
}
    
func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
    return [NSToolbarItem.Identifier(rawValue: "testGroup")]
}
    
func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
    return self.toolbarDefaultItemIdentifiers(toolbar)
}

Behold, the final result:

Screen Shot 2019-06-10 at 10.30.56 pm.png

If you’re porting an app with tab bar based interface like I am then from here you can fairly easily hide the tab bar and hook view controller swapping up to the toolbar section change action, like so:

#if targetEnvironment(UIKitForMac)
let rootViewController = window?.rootViewController as? UITabBarController
rootViewController?.tabBar.isHidden = true
#endif
@objc func toolbarGroupSelectionChanged(sender: NSToolbarItemGroup) {
    let rootViewController = window?.rootViewController as? UITabBarController
    rootViewController?.selectedIndex = sender.selectedIndex
}
Appearance on iOS and macOS

Appearance on iOS and macOS

The source for a test app is available here if you’d prefer to just look at the code.