Mac Catalyst Title Bar Window Tabs

How to setup NSTitleBar style tabs for a UIKit app on mac with Catalyst

To start with let's take a simple tab bar based app with a label in the UIViewController's' telling us which view we are looking at.
If we just take this app and compile and run it for mac Catalyst this is the result we get.

Screenshot of the UITabBarController app on iPhone 8 and macOS Catalina

This works... but feels a lot like running the app in the iOS simulator.
You probably want to change the style, to either the side tabs, like the AppStore app, or titlebar tabs, like the Calendar app.

Screenshot of the AppStore and Calendar apps on Catalina

Most of the time I want the tabs in the titlebar. (if you want the sidebar style, that's UISplitViewController + .primaryBackgroundStyle = .sidebar)

Putting Tabs in the Title Bar

To do that we need to customise the window's tilebar. In iOS, the application window (as in multi-window support, not UIWindow or NSWindow) is now controlled from the UISceneDelegate.

In the SceneDelegate we setup an NSToolbar, set the SceneDelegate as the NSToolbar delegate and then hide both the title bar's title itself, after assigning the toolbar, & the tab bar.
Inside the SceneDelegate class add:

Objective-C (ObjC)

// SceneDelegate.m
#import <AppKit/AppKit.h>
@interface SceneDelegate () <NSToolbarDelegate>
@interface SceneDelegate ()

// -[SceneDelegate scene:willConnectToSession:options:] {

NSToolbar *toolbar = [[NSToolbar alloc] initWithIdentifier:@"mainToolbar"];
toolbar.centeredItemIdentifier = @"mainTabsToolbarItem";
toolbar.delegate = self;

[(UIWindowScene*)scene titlebar].toolbar = toolbar;
[(UIWindowScene*)scene titlebar].titleVisibility = UITitlebarTitleVisibilityHidden;

_tabbarController.tabBar.hidden = YES;


// SceneDelegate.swift
// SceneDelegate.scene(_:willConnectTo:options:) {

#if targetEnvironment(macCatalyst)
let toolbar = NSToolbar(identifier: "mainToolbar")
toolbar.centeredItemIdentifier = NSToolbarItem.Identifier(rawValue: "mainTabsToolbarItem")
toolbar.delegate = self

windowScene.titlebar?.titleVisibility = .hidden
windowScene.titlebar?.toolbar = toolbar

tabBarController.tabBar.isHidden = true

Most of the NSToolbar stuff is actually configured via the NSToolbarDelegate methods.

Here we are: specifying which items can be added to the toolbar toolbarDefaultItemIdentifiers: & toolbarAllowedItemIdentifiers:; setting up a new NSToolbarItemGroup item, specifying the segment control in the center of the title bar; and then .


// SceneDelegate.m
#pragma mark - NSToolbarDelegate


// NSToolbarItemIdentifier is just an NSString*
- (nullable NSToolbarItem *)toolbar:(NSToolbar *)toolbar itemForItemIdentifier:(NSToolbarItemIdentifier)itemIdentifier willBeInsertedIntoToolbar:(BOOL)flag
   if ([itemIdentifier isEqualToString:@"mainTabsToolbarItem"]) {
      NSToolbarItemGroup *group = [NSToolbarItemGroup groupWithItemIdentifier:itemIdentifier
                                                       titles:@[@"First", @"Second"]
                                                       labels:@[@"First view", @"Second view"]
      [group setSelectedIndex:0];
      return group;
   return nil;

/* Returns the ordered list of items to be shown in the toolbar by default.   If during initialization, no overriding values are found in the user defaults, or if the user chooses to revert to the default items this set will be used. */
- (NSArray<NSToolbarItemIdentifier> *)toolbarDefaultItemIdentifiers:(NSToolbar *)toolbar{
   return @[@"mainTabsToolbarItem"];

/* Returns the list of all allowed items by identifier.  By default, the toolbar does not assume any items are allowed, even the separator.  So, every allowed item must be explicitly listed.  The set of allowed items is used to construct the customization palette.  The order of items does not necessarily guarantee the order of appearance in the palette.  At minimum, you should return the default item list.*/
- (NSArray<NSToolbarItemIdentifier> *)toolbarAllowedItemIdentifiers:(NSToolbar *)toolbar{
   return [self toolbarDefaultItemIdentifiers:toolbar];

   self.tabbarController.selectedIndex = sender.selectedIndex;


// SceneDelegate.swift
// MARK: - NSToolbarDelegate

func toolbar(_ toolbar: NSToolbar, itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier, willBeInsertedIntoToolbar flag: Bool) -> NSToolbarItem?
   if (itemIdentifier.rawValue == "mainTabsToolbarItem")
      let group = NSToolbarItemGroup.init(itemIdentifier: itemIdentifier,
                                 titles: ["First", "Second"],
                                 selectionMode: .selectOne,
                                 labels: ["First view", "Second view"],
                                 target: self, action: #selector(toolbarGroupSelectionChanged))
      group.setSelected(true, at: 0)
      return group
   return nil

func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
   return [NSToolbarItem.Identifier(rawValue: "mainTabsToolbarItem")]

func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
   return self.toolbarDefaultItemIdentifiers(toolbar)

@objc func toolbarGroupSelectionChanged(_ sender: NSToolbarItemGroup) {
   tabBarController.selectedIndex = sender.selectedIndex

If you get the error message:

ERROR: Declaration of 'NSToolbarItemGroup' must be imported from module 'AppKit.NSToolbarItemGroup' before it is required

This means you haven't imported the Appkit headers. The fix is to @include AppKit; or #import <AppKit/AppKit.h>.

Before and after:

Example application using UITabBarController and NSToolbar

Looking at the result it might seem that the labels part has been ignored. However, if you turn on customisation of the toolbar, or remove the hiding of the title in the titlebar you can see where it is used.

Example application with visible title, visible labels and customisation panel open

Example Projects

Made with Xcode 11.4.1 targeting iOS 13.0