Making Marzipan Apps Sing

March 02 2019

Preamble

Fair warning: this post is going to be code-heavy, and it's more a series of examples rather than a tutorial — not everything you need will be listed here. Use this as your springboard (😏) to new research topics. All examples will be Objective-C — that's the language I work with, and the easiest language to work with private and undocumented APIs and class-dumped headers. This can all be done with Swift with enough hoop-jumping, but I'll leave that as an exercise for the reader.


💡 Don't miss the other posts in this series


Let's Go

If you look at your newly-marzipanified app, and compare it with Apple's built-in UIKit apps on Mojave, you will notice that yours looks a lot more clunky and less-native than what Apple's doing. To really make your app sing, you're going to have to use some new classes and mechanics unique to UIKit on macOS.

These classes won't be in your iOS SDK, so to retrieve the most up-to-date versions, you'll have to use a tool like class-dump on UIKitCore.framework, which is housed in /System/iOSSupport/System/Library/PrivateFrameworks.

Class-dump looks at the ObjC class information of a binary and generates headers for everything it finds, including private classes and APIs. With a little clean-up, these headers can be incorporated in your project and the private APIs called.


⚠️ Private APIs should not be used in your shipping App Store app, as they will land you a rejection from App Review and are likely to change in between updates of the OS and crash your app if you're not treating them carefully. Technically, all of UIKit on the Mac, and everything related to Marzipan, is private API.

I don't expect any of these APIs to survive as-is in the next release of macOS and the public UIKit SDK. Bear that in mind and treat this as experimentation work rather than concrete planning for bringing your app to macOS.


I'm not going to explain everything you need to use class-dump — I hope that you are already familiar with it, but if not it might be worth doing a little research online before you continue. At its simplest, here is how you can invoke it:

mkdir -p ~/UIKitPrivateHeaders
cd ~/UIKitPrivateHeaders
class-dump -H -o . /System/iOSSupport/System/Library/PrivateFrameworks/UIKitCore.framework/UIKitCore

As mentioned, class-dumped headers need a little cleanup before they can be used in your projects. You may need to remove some of the imports and replace them with <Foundation/Foundation.h>, or <UIKit/UIKit.h>, and you may have to remove some methods that aren't relevant to what you need.

All in all, here's a list of the specific headers (as of macOS 10.14.0) that relate to Marzipan. A great many of them can be safely ignored, as they're under-the-hood implementation details. A few of them will be key to what you need in your app, like everything prefixed with _UIWindowToolbar or _UIMenuBar.

Marzipan-related classes

So, once you have some headers you're happy with, how do you use them?

Generally, there are two ways.

-Wl,-U,_OBJC_CLASS_$__UIWindowToolbarButtonItem

I recommend weak-linking in your own apps, as it will help for menu support.

Toolbars

A toolbar

A very simple starting point is to add a couple of buttons to a Mac-style toolbar to use in your app. Your iOS app may have iOS navigation bars and toolbars affixed to the top of the viewport, and you'll want to turn those off where appropriate.

    myNavigationController.navigationBarHidden = YES;

Then you can start to implement Marzipan-specific API.


    /* To create a simple button with a title and an action */
    _UIWindowToolbarButtonItem *editButton = [[_UIWindowToolbarButtonItem alloc] initWithIdentifier:@"com.test.button.edit"];
    editButton.title = @"Edit";
    editButton.action = @selector(editButtonPressed:);
    editButton.target = self;

    /* To create a simple bordered button with an image or icon */
    _UIWindowToolbarButtonItem *addButton = [[_UIWindowToolbarButtonItem alloc] initWithIdentifier:@"com.test.button.add"];
    addButton.imageName = @"Big-Plus-Icon";
    addButton.action = @selector(addButtonPressed:);
    addButton.target = self;

    /* To create a title label, which Apple uses in its apps to show the window title */
    _UIWindowToolbarLabelItem *titleLabel = [[_UIWindowToolbarLabelItem alloc] initWithIdentifier:@"titleLabel"];
    titleLabel.text = @"My Great App";

    [window _windowToolbarController].templateItems = [NSSet setWithObjects: editButton, addButton, titleLabel,nil];
    [window _windowToolbarController].itemIdentifiers = @[@"NSToolbarFlexibleSpaceItem",@"titleLabel",@"NSToolbarFlexibleSpaceItem", @"com.test.button.edit", @"com.test.button.add"];

    [window _windowToolbarController].centeredItemIdentifier = @"titleLabel";

Tab bars

Tab bar

If you use a tab bar in your iOS app, you may have noticed that it doesn't appear on macOS. To replace your tab bar, you use a segmented control in the window's toolbar like you see in the Home app on Mojave.

    _UIWindowToolbarSegmentedControlItem *item = [[_UIWindowToolbarSegmentedControlItem alloc] initWithIdentifier:@"com.test.segmentedcontrol"];

    item.target = self;
    item.action = @selector(setSelectedIndex:);

    item.segmentTitles = @[@"Tab One", @"Tab Two"];
    /* Or, use images
        item.segmentImageNames = @[@"TabOneIcon", @"TabTwoIcon"];
    */

    [self.window _windowToolbarController].templateItems = [NSSet setWithObject:item];
    [self.window _windowToolbarController].itemIdentifiers = @[@"com.test.segmentedcontrol"];

    [self.window _windowToolbarController].centeredItemIdentifier = @"com.test.segmentedcontrol";
    [self.window _windowToolbarController].autoHidesToolbarInFullScreen = YES;

Menus

Example menu

UIKit has constants for all the standard system menus, which you will need to weak link the same way you do with your classes.

-Wl,-U,__UIMenuBarStandardMenuIdentifierApplication
-Wl,-U,__UIMenuBarStandardMenuIdentifierFile
-Wl,-U,__UIMenuBarStandardMenuIdentifierEdit
-Wl,-U,__UIMenuBarStandardMenuIdentifierView
-Wl,-U,__UIMenuBarStandardMenuIdentifierWindow
-Wl,-U,__UIMenuBarStandardMenuIdentifierHelp

Once you have these core symbols linked, then you can start to build up or modify your menus.

    /* To add an item or items to e.g. the File menu */
    _UIMenuBarItem *myFileItem = [[_UIMenuBarItem alloc] initWithTitle:@"My File Item" action:@selector(performFileItem:) keyEquivalent:@"0"];
    myFileItem.target = self;

    [[_UIMenuBarMenu mainMenu] insertItems:@[myFileItem] atBeginningOfMenu:_UIMenuBarStandardMenuIdentifierFile];
    /* To add a new menu with a couple of items and a separator */
    _UIMenuBarItem *myItemOne = [[_UIMenuBarItem alloc] initWithTitle:@"Item One" action:@selector(performItemOne:) keyEquivalent:@"1"];
    myItemOne.target = self;

    _UIMenuBarItem *myItemTwo = [[_UIMenuBarItem alloc] initWithTitle:@"Item Two" action:@selector(performItemTwo:) keyEquivalent:@"2"];
    myItemTwo.target = self;

    _UIMenuBarMenu *myCustomMenu = [[_UIMenuBarMenu alloc] initWithTitle:@"My Menu"];
    myCustomMenu.items = @[myItemOne, [_UIMenuBarItem separatorItem], myItemTwo];

    [[_UIMenuBarMenu mainMenu] insertMenu:myCustomMenu afterStandardMenu:_UIMenuBarStandardMenuIdentifierFile];

Menus can be nested so you can have your traditional multi-level Mac menus, and the key equivalent is your given keyboard shortcut (e.g. ⌘1 and ⌘2 in this case).

Contextual Menus

Context menu

You can use the new _UIContextualMenuGestureRecognizer as your right-click handler to display contextual menus with UIMenuController within any view or view controller that can become first responder. This works like any other gesture recognizer.

UIMenuController is a public class that you may be familiar with that shows the editing popup (Copy, etc) on iOS.

- (void)viewDidLoad {
    [super viewDidLoad];

    _UIContextualMenuGestureRecognizer *r = [[_UIContextualMenuGestureRecognizer alloc] initWithTarget:self action:@selector(contextualGesture:)];
    [self.view addGestureRecognizer:r];
}

-(void)performContextAction:(id)sender
{
    /* Your code goes here! */
}

-(void)contextualGesture:(_UIContextualMenuGestureRecognizer *)recognizer
{
    if (recognizer.state == UIGestureRecognizerStateEnded)
    {
        CGRect r = CGRectMake([recognizer locationInView:nil].x, [recognizer locationInView:nil].y, 1, 1);

        UIMenuItem *menuItem = [[UIMenuItem alloc] initWithTitle:@"Custom Action One" action:@selector(performContextAction:)];
        UIMenuItem *menuItemTwo = [[UIMenuItem alloc] initWithTitle:@"Custom Action Two" action:@selector(performContextAction:)];

        [[UIMenuController sharedMenuController] setMenuItems:@[menuItem, menuItemTwo]];
        [[UIMenuController sharedMenuController] setTargetRect:r inView:self.view];

        [[UIMenuController sharedMenuController] setMenuVisible:YES animated:YES];
    }
}

-(BOOL)canBecomeFirstResponder
{
    return YES;
}

Sidebars

Sidebar

UIKit on macOS has a new UITableViewStyleSidebar constant, which is what it uses to simulate a Mac-style sidebar (with pass-through blur/vibrancy effect). Use this table view style if your app wants to use a sidebar as its primary navigation structure.

Mouse

Marzipan brings mouseover support to UIKit via a new UIHoverGestureRecognizer. This is what the Stocks app in Mojave uses to provide mouseover feedback on its graphs. It should be straightforward to implement just like other gesture recognizers.

UIHoverGestureRecognizer *hover = [[UIHoverGestureRecognizer alloc] initWithTarget:self action:@selector(hover:)];
[self.view addGestureRecognizer:hover];

-(void)hover:(UIHoverGestureRecognizer *)gestureRecognizer
{
    CGPoint p = [gestureRecognizer locationInView:self.view];

    if (gestureRecognizer.state == UIGestureRecognizerStateChanged)
    {
        /* Your code here! */
    }
}

Touch Bar

Touch Bar example

The Touch Bar works a little bit like UIKeyCommand in iOS apps; you register a Touch Bar responder, and when the responder gets focus the -touchBar selector will be called on it wherein you build your touch bar hierarchy and return it.

The Touch Bar, much like the window toolbar, supports a handful of standard macOS item identifiers like NSTouchBarItemIdentifierFlexibleSpace. Refer to the macOS documentation for Touch Bar support for the full list.

-(_UITouchBar *)touchBar
{
    /* Touch Bar is built when you call registerResponder: on [[UIApplication sharedApplication] _touchBarController] */

    _UIButtonTouchBarItem *touchBarItemOne = [[_UIButtonTouchBarItem alloc] initWithIdentifier:@"com.test.touchbarbuttonone"];
    touchBarItemOne.title = @"Button One";
    touchBarItemOne.action = @selector(performTouchBarAction:);
    touchBarItemOne.target = self;

    _UIButtonTouchBarItem *touchBarItemTwo = [[_UIButtonTouchBarItem alloc] initWithIdentifier:@"com.test.touchbarbuttontwo"];
    touchBarItemTwo.title = @"Button Two";
    touchBarItemTwo.action = @selector(performTouchBarActionTwo:);
    touchBarItemTwo.target = self;

    _UITouchBar *touchbar = [[_UITouchBar alloc] initWithIdentifier:@"com.test.touchbar"];
    touchbar.allowsCustomization = YES;
    touchbar.templateItems = [NSSet setWithObjects:touchBarItemOne, touchBarItemTwo, nil];
    touchbar.itemIdentifiers = @[@"NSTouchBarItemIdentifierFlexibleSpace", @"com.test.touchbarbuttonone", @"com.test.touchbarbuttontwo", @"NSTouchBarItemIdentifierFlexibleSpace"];

    return touchbar;
}

You can register a responder with the UIApplication's _touchBarController; this can be defined in a category on UIApplication pretty easily:

@interface UIApplication (TouchBar)
-(id)_touchBarController;
@end

And then you register your touch bar responder (i.e. the class that implements -touchBar above) wherever suits.

    [[[UIApplication sharedApplication] _touchBarController] registerResponder:self];

Dark Mode

Dark mode

UIKit apps on macOS support Dark Mode in much the same way as they do on tvOS. You can respond to Dark Mode changes through public API. It's pretty simple!

- (void)traitCollectionDidChange:(UITraitCollection *)previousTraitCollection
{
    if (self.traitCollection.userInterfaceStyle == UIUserInterfaceStyleDark)
    {
        self.navigationController.navigationBar.barStyle = UIBarStyleBlack;
        self.view.backgroundColor = [UIColor colorWithWhite:0.1 alpha:1.0];
    }
    else
    {
        self.navigationController.navigationBar.barStyle = UIBarStyleDefault;
        self.view.backgroundColor = [UIColor whiteColor];
    }
}

If you're using the Mac-specific sidebar tableview style, it will update to a vibrant dark appearance as one might expect.


Conclusion

With a little elbow-grease, and some respect for macOS and how its users work, you can start to make your UIKit-based app feel way more natural on the OS. I hope I've made that easier for you.

Here is an example of one of my apps before tweaking it to use some of the new APIs, and afterwards.

Before:

Grace on Marzipan

After:

Grace redesigned for macOS on Marzipan

Go Beyond

You don't need to stop at the basics! You could start to redesign your table view cells, spacing, and icons to adhere more closely to the Mac HIG. You may just find that there's some middle-ground with both Mac and iOS elements that actually works and looks good on the desktop. It is this hybrid design, that integrates the best of iOS with the best of macOS, that I think is going to be the future of all Mac apps.

Hybrid Mac-iOS app design


P.S.

One thing that is key to remember is that Marzipan scales everything in your window by 0.77 to better suit the desktop. This is mostly transparent to the developer, unless of course you're trying to closely match the metrics used in AppKit apps for e.g. sidebar row height, and you suddenly realize everything is smaller than it should be. In this case, remember that you need to scale any metrics you use by 1.0/0.77 (~1.3) to accurately display as expected.

P.P.S.

You may find all your centered text is no longer centered when run on the Mac. That's because Apple uses different integer values for the NSTextAlignment enum, so NSTextAlignmentCentered is interpreted as NSTextAlignmentRight. This is one of probably hundreds of tiny divergence points between iOS and macOS that Apple has no doubt spent the year trying to fix before the final, public Marzipan SDK is released. You can decide how best to handle fixing this particular bug in your own project.

/* Values for NSTextAlignment */
typedef NS_ENUM(NSInteger, NSTextAlignment) {
    NSTextAlignmentLeft      = 0,    // Visually left aligned
#if TARGET_OS_IPHONE && !0
    NSTextAlignmentCenter    = 1,    // Visually centered
    NSTextAlignmentRight     = 2,    // Visually right aligned
#else /* !TARGET_OS_IPHONE */
    NSTextAlignmentRight     = 1,    // Visually right aligned
    NSTextAlignmentCenter    = 2,    // Visually centered
#endif
    NSTextAlignmentJustified = 3,    // Fully-justified. The last line in a paragraph is natural-aligned.
    NSTextAlignmentNatural   = 4,    // Indicates the default alignment for script
} NS_ENUM_AVAILABLE_IOS(6_0);