23

Menus and Popovers in Menu Bar Apps for macOS

 3 years ago
source link: https://www.raywenderlich.com/450-menus-and-popovers-in-menu-bar-apps-for-macos
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client
Home macOS Tutorials

Menus and Popovers in Menu Bar Apps for macOS

In this Menu Bar App tutorial you will learn how to present a menu and a popover that shows quotes from famous people.

By Warren Burton Aug 18 2017 · Article (25 mins) · Beginner

4.6/5 19 Ratings

Version

Update note: This Menus and Popovers in Menu Bar Apps for macOS tutorial has been updated to Xcode 9 and Swift 4 by Warren Burton. The original tutorial was written by Mikael Konutgan.

Menu bar apps have been staple of macOS for a long time. Many apps like 1Password and Day One have companion menu bar apps. Others like Fantastical live exclusively in macOS’s menu bar.

In this menu bar app tutorial, you’ll build a menu bar app that shows inspirational quotes in a popover. While you do this, you’ll learn:

  • How to create a menu bar icon
  • How to make the app live exclusively in the menu bar
  • How to add a menu for the user
  • How to make a popover that shows on demand and hides when the user moves on — aka Event Monitoring
Note: This tutorial assumes you’re familiar with Swift and macOS. If you need a refresher, start with our macOS Development for Beginners tutorial for a great introduction.

Getting Started

Fire up Xcode. Go to File/New/Project… then select the macOS/Application/Cocoa App template and click Next.

On the next screen, enter Quotes as the Product Name, choose your desired Organization Name and Organization Identifier. Then make sure that Swift is selected as the language, and that Use Storyboards is checked. Uncheck Create Document-Based Application, Use Core Data, Include Unit tests and Include UI Tests.

Finally, click Next again, choose a place to save the project and click Create.

Once the new project is set up, open AppDelegate.swift and add the following property to the class:

let statusItem = NSStatusBar.system.statusItem(withLength:NSStatusItem.squareLength)

This creates a Status Item — aka application icon — in the menu bar with a fixed length that the user will see and use.

Next, you’ll need to associate an image to the status item to make your app recognizable in the menu bar.

Go to Assets.xcassets in the project navigator, download this image [email protected] and drag it into the asset catalog.

Select the image and open the attributes inspector. Change the Render As option to Template Image.

If you use your own custom image, make sure that the image is black and white and configured as a template image so the Status Item looks great against both light and dark menu bars.

Back in AppDelegate.swift, add the following code to applicationDidFinishLaunching(_:)

if let button = statusItem.button {
  button.image = NSImage(named:NSImage.Name("StatusBarButtonImage"))
  button.action = #selector(printQuote(_:))
}

This will configure the status item with an icon of the image you just added, and an action for when you click on the item. This will create an error but you’ll fix that now.

Add the following method to the class:

@objc func printQuote(_ sender: Any?) {
  let quoteText = "Never put off until tomorrow what you can do the day after tomorrow."
  let quoteAuthor = "Mark Twain"
  
  print("\(quoteText) — \(quoteAuthor)")
}

This method will simply log out the quote text to the console.

Take note of the @objc directive in the signature. This exposes the method to the Objective-C runtime to allow the button to use it as an action.

Build and run the app, and you should see a new menu bar app available. You did it!

Note: If you have too many menu bar apps, you might not be able to see your button. Switch to an app with fewer menus than Xcode (like Finder) and you should be able to see it.

Every time you click on the menu bar icon, you’ll see the quote printed out in the Xcode console.

Hiding the Dock Icon and Main Window

There are still two small things to do before you have a functional menu bar app.

  1. Disable the dock icon.
  2. Remove the main window.

To disable the dock icon, open Info.plist. Add a new key Application is agent (UIElement) and set its value to YES.

Application-Is-Agent-YES.png

Note: If you’re an expert plist editor, feel free to set this manually with the key LSUIElement.

Now it’s time to handle the main window.

  • Open Main.storyboard
  • Select the Window Controller scene and delete it.
  • Leave the View Controller scene alone as you are going to use it soon.

Build and run. You’ll see the app has no main window, no pesky dock icon and only a tidy status item in the menu bar. High five yourself :]

Adding a Menu to the Status Item

Usually, a measly single action on click is not enough for a menu bar app. The easiest way to add more functionality to your app is to add a menu. Add the following function to the end of AppDelegate.

func constructMenu() {
  let menu = NSMenu()

  menu.addItem(NSMenuItem(title: "Print Quote", action: #selector(AppDelegate.printQuote(_:)), keyEquivalent: "P"))
  menu.addItem(NSMenuItem.separator())
  menu.addItem(NSMenuItem(title: "Quit Quotes", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q"))

  statusItem.menu = menu
}

and then add this call to the end of applicationDidFinishLaunching(_:)

constructMenu()

Here you create an NSMenu, add 3 instances of NSMenuItem to it, and then set the status item’s menu to that new menu.

A few things to note here:

  • The title of a menu item is the text that appears in the menu. This is a good point for localization if needed.
  • The action, like the action of a button or any control, is the method that gets called when you click the menu item.
  • The keyEquivalent is a keyboard shortcut that you can use to activate the menu item. A lowercase letter uses Cmd as the modifier key and an uppercase letter uses Cmd+Shift. This keyboard shortcut only works if the application is front-most and active. So, in this case, the menu or any other window needs to be visible, since the app has no dock icon.
  • A separatorItem is a stock inactive menu item that appears as a simple gray line between other menu items. Use it to group functionality in the menu.
  • The printQuote: action is the method you already defined in AppDelegate while terminate: is an action method defined by NSApplication.

Build and run, and you should see a menu when clicking on the status item. Progress!

Try out your options – selecting Print Quote will display the quote in the Xcode console, while Quit Quotes will quit the app.

Adding a Popover to the Status Item

You’ve seen how easy it is to set up a menu from code, but showing the quote in the Xcode console won’t cut it for most of your end users. The next step is to replace the menu with a simple view controller to show a quote right in place.

Go to File/New/File…, select the macOS/Source/Cocoa Class template and click Next.

  • Name the class QuotesViewController.
  • Make it a subclass of NSViewController.
  • Ensure that Also create XIB file for user interface is not checked.
  • Set the language to Swift.

Finally, click Next again, choose a place to save the file (In the Quotes subfolder of the project folder is a good place) and click Create.

Now open Main.storyboard. Expand the View Controller Scene and select the View Controller instance.

First select the Identity Inspector and change the Class to QuotesViewController, next set the Storyboard ID to QuotesViewController

Next add the following code to the end of QuotesViewController.swift

extension QuotesViewController {
  // MARK: Storyboard instantiation
  static func freshController() -> QuotesViewController {
    //1.
    let storyboard = NSStoryboard(name: NSStoryboard.Name(rawValue: "Main"), bundle: nil)
    //2.
    let identifier = NSStoryboard.SceneIdentifier(rawValue: "QuotesViewController")
    //3.
    guard let viewcontroller = storyboard.instantiateController(withIdentifier: identifier) as? QuotesViewController else {
      fatalError("Why cant i find QuotesViewController? - Check Main.storyboard")
    }
    return viewcontroller
  }
}

What happens here is…

  1. Get a reference to Main.storyboard.
  2. Create a Scene identifier that matches the one you set just before.
  3. Instantiate QuotesViewController and return it.

You create this method so that anything thats using QuotesViewController doesn’t need to know how to instantiate it. It just works :]

Notice the fatalError inside the guard statement. Its often good to use this or assertionFailure to let yourself or other team members know when you have messed up during development.

Now go back to AppDelegate.swift. Start by adding a new property declaration to the class:

let popover = NSPopover()

Next, replace applicationDidFinishLaunching(_:) with the following:

func applicationDidFinishLaunching(_ aNotification: Notification) {
  if let button = statusItem.button {
    button.image = NSImage(named:NSImage.Name("StatusBarButtonImage"))
    button.action = #selector(togglePopover(_:))
  }
  popover.contentViewController = QuotesViewController.freshController()
}

You’ve changed the button action to togglePopover(_:) which you’ll implement next. Also, rather than set up a menu, you’re setting up the popover to show whatever’s in QuotesViewController.

Add the following three methods to AppDelegate

@objc func togglePopover(_ sender: Any?) {
  if popover.isShown {
    closePopover(sender: sender)
  } else {
    showPopover(sender: sender)
  }
}

func showPopover(sender: Any?) {
  if let button = statusItem.button {
    popover.show(relativeTo: button.bounds, of: button, preferredEdge: NSRectEdge.minY)
  }
}

func closePopover(sender: Any?) {
  popover.performClose(sender)
}

showPopover() displays the popover to the user. You just need to supply a source rect and macOS will position the popover and arrow so it looks like it’s coming out of the menu bar icon.

closePopover() simply closes the popover, and togglePopover() is the action method that will either open or close the popover depending on its current state.

Build and run, and then click on the menu bar icon to check that it shows and then hides an empty popover.

Your popover works great, but where’s all the inspiration? All you see is an empty view and no quotes. Guess what you’ll do next?

Implementing the Quote View Controller

First, you need a model to store the quotes and attributions. Go to File/New/File… and select the macOS/Source/Swift File template, then click Next. Name the file Quote and click Create.

Open Quote.swift and add the following code to the file:

struct Quote {
  let text: String
  let author: String
  
  static let all: [Quote] =  [
    Quote(text: "Never put off until tomorrow what you can do the day after tomorrow.", author: "Mark Twain"),
    Quote(text: "Efficiency is doing better what is already being done.", author: "Peter Drucker"),
    Quote(text: "To infinity and beyond!", author: "Buzz Lightyear"),
    Quote(text: "May the Force be with you.", author: "Han Solo"),
    Quote(text: "Simplicity is the ultimate sophistication", author: "Leonardo da Vinci"),
    Quote(text: "It’s not just what it looks like and feels like. Design is how it works.", author: "Steve Jobs")
  ]
}

extension Quote: CustomStringConvertible {
  var description: String {
    return "\"\(text)\" — \(author)"
  }
}

This defines a simple quote structure and a static property that returns all the quotes. Since you also make Quote conform to CustomStringConvertible, you can easily get a nice formatted string.

You’re making progress, but you now need some function in the UI to display all these famous quotes.

Setting up the View Controller UI

Open Main.storyboard and drag 3 Push Button instances, and a Multiline Label into the view controller.

Drag the buttons and label into place until they look like this layout. The dotted blue layout guides that appear will help you get the items into place:

Can you add the auto layout constraints to make the user interface match? Give it a few good attempts before you open the spoiler below. If you get it right, skip the spoiler and give yourself a gold star.

Once you have the layout setup to your satisfaction set up the elements like this:

  • Set the left button’s image to NSGoLeftTemplate and delete the title.
  • Set the right button’s image to NSGoRightTemplate and delete the title.
  • Set the title of the lower push button to Quit Quotes.
  • Set the label’s text alignment to center.
  • Check that Line Break for the label is set to Word Wrap.

Now open QuotesViewController.swift and add the the following code to the class implementation of QuotesViewController:

@IBOutlet var textLabel: NSTextField!

Add this extension after the class implementation. You will now have two extensions in QuotesViewController.swift.

// MARK: Actions

extension QuotesViewController {
  @IBAction func previous(_ sender: NSButton) {
  }

  @IBAction func next(_ sender: NSButton) {
  }

  @IBAction func quit(_ sender: NSButton) {
  }
}

You have just added an outlet for the text label, which you’ll use to display the inspirational quote and 3 stub actions which you will connect to the 3 buttons.

Connect code to Interface Builder

You’ll notice that Xcode has placed circles in the left hand margin of your source editor. The circles are handles that appear when you use the @IBAction and @IBOutlet keywords.

You will now use them to connect your code to the UI.

While holding down alt click on Main.storyboard in the project navigator. This should open the storyboard in the Assistant Editor on the right and the source on the left.

Drag from the circle next to textLabel to the label in interface builder. In the same way connect the previous, next and quit actions to the left, right and bottom buttons respectively.

Note: If you have trouble with any of the above steps, refer to our library of macOS tutorials, where you’ll find introductory tutorials that will walk you through many aspects of macOS development, including adding views/constraints in interface builder and connecting outlets and actions.

Stand up, stretch and maybe do a quick victory lap around your desk because you just flew through a bunch of interface builder work.

Build and run, and your popover should look like this now:

You used the default size of the view controller for the popover above. If you want a smaller or bigger popover, all you need to do is resize the view controller in the storyboard.

The interface is finished, but you’re not done yet. Those buttons are waiting on you to know what to do when the user clicks them — don’t leave them hanging.

Create actions for the buttons

If you haven’t already dismiss the Assistant Editor with Cmd-Return or View > Standard Editor > Show Standard Editor

Open QuotesViewController.swift and add the following properties to the class implementation:

let quotes = Quote.all

var currentQuoteIndex: Int = 0 {
  didSet {
    updateQuote()
  }
}

The quotes property holds all the quotes, and currentQuoteIndex holds the index of the current quote displayed. currentQuoteIndex also has a property observer to update the text label string with the new quote when the index changes.

Next, add the following methods to the class:

override func viewDidLoad() {
  super.viewDidLoad()
  currentQuoteIndex = 0
}

func updateQuote() {
  textLabel.stringValue = String(describing: quotes[currentQuoteIndex])
}

When the view loads, you set the current quote index to 0, which in turn updates the user interface. updateQuote() simply updates the text label to show whichever quote is currently selected according to currentQuoteIndex.

To tie it all together, update the three action methods as follows;

@IBAction func previous(_ sender: NSButton) {
  currentQuoteIndex = (currentQuoteIndex - 1 + quotes.count) % quotes.count
}

@IBAction func next(_ sender: NSButton) {
  currentQuoteIndex = (currentQuoteIndex + 1) % quotes.count
}

@IBAction func quit(_ sender: NSButton) {
  NSApplication.shared.terminate(sender)
}

In next() and previous(), you cycle through the all the quotes and wrap around when you reach the ends of the array. quit terminates the app.

Build and run again, and now you can cycle back and forward through the quotes and quit the app!

Event Monitoring

There is one feature your users will want in your unobtrusive, small menu bar app, and that’s when you click anywhere outside the app, the popover automatically closes.

Menu bar apps should open the UI on click, and then disappear once the user moves onto the next thing. For that, you need an macOS global event monitor.

Next you’ll make an event monitor thats reusable in all your projects and then use it when showing the popover.

Bet you’re feeling smarter already!

osx-triumphant-1-320x320.png

Create a new Swift File and name it EventMonitor, and then replace its contents with the following class definition:

import Cocoa

public class EventMonitor {
  private var monitor: Any?
  private let mask: NSEvent.EventTypeMask
  private let handler: (NSEvent?) -> Void

  public init(mask: NSEvent.EventTypeMask, handler: @escaping (NSEvent?) -> Void) {
    self.mask = mask
    self.handler = handler
  }

  deinit {
    stop()
  }

  public func start() {
    monitor = NSEvent.addGlobalMonitorForEvents(matching: mask, handler: handler)
  }

  public func stop() {
    if monitor != nil {
      NSEvent.removeMonitor(monitor!)
      monitor = nil
    }
  }
}

You initialize an instance of this class by passing in a mask of events to listen for – things like key down, scroll wheel moved, left mouse button click, etc – and an event handler.

When you’re ready to start listening, start() calls addGlobalMonitorForEventsMatchingMask(_:handler:), which returns an object for you to hold on to. Any time the event specified in the mask occurs, the system calls your handler.

To remove the global event monitor, you call removeMonitor() in stop() and delete the returned object by setting it to nil.

All that’s left is calling start() and stop() when needed. How easy is that? The class also calls stop() for you in the deinitializer, to clean up after itself.

Connect the Event Monitor

Open AppDelegate.swift one last time, and add a new property declaration to the class:

var eventMonitor: EventMonitor?

Next, add the code to configure the event monitor at the end of applicationDidFinishLaunching(_:)

eventMonitor = EventMonitor(mask: [.leftMouseDown, .rightMouseDown]) { [weak self] event in
  if let strongSelf = self, strongSelf.popover.isShown {
    strongSelf.closePopover(sender: event)
  }
}

This notifies your app of any left or right mouse down event and closes the popover when the system event occurs. Note that your handler will not be called for events that are sent to your own application. That’s why the popover doesn’t close when you click around inside of it. :]

You use a weak reference to self to avoid a potential retain cycle between AppDelegate and EventMonitor. It’s not essential in this particular situation because there’s only one setup cycle but is something to watch out for in your own code when you use block handlers between objects.

Add the following code to the end of showPopover(_:):

eventMonitor?.start()

This will start the event monitor when the popover appears.

Then, you’ll need to add the following code to the end of closePopover(_:):

eventMonitor?.stop()

This will stop the event monitor when the popover closes.

All done! Build and run the app one more time. Click on the menu bar icon to show the popover, and then click anywhere else and the popover closes. Awesome!

Where To Go From Here?

Here is the final project with all of the code you’ve developed in the above tutorial.

You’ve seen how to set up both menus and popovers in your menu bar status items – why not keep experimenting with using attributed text or multiple labels to style the quote, or connecting to a web backend to pull new quotes. Maybe you can discover how to use the keyboard to cycle through the quotes.

A good place to look for other possibilities is reading the official documentation for NSMenu, NSPopover and NSStatusItem.

One thing to consider is that you are asking your customers for a very privileged piece of screen real estate so while you might think its cool to have a status bar item your users might not feel the same way. A lot of apps manage this by providing preferences to show or hide the item. You can use that as an advanced exercise for yourself.

Thanks for taking the time to learn how to make a cool popover menu app for macOS. For now, it’s pretty simple, but you can see that the concepts you’ve learned here are an excellent foundation for a variety of apps.

If you have any questions, awesome discoveries or ideas you’d like to bounce off others as you configure status items, menus or popovers in your apps, please let me know in the forum discussion below! :]

raywenderlich.com Weekly

The raywenderlich.com newsletter is the easiest way to stay up-to-date on everything you need to know as a mobile developer.

Get a weekly digest of our tutorials and courses, and receive a free in-depth email course as a bonus!

Average Rating

4.6/5

Add a rating for this content

Sign in to add a rating
19 ratings

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK