Home iOS & Swift Books Catalyst by Tutorials

11
Barista Training: Toolbar Written by Andy Pereira

Toolbars are an essential part of macOS applications. Without a doubt, NSToolbar is used so ubiquitously across so many apps that most users may overlook its presence. Because of this, it’s essential to understand what you get when you use a toolbar and how toolbars behave. By adopting NSToolbar in your app, you have access to almost two decades worth of work from the smart developers and designers at Apple.

Getting started

Open the starter project for this chapter. Select My Mac for the active scheme, and Build and run. At the moment, this project has a split view controller and can handle multiple windows:

Adding the toolbar

NSToolbar is a macOS-specific control. In the past, you probably had to use macros or targets to ensure frameworks did not get imported into unsupported builds. With Catalyst, you’ll need to be able to integrate your macOS, iOS and iPadOS code more seamlessly.

To add the toolbar, open SceneDelegate.swift and add the following to the end of scene(_:willConnectTo:options:):

#if targetEnvironment(macCatalyst)
// 1
if let scene = scene as? UIWindowScene,
  let titlebar = scene.titlebar {
  // 2
  let toolbar = NSToolbar(identifier: "Toolbar")
  // 3
  titlebar.toolbar = toolbar
}
#endif

Here’s what you’ve done:

  1. You check that the scene has a titlebar. This property will be present if the app is running inside of a macOS environment.
  2. Then you create a toolbar with an identifier. Every one of the toolbars will have the same identifier so that the system synchronizes their state across windows.
  3. Last, you set the toolbar on the titlebar.

Build and run, and you’ll see a beautiful — albeit empty — toolbar.

It doesn’t make sense to keep the navigation bars around any longer, as the toolbar will serve the same purpose.

To get rid of both of the navigation bars, open MainTableViewController.swift and add the following to the end of viewDidLoad():

#if targetEnvironment(macCatalyst)
navigationController?.navigationBar.isHidden = true
#endif

Then, open EntryTableViewController.swift and add the same line of code to the existing macro. Build and run, and now your navigation bars are gone, leaving just a toolbar:

Adding buttons

In its current state, the toolbar is providing no functionality to the app. To start adding functionality, you’ll be adding a few buttons.

Open SceneDelegate.swift and add the following after setting the toolbar on the titlebar:

toolbar.delegate = self

Next, at the end of the file, inside the empty macro that checks for Catalyst, add the following:

extension NSToolbarItem.Identifier {
  static let addEntry = 
    NSToolbarItem.Identifier(rawValue: "AddEntry")
  static let deleteEntry = 
    NSToolbarItem.Identifier(rawValue: "DeleteEntry")
  static let shareEntry =
    NSToolbarItem.Identifier(rawValue: "ShareEntry")
}

extension SceneDelegate: NSToolbarDelegate {
}

These three toolbar identifiers are needed to start adding buttons to the toolbar. Just like you added an identifier to the toolbar for the system to know how to sync across windows, these identifiers allow the toolbar to know what is added to itself.

Next, add the following to the NSToolbarDelegate extension:

func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar)
  -> [NSToolbarItem.Identifier] {
    return [.toggleSidebar, .addEntry, .deleteEntry, .shareEntry, .flexibleSpace]
}

By adding this, you tell the toolbar which identifiers are allowed to be in the toolbar. You’ll notice that the three identifiers are what you added above.

The first item, .toggleSidebar is a convenience button for showing and hiding your sidebar. The last one, .flexibleSpace, is a system-defined identifier that places a blank item that used to automatically adjust its spacing. As of macOS 11, it just adds a divider line.

Now, add the following method:

func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) 
  -> [NSToolbarItem.Identifier] {
  return [.toggleSidebar, .addEntry, .shareEntry]
}

Adding toolbarDefaultItemIdentifiers(_:) will inform the toolbar what should initially be displayed in itself. Also, later on, when you start customizing the toolbar, it will provide the user a way to reset the toolbar to the initial state.

You’re almost there, but there are a few more steps before you can add the buttons. Add the following methods inside the same extension:

// 1. 
func toolbar(_ toolbar: NSToolbar,
  itemForItemIdentifier itemIdentifier: 
  NSToolbarItem.Identifier,
  willBeInsertedIntoToolbar flag: Bool) -> NSToolbarItem? {
    var item: NSToolbarItem?
    return item
}

// 2. 
@objc private func addEntry() {
}
  
@objc private func deleteEntry() {
}

Here’s what you’ve added:

  1. This method will provide the actual buttons to a toolbar. It is currently incomplete and will be finished shortly.
  2. These methods are for convenience right now and will be finished later.

At last, you’re ready to add the buttons.

Add the following property to SceneDelegate:

#if targetEnvironment(macCatalyst)
private let shareItem = 
  NSSharingServicePickerToolbarItem(itemIdentifier: .shareEntry)
#endif

Replace toolbar(_:itemForItemIdentifier:willBeInsertedIntoToolbar:) with the following:

func toolbar(_ toolbar: NSToolbar, 
  itemForItemIdentifier itemIdentifier: 
  NSToolbarItem.Identifier, 
  willBeInsertedIntoToolbar flag: Bool) -> NSToolbarItem? {
  
  var item: NSToolbarItem?
  switch itemIdentifier {
  case .addEntry:
    item = NSToolbarItem(itemIdentifier: .addEntry)
    item?.image = UIImage(systemName: "plus")
    item?.label = "Add"
    item?.toolTip = "Add Entry"
    item?.target = self
    item?.action = #selector(addEntry)
  case .deleteEntry:
    item = NSToolbarItem(itemIdentifier: .deleteEntry)
    item?.image = UIImage(systemName: "trash")
    item?.label = "Delete"
    item?.toolTip = "Delete Entry"
    item?.target = self
    item?.action = #selector(deleteEntry)
  case .shareEntry:
    return shareItem
  case .toggleSidebar:
    item = NSToolbarItem(itemIdentifier: itemIdentifier)
  default:
    item = nil
  }
  return item
}

You add custom buttons to the toolbar by checking the identifier passed in and returning the appropriate button. Here are the buttons you added:

  1. Add: This will add a new journal entry to the app.
  2. Delete: Based on the active window, it will delete the selected entry.
  3. Share: Just like delete, it will share the active window’s current entry.

Build and run. Because your default buttons are just Add and Share, you will only see two buttons on the right initially:

OK. You’re now officially past the hardest part of working with toolbars in this tutorial!

Customizing the toolbar

Toolbars don’t always have to contain a fixed set of buttons. Above, you provided a delete button without giving the user a way to see it. You can enable your toolbar to be customized by the user, and save its state between launches.

Still within SceneDelegate.swift, add the following lines of code after you set the delegate for the toolbar inside of scene(_:willConectTo:options:):

toolbar.allowsUserCustomization = true
toolbar.autosavesConfiguration = true

By setting allowsUserCustomization, you enable users to customize their toolbar by right-clicking on it. Also, autosavesConfiguration will determine if the system should save the toolbar configuration to NSUserDefaults, persisting the user’s preferences between runs.

Build and run, then right-click on your toolbar. You’ll see an option to Customize Toolbar:

Select this option, and you’ll see a modal window in which you can customize the toolbar:

Try changing the configuration, then quit the application and restart it. Your changes should remain between runs.

Finally, you can also remove the title bar from the window, making the toolbar a bit smaller. To test this, add the following to scene(_:willConnectTo:options:), after setting the delegate:

titlebar.titleVisibility = .hidden

Build and run to see how this looks:

Notice that the labels and the title in the window are now gone. You can remove this line of code for the remainder of the tutorial.

Responding to actions

The last thing you need to do is respond to actions in the toolbar. To add items to the list, replace the empty implementation of addEntry() with the following:

@objc private func addEntry() {
  guard
    let splitViewController
      = window?.rootViewController
      as? UISplitViewController,
    let navigationController
      = splitViewController.viewControllers.first
      as? UINavigationController,
    let mainTableViewController
      = navigationController.topViewController
      as? MainTableViewController else {
    return
  }
  DataService.shared.addEntry(Entry())
  let index = DataService.shared.allEntries.count - 1
  mainTableViewController.selectEntryAtIndex(index)
}

This code will add a new entry to your data model, and then select it in the list.

Build and run, then select Add. You should see entries get added to the list:

Next, implement deleteEntry() with the following:

guard let splitViewController =
  window?.rootViewController as? UISplitViewController,
  let navigationController =
    splitViewController.viewControllers.first
    as? UINavigationController,
  let mainTableViewController =
    navigationController.topViewController
    as? MainTableViewController,
  let secondaryViewController =
    splitViewController.viewControllers.last
    as? UINavigationController,
  let entryTableViewController =
    secondaryViewController.topViewController
    as? EntryTableViewController,
  let entry = entryTableViewController.entry,
  let index = DataService.shared.allEntries
    .firstIndex(of: entry) else { return }
DataService.shared.removeEntry(atIndex: index)
mainTableViewController.selectEntryAtIndex(index)

This isn’t as complicated as it may first appear. Because your app can have multiple windows, you need to check which entry is selected in the window you select Delete in. This code simply goes through the hierarchy of the active window and removes the appropriate entry.

Build and run. Ensure you have the delete button in your toolbar, add a few entries, then select Delete.

Sharing

For the final toolbar item, Share, you’ll need to do a few more steps. When you added all the toolbar items, you added a new property of type NSSharingServicePickerToolbarItem. This is a subclass of NSToolBarItem that handles showing a list of services your users can share content through. To get this working takes a few steps.

First, import Combine in SceneDelegate.swift:

import Combine

Next, add a new property to SceneDelegate:

private var activityItemsConfigurationSubscriber: AnyCancellable?

This property will listen for notifications that will be sent when entries are selected in the main list, or as text is entered for an entry. To setup the subscription, add the following in SceneDelegate.swift, after the lines of code you set the delegate for the toolbar inside of scene(_:willConectTo:options:):

activityItemsConfigurationSubscriber
  = NotificationCenter.default
  .publisher(for: .ActivityItemsConfigurationDidChange)
  .receive(on: RunLoop.main)
  .map({
      $0.userInfo?[NotificationKey.activityItemsConfiguration] 
        as? UIActivityItemsConfiguration
  })
  .assign(to: \.activityItemsConfiguration,
          on: shareItem)

If you’re unfamiliar with Combine, all you need to understand is the following:

  • You setup activityItemsConfigurationSubscriber to listen for the notification ActivityItemsConfigurationDidChange.
  • When it receives the notification, handle any actions on the main thread.
  • Grab the activityItemsConfiguration that was sent in the notification, and assign it to your Share button’s activityItemsConfiguration.

At the moment, your Share item is always disabled. It should remain disabled until an entry is in a state to be shared.

To handle this behavior, and setup the configuration that your button is expecting, open EntryTableViewController.swift and add the following extension to the end of the file:

extension EntryTableViewController {
  private func configureActivityItems() {
    let configuration
      = UIActivityItemsConfiguration(objects: [])
    // 1. 
    configuration.metadataProvider = { key in
      // 2.
      guard let shareText
              = self.shareText else { return nil }
      switch key {
      // 3.
      case .title, .messageBody:
        return shareText
      default:
        return nil
      }
    }
    // 4.
    NotificationCenter
      .default
      .post(name: .ActivityItemsConfigurationDidChange,
            object: self,
            userInfo: [NotificationKey
                        .activityItemsConfiguration: configuration])
  }
}

Here’s a breakdown of what you’ve added:

  1. You create a UIActivityItemsConfiguration and setup what needs to be provided through the metadataProvider. This will inform the service that handles sharing your content what needs to be shared.
  2. Ensure that you have text to share, otherwise don’t allow content to be shared.
  3. Set your entry’s text as the content to be shared.
  4. Post a notification with the new configuration. This is the notification and configuration content you setup in the previous step.

There’s two places you’ll want to call configureActivityItems(). First, add the following to the main body of EntryTableViewController:

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

This will handle notifying your toolbar item to update whenever you switch entries.

Finally, replace textViewDidChange(_:) with the following:

func textViewDidChange(_ textView: UITextView) {
  validateState()
  configureActivityItems()
}

Now, when you start entering any text for an entry, the share button will be updated with your latest content.

Build and run, add some text to an entry, then select Share.

Key points

  • Use Mac style toolbars, not iOS navigation bars in macOS apps.
  • Toolbars are for the entire window, not just the specific view controller presented to the user.
  • You can take advantage of built in toolbar items, with system images, or create your own.
  • Users are used to customizing toolbars in many apps. Ensure you provide this capability, as it makes sense.

Where to go from here?

This chapter showed you how quick it is to implement a macOS-centric design in a way that was never so easy. While knowing how to implement your own toolbar items is important, don’t forget there are several other system-provided toolbar items provided that you can put to use as well.

You can learn more about these topics from Apple’s website at https://developer.apple.com/documentation/appkit/nstoolbaritem/identifier.

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.

Have feedback to share about the online reading experience? If you have feedback about the UI, UX, highlighting, or other features of our online readers, you can send them to the design team with the form below:

© 2020 Razeware LLC