Catalyst Tutorial: Running iPad apps on macOS

In this Catalyst tutorial, you’ll learn how to take an iPad app and configure it to run on macOS and add Mac-specific features like contextual menus. By Warren Burton.

Leave a rating/review
Download materials
Save for later
Share

The dream of write once, run everywhere came closer at WWDC 2019 when Apple released the Catalyst project into developers’ hands. Catalyst allows you to use most of your UIKit and Foundation code to compile an app that runs natively on macOS. What does this mean in reality?

The apps you write using UIKit for iPad targets can be cross-compiled to run on macOS, but there are catches in this statement. Not all frameworks for iOS are available on macOS. For example, ARKit makes no sense for macOS and is not available, and for people who have developed for macOS, there is no access to AppKit.

Your standard iOS containers and collections will work as they did on iOS. Where these objects have an equivalent AppKit version, the compiler swaps them out for the platform native version. For example:

  • UITableView -> NSTableView
  • UICollectionView -> NSCollectionView
  • UISplitView -> NSSplitView
  • SwiftUI components that are in a UIHostingController just work.

Other iOS UI objects that have no macOS equivalent like UINavigationController will render the same as they do on iOS.

This tutorial will cover some more complex cases you’ll need to deal with to make your iOS app a good macOS citizen. Multiple windows support is a broad topic that deserves its own tutorial, so it is out of scope for this session.

To complete this tutorial you’ll need:

  • A Mac running macOS 10.15 Catalina.
  • Xcode 11.

Getting Started

You can download the tutorial content via the Download Materials button at the top or bottom of the tutorial. You might have seen this project before if you did the Document Based Apps tutorial. The project allows you to add text and color to a background image.

You’ll be working with the Markup folder in the Project navigator. There are no changes needed for MarkupFramework.

Build and run the project in the Markup-Starter folder using the iOS simulator target iPad Pro (9.7-inch).

set target as iPad

You can create a marked-up image with text and a description.

Markup app initial iPad screenshot

You now know this is a working iOS project. The next step is to make the app run on macOS.

Enabling Your Build for Mac

In this section, you’ll make some basic project changes to allow Xcode to compile your project for macOS.

1. Select the Markup project in Project navigator and open the General tab:

enable the Mac build

2. Select the Markup target. Tick the box marked Mac in the Deployment Info section. A dialog will appear asking if you want to enable Mac support. Click Enable:

Xcode warning panel

3. Next, you need to make similar settings on the framework. Select the MarkupFramework target and tick the box marked Mac in the Deployment Info section. Click Enable in the dialog:

enable Mac deployment

4. Finally, go back and select the Markup target again. Locate the Frameworks, Libraries, and Embedded Content section. For MarkupFramework.framework, select the option macOS + iOS:

ensure framework is set for macOS and iOS

These actions set up the project to compile for macOS. Xcode automatically generates a new bundle ID for the Mac build based on your original iOS bundle ID. For this project you’ll see maccatalyst.com.raywenderlich.markup2019.

It’s possible to assign a bundle ID for Mac manually. You can do this in iTunes Connect, but for this tutorial you’ll stick with the automatically assigned ones.

Next, open the Signing & Capabilities tab, and select the Markup target. In the Signing section, ensure that macOS is set to Sign To Run Locally. Don’t worry about the status error. A development team is only required for deployment to other machines:


signing certificate checkbox for macOS

Select the target to be My Mac.

set target to my Mac

Build and run. You’ll see the app icon appear in the dock and a single window will appear with the interface you saw before on the iPad.

initial Mac display

Free Functionality

You now have a working Mac app. It doesn’t look very Mac-like at the moment, but you’ll change that soon. First, you’ll look at some of the cross-platform functionality you get for free.

Add a title, description and image to the document, then:

  • Resize the window by clicking and dragging the edge of the window interface.
  • Locate an image from your Finder or Photos and drag it into to the Markup window. The view adds the image to itself.
  • Click and drag the composition render from the Markup window to a new rich text TextEdit document. TextEdit adds the composition image to the document.

You see that most UIKit based features work the same on macOS as they do on iOS.

Compiling Conditionally

There will be code paths that you don’t want to run on macOS or, alternately, on iOS. These conditional paths are not runtime choices. Xcode compiles your code twice: once for iOS and once for macOS. So, there are two binaries.

To make these choices, you can use the following structure:

#if targetEnvironment(macCatalyst)
  //code to run on macOS 
#else
  //code to run on iOS
#endif

You’ll see this pattern frequently within the tutorial.

Improving the App for all Platforms

To borrow a phrase from 2019 WWDC session 205, “You make a great Mac app by making a great iPad app.” In this section, you’re going to improve the app for both platforms by adding a contextual menu.

Adding a Contextual Menu

Mac users expect to see contextual menus when they control-click interface items. These menus should contain actions specific to the item you click.

Your next task is to add a contextual menu that will appear when the user control-clicks the editor view. An excellent action to add here is the ability to clear all the content from the editor.

In the project navigator select the Primary Views folder, then add a new file by pressing the key combination Command-N:


choose a template dialog

Select iOS ▸ Swift File and click Next.


MarkUp target selected checkbox

Name the file MarkupViewController+ContextMenu.swift and make sure Markup target is selected, then click Create.

Replace all the code in the file with this extension:

import UIKit
import MarkupFramework

extension MarkupViewController: UIContextMenuInteractionDelegate {
  //1
  func contextMenuInteraction(
    _ interaction: UIContextMenuInteraction,
    configurationForMenuAtLocation location: CGPoint)
      -> UIContextMenuConfiguration? {
    //2
    let config = UIContextMenuConfiguration(
      identifier: "display" as NSString,
      previewProvider: nil,
      actionProvider: { _ in
        //3
        let identifier = UIAction.Identifier("Clear View")
        let clearAction = UIAction(
          title: "Clear Editor",
          image: UIImage(systemName: "trash"),
          identifier: identifier) { (action) in
            self.cleanDocumentAction(self)
        }
      
        //4
        let menu = UIMenu(
          title: "", 
          image: nil,
          identifier: UIMenu.Identifier("Template"),
          options: .displayInline,
          children: [clearAction])
      
        return menu
    })
    return config
  }
  
  //5
  @objc func cleanDocumentAction(_ sender: Any) {
    let template = ContentDescription(template: BottomAlignedView.name)
    currentContent = template
    titleField.text = nil
    descriptionField.text = nil
  }
  
  //6
  func configureContextInteraction() {
    let interaction = UIContextMenuInteraction(delegate: self)
    templateContainer.addInteraction(interaction)
  }
}

This extension provides a context menu by conforming MarkupViewController to UIContextMenuInteractionDelegate.

  1. First, implement UIContextMenuInteractionDelegate by adding contextMenuInteraction(_:configurationForMenuAtLocation:).
  2. Create an instance of UIContextMenuConfiguration.
  3. Create a UIAction for the Clear Editor action.
  4. Embed that UIAction in an instance of UIMenu.
  5. Provide an implementation for cleanDocumentAction.
  6. Add the UIContextMenuInteraction to the editor view. You’ll have to call the helper function, configureContextInteraction() when the app launches.

Now open MarkupViewController.swift and add the following to the end of viewDidLoad():

configureContextInteraction()

Build and run. This time around, when you control-click inside the editor, you see a menu with a Clear Editor item. You can use the menu item to return your editor to a blank state:

context menu on Mac

Note: On iOS, AppDelegate.application(_:didFinishLaunchingWithOptions:) is optional and, in many apps, deleted as unnecessary. In a Catalyst app, however, it must be present, even if it only returns true. Without it, your context menu will appear but will do nothing, and you’ll waste hours trying to figure out why. So, if you’re converting an existing app and your menus don’t work, make sure this method is present in your app.

To see how this code works on iPad, change your target to iPad Pro (9.7-inch), and build and run. Now, long press the editor render view and then release. A context menu will appear, only this time you get a snazzy trash can from SF Symbols icon set too:

context menu on iPad

Congratulations! You added features to both macOS and iOS with one set of project changes. This is a concrete example of the benefits of the Catalyst framework.

Mac Exclusive Features

macOS has several features that iOS doesn’t. Windows can have toolbars at the top for useful UI. Some MacBook Pros have a configurable touch bar, and the primary screen has a menu bar.

In this section you will:

  • Add a menu item to the main menu bar.
  • Add a toolbar to the window.
  • Provide a custom icon for Mac.
  • Add a touch bar item to the touch bar.

Modifying the Menu Bar

The menu bar should be the primary source of actions for your app. An action doesn’t need to exist in the toolbar or the touch bar, but the action should exist in the menu bar. Ideally, the action should have a key command too.

macOS menu bar

To change the menu bar, you need to override buildMenu(with:), from UIResponder. If you open AppDelegate.swift from the Project navigator, you see that the declaration for AppDelegate is:

class AppDelegate: UIResponder, UIApplicationDelegate

Which means that AppDelegate is already a UIResponder. The UIApplicationDelegate for any app is also the penultimate object to receive a message in the responder chain. The last object in the responder chain is UIApplication.

You’ll add a new extension to AppDelegate now. Select and expand the Infrastructure folder in the Project navigator and press Command-N. Select iOS ▸ Swift File and click Next. Name the file AppDelegate+MenuBuilder.swift, make sure the Markup target is selected, and click Create.

Replace the content of the new file with this code:

import UIKit

extension AppDelegate {
  override func buildMenu(with builder: UIMenuBuilder) {
    //1
    guard builder.system == .main else { return }
    
    //2
    builder.remove(menu: .format)
    
    //3
    let selector = #selector(MarkupViewController.cleanDocumentAction)
    let clearEditor = UIKeyCommand(
      title: "Clear Editor",
      image: nil,
      action: selector,
      input: "k",
      modifierFlags: [.command],
      propertyList: nil)
    
    //4
    let menu = UIMenu(
      title: "",
      image: nil,
      identifier: UIMenu.Identifier("Open Doc"),
      options: .displayInline,
      children: [clearEditor])
    
    //5
    builder.insertChild(menu, atEndOfMenu: .edit)
  }
}

In this extension you override buildMenu(with:) in AppDelegate to change the main menu.

  1. Check that the menu is Main menu as opposed to a context menu.
  2. Remove the Format menu you don’t need.
  3. Create a UIKeyCommand instance using K as the key shortcut. The action calls out to cleanDocumentAction in MarkupViewController.
  4. Create a UIMenu instance with the new command.
  5. Add the new menu to the end of Edit menu.

Check that your target is set to My Mac. Build and run and you can see that the Format menu is gone and the Edit menu now has a Clear Editor action:

edit menu for the Mac app

As a bonus, you’re still improving your iPad app. Any iPad user with a hardware keyboard can use this key command too.

Adding a Toolbar

A toolbar holds UI that is relevant to the window, like this:

toolbar in the Mac app

In this section, you’ll shift the color selector, image picker and share buttons into the toolbar.

The first thing to do is to hide these elements from the main section of the app when running on macOS:

Mac app with toolbars

Hiding Unwanted UI

Open MarkupViewController.swift, and add two IBOutlet inside MarkupViewController, below the four other IBOutlet lines:

@IBOutlet weak var buttonStack: UIStackView!
@IBOutlet weak var colorStack: UIStackView!

Now open MarkupViewController.storyboard in Assistant Editor by holding down Option while clicking MarkupViewController.storyboard in Project navigator. You should now have MarkupViewController.swift on one side of your window and MarkupViewController.storyboard on the other.

Drag from the IBOutlet connection for buttonStack in MarkupViewController.swift to the UIStackView in the storyboard that holds the Choose Image… button.

assistant editor connecting button stack

Repeat for the color controls. Drag from the IBOutlet connection for colorStack in MarkupViewController.swift to UIStackView in the storyboard that holds the background color buttons and the share button.

assistant editor connecting color stack

You can now close Assistant Editor by clicking the close button at the top left of the split view.

close assitant editor

Still in MarkupViewController.swift, locate viewDidLoad() and add this code at the end of the method:

#if targetEnvironment(macCatalyst)
buttonStack.isHidden = true
colorStack.isHidden = true
#endif

You’re hiding these two stack views when running on macOS. The outer stack view that contains them will squish up to hide the spaces at runtime.

Build and run. You now have a very clean UI for your editor. It’s time to build the toolbar.

Mac app without toolbar

Building the Toolbar

You’re going to add another extension to MarkupViewController. Select the Primary Views folder in Project navigator and press Command-N to add a new file. Select iOS ▸ Swift File and click Next. Name the file MarkupViewController+NSToolbar.swift and click Create.

Add this code to the file:

import UIKit

#if targetEnvironment(macCatalyst)
extension MarkupViewController: NSToolbarDelegate {
  //1
  enum Toolbar {
    static let colors = NSToolbarItem.Identifier(rawValue: "colors")
    static let share = NSToolbarItem.Identifier(rawValue: "share")
    static let addImage = NSToolbarItem.Identifier(rawValue: "addImage")
  }
  
  //2
  func toolbar(
    _ toolbar: NSToolbar,
    itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier,
    willBeInsertedIntoToolbar flag: Bool) 
      -> NSToolbarItem? {
    //3
    if itemIdentifier == Toolbar.colors {
      let items = AppColors.colorSpace
        .enumerated()
        .map { (index, slice) -> NSToolbarItem in
          let item = NSToolbarItem()
          item.image = UIImage.swatch(slice.1)
          item.target = self
          item.action = #selector(colorSelectionChanged(_:))
          item.tag = index
          item.label = slice.0
          return item
        }
      
      let group = NSToolbarItemGroup(itemIdentifier: Toolbar.colors)
      group.subitems = items
      group.selectionMode = .momentary
      group.label = "Text Background"
      
      return group
    }
    //4
    else if itemIdentifier == Toolbar.addImage {
      let item = NSToolbarItem(itemIdentifier: Toolbar.addImage)
      item.image = UIImage(systemName: "photo")?.forNSToolbar()
      item.target = self
      item.action = #selector(chooseImageAction)
      item.label = "Add Image"
      
      return item
    }
    else if itemIdentifier == Toolbar.share {
      let item = NSToolbarItem(itemIdentifier: Toolbar.share)
      item.image = UIImage(systemName: "square.and.arrow.up")?.forNSToolbar()
      item.target = self
      item.action = #selector(shareAction)
      item.label = "Share Item"
      
      return item
    }
    
    return nil
  }
  
  //5
  func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar)
      -> [NSToolbarItem.Identifier] {
    return [Toolbar.colors, Toolbar.addImage, .flexibleSpace, Toolbar.share]
  }
  
  func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar)
      -> [NSToolbarItem.Identifier] {
    return self.toolbarDefaultItemIdentifiers(toolbar)
  }
  
  //6
  @objc func colorSelectionChanged(_ sender: NSToolbarItem) {
    guard let template = currentContent else {
      return
    }
    template.textBackgroundColor = AppColors.colors[sender.tag]
    currentContent = template
  }
}
#endif

This big chunk of code definitely needs some explanation, but worry not — most of it should be relatively straight forward. You made MarkupViewController conform to NSToolbarDelegate.

  1. Create the NSToolbarItem.Identifier items you’ll use in the extension.
  2. Implement toolbar(_:itemForItemIdentifier:willBeInsertedIntoToolbar:) from NSToolbarDelegate and, for each possible identifier, return an NSToolbarItem.
  3. The color selector is an NSToolbarItemGroup formed of NSToolbarItem, each representing a color swatch image.
  4. Add Image and Share items are instances of NSToolbarItem that use system icons. Note that NSToolbarItem images render as a 32-by-32 square regardless of the input image. The helper forNSToolbar() paints a source image into a new UIImage of that exact size with correct aspect ratio.
  5. toolbarDefaultItemIdentifiers(_:) and toolbarAllowedItemIdentifiers(_:) are delegate methods that describe what items should appear by default and what items are allowed in the toolbar.
  6. colorSelectionChanged(_:) handles the action to change the color from the toolbar.

Next, add this code to the end of MarkupViewController+NSToolbar.swift:

extension MarkupViewController {
  func buildMacToolbar() {
    #if targetEnvironment(macCatalyst)
    guard let windowScene = view.window?.windowScene else {
      return
    }
    
    if let titlebar = windowScene.titlebar {
      let toolbar = NSToolbar(identifier: "toolbar")
      toolbar.delegate = self
      toolbar.allowsUserCustomization = true
      titlebar.toolbar = toolbar
    }
    #endif
  }
}

Here, you create a helper method that hooks up your toolbar to an instance of UITitleBar, which is a property of UIWindowScene, and you get access to that from your application’s UIWindow.

Now you need to call buildMacToolbar() at some point when the app launches. Open MarkupViewController.swift and add this method to MarkupViewController, just below viewDidLoad():

override func viewDidAppear(_ animated: Bool) {
  super.viewDidAppear(animated)
  buildMacToolbar()
}

viewDidAppear(_:) gets called later in the the view lifecycle than viewDidLoad(). You need to wait until the window has appeared to gain access to UIWindowScene.

Build and run. You will see that your window gains a toolbar at the top of the window:

mac app with better toolbar

Click on the Color swatches, and the background color behind the rendered text will change accordingly.

You may notice the Share and Add Image buttons don’t work yet. That’s because UIPopover views need anchors ― a location to display popovers. The anchors for iOS are the buttons you hid at the start of the section.

Inside MarkupViewController, locate chooseImageAction(_:) and add the following code at the start of the method:

#if targetEnvironment(macCatalyst)
let source = titleField!
#else
let source = chooseImageButton!
#endif

Then change let helper = ImagingHelper(presenter: self, sourceview: chooseImageButton) to:

let helper = ImagingHelper(presenter: self, sourceview: source)

On macOS you present the UIImagePickerViewController popover on titleField.

Now locate shareAction(_:) in MarkupViewController and replace the line for activity.popoverPresentationController?.sourceView = sender with:

#if targetEnvironment(macCatalyst)
activity.popoverPresentationController?.sourceView = view
activity.popoverPresentationController?.sourceRect = CGRect(x: view.bounds.width - 200,y: 50,width: 1,height: 1)
#else
activity.popoverPresentationController?.sourceView = sender
#endif

Here you set the anchor for UIActivityViewController popover to be the top right of the main view when running on macOS. These two changes work but aren’t very attractive. They do show you the type of changes that you’ll need to do to adapt your app to Catalyst.

Build and run. Try out Add Image and Share buttons. Notice how UIActivityViewController doesn’t look like the version you have on iOS. You receive a NSSharingServicePicker instead:

share menu in Mac app

Catalyst provides the bridging. You’ll notice more of these bridges from UIKit to AppKit as you continue your adventures. Your app now looks as if it belongs on macOS.

Installing a Mac App Icon

By default, Catalyst uses the iOS icon in the dock and Finder, but your iOS icon may look a bit flat on macOS. You can supply a dedicated icon for macOS.

Mac app with iOS icon set

Locate and open Assets.xcassets in Resources subfolder from the Project navigator. In the asset list select AppIcon. The attributes inspector provides the option to have dedicated Mac resources:

asset catalog in Xcode

When you check that box extra slots open up in the asset description:

asset catalog with Mac icons

You can place your dedicated icon resources there. To save you some time, there’s a pre-built set in the materials folder that you downloaded to start the tutorial, and to make it even easier, you’re going to replace the entire AppIcon set instead of updating them one at a time. Locate the assets folder inside the materials folder. There’s a folder called AppIcon.appiconset inside.

  • In the Xcode assets inspector, select AppIcon asset in the left sidebar and press delete on your keyboard to remove it.
  • Drag AppIcon.appiconset from Finder to Xcode into the assets inspector left sidebar.

This should restore your AppIcon asset list with a complete set of both Mac and iOS icons.

Note: Older versions of Xcode 11 have a bug that will trigger a warning about missing icon sizes. This problem is fixed in Xcode 11.2.1.

Build and run to see your new, shiny, Mac-specific icon in the dock!

Mac app with Mac icons

Configuring the Touch Bar

Some MacBook Pros have a touch bar screen at the top of the keyboard for extra contextual UI. You can configure the touch bar using the NSTouchBar API. For now Catalyst exposes some but not all the NSTouchBar functionality. You need to use the inbuilt set of widgets. Trying to use NSCustomTouchBarItem results in a compiler error.

NSCustomTouchBarItem is unavailable in Mac catalyst

In this section, you’ll add a color picker to allow you to pick custom colors for the text background.

The first step is to add another extension to MarkupViewController. Select the Primary Views folder and add a new file by pressing Command-N. Select iOS ▸ Swift File and click Next. Name the file MarkupViewController+NSTouchbar.swift and click Create.

Add this code to the new file:

import UIKit

#if targetEnvironment(macCatalyst)

//1
let ColorPickerTouchBarIdentifier = NSTouchBarItem.Identifier("colorpicker")

//2
extension MarkupViewController: NSTouchBarDelegate {
  //3
  override func makeTouchBar() -> NSTouchBar? {
    let tbar = NSTouchBar()
    tbar.defaultItemIdentifiers = [ColorPickerTouchBarIdentifier]
    tbar.delegate = self
    return tbar
  }
  
  //4
  func touchBar(
    _ touchBar: NSTouchBar, 
    makeItemForIdentifier identifier: NSTouchBarItem.Identifier) 
      -> NSTouchBarItem? {
    if identifier == ColorPickerTouchBarIdentifier {
      let item = NSColorPickerTouchBarItem(
        identifier: ColorPickerTouchBarIdentifier)
      item.target = self
      item.action = #selector(updateBackgroundColor)
      return item
    }
    return nil
  }
  
  //5
  @objc func updateBackgroundColor(_ sender: NSColorPickerTouchBarItem) {
    guard let template = currentContent else {
      return
    }
    template.textBackgroundColor = sender.color
    currentContent = template
  }
}

#endif

In this extension, you do the following:

  1. Create an NSTouchBarItem.Identifier to identify your widget.
  2. Conform MarkupViewController to NSTouchBarDelegate.
  3. Override makeTouchBar() to allow your view controller to construct your custom touch bar. In this case, you have an array of one identifier.
  4. Implement touchBar(_: makeItemForIdentifier:) from NSTouchBarDelegate and return a NSTouchbarItem for the identifiers that you registered in makeTouchBar().
  5. Create the action to change the color when you press the item.

show Touch Bar via Xcode, Window, Show Touch Bar menu

If you don’t have a MacBook with a touch bar, you can show a virtual touch bar from Xcode ▸ Window ▸ Show Touch Bar.

Build and run. Tap on the Color button to select an arbitrary color for the background behind the rendered text. Apple provides some built-in color swatches.

Note: The touch bar feature you just created violates Apple’s touch bar guidelines because you supply a UI item that is only available on the touch bar. In a real-world application, you should make arbitrary color selection part of the main app UI too. You can learn more about Touch Bar design guidelines in Apple Human Interface Guidelines: Touch Bar.

Where to Go From Here?

The final version of the project is available via the Download Materials button at the top or bottom of the tutorial. In this tutorial you covered:

  • Converting your project to build for Catalyst.
  • Free cross-platform behavior like drag and drop.
  • Modifying the UI to be more Mac-like.
  • Implementing some Mac specific features such as NSToolbar & NSTouchbar.

Some areas you could continue to explore are accessibility features like Dynamic Type for iOS and the new voice control API for macOS.

Catalyst is a new system for developers, and there’s no doubt Apple will continue to improve and add new features to the system.

Do try to view the following WWDC 2019 sessions that cover all the aspects of creating a great experience on both platforms.

If you have any questions or comments about this tutorial, please leave them in the comments below, or in the forums!