[Перевод] Приложение в строке меню для macOS

Приложения, размещенные в строке меню, уже давно известны пользователям macOS. У некоторых из этих приложений есть «обычная» часть, другие размещены только в строке меню.
В этом руководстве вы напишете приложение, которое показывает во всплывающем окне несколько цитат известных людей. В процессе создания этого приложения вы научитесь:

  • назначать иконку приложения в строке меню
  • делать приложение размещенным только в строке меню
  • добавлять пользовательское меню
  • показывать всплывающее по запросу пользователя окно и прятать его, когда необходимо, используя Event Monitoring


Замечание: это руководство предполагает, что вы знакомы со Swift и macOS.


Начинаем


Запускаем Xcode. Далее в меню File/New/Project…, выберите шаблон macOS/Application/Cocoa App и нажмите Next.

На следующем экране введите Quotes в качестве Product Name, выберите ваши Organization Name и Organization Identifier. Затем удостоверьтесь, что в качестве языка приложения выбран Swift и отмечен чекбокс Use Storyboards. Снимите отметку с чекбоксов Create Document-Based Application, Use Core Data, Include Unit tests и Include UI Tests.

4c_fg6hmxqq0k_vqkm8-zqrt_ok.png

Наконец, кликните ещё раз Next, укажите место для сохранения проекта и кликните Create.
Как только новый проект будет создан, откройте AppDelegate.swift и добавьте к классу следующее свойство:

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


Тут мы создаём в строке меню Status Item (иконку приложения) фиксированной длины, которая будет видна пользователям.

Затем нам нужно назначить свою картинку этому новому элементу в строке меню, чтобы мы могли различить наше новое приложение.

В project navigator перейдите в Assets.xcassets, загрузите картинку и перетащите её в каталог ресурсов (asset catalog).

Выберите картинку и откройте инспектор атрибутов. Измените опцию Render As на Template Image.

wves-ouas8duyvk97fki1swuedk.png

Если вы используете свою собственную картинку, убедитесь в том, что изображение черно-белое и сконфигурируйте его как Template image, чтобы иконка выглядела отлично как на темной, так и на светлой строке меню.

Вернитесь к AppDelegate.swift, и добавьте следующий код к applicationDidFinishLaunching (_:)

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


Здесь мы назначаем иконке приложения картинку, которую только что добавили и назначаем action, когда кликаем на ней.

Добавьте в класс следующий метод:

@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)")
}


Этот метод просто выводит цитату на консоль.

Обратите внимание на директиву метода objc. Это позволяет использовать этот метод в качестве отклика на нажатие кнопки.

Постройте и запустите приложение, и вы увидите новое приложение в строке меню. Ура!
Каждый раз, как вы кликаете на иконке в строке меню, в консоли Xcode выводится известное изречение Марка Твена.

Прячем главное окно и иконку в доке


Есть пара мелочей, которые нам нужно сделать, прежде чем заняться непосредственно функционалом:

  • удалить иконку в доке
  • удалить ненужное главное окно приложения


Чтобы удалить иконку в доке, откройте Info.plist. Добавьте новый ключ Application is agent (UIElement) и установите его значение в YES.

-cg_pnrysiiynfs2xfab8fyblhk.png

Теперь время разобраться с главным окном приложения.

  • откройте Main.storyboard
  • выберите Window Controller scene и удалите его
  • View Controller scene оставьте, мы скоро будем использовать его

td1qvrwqe8qpgp2xvadbifvu_4o.png

Постройте и запустите приложение. Теперь у приложения нет как главного окна, так и ненужной иконки в доке. Отлично!

Добавляем к Status Item меню


Одной-единственной реакции на клик явно недостаточно для серьёзного приложения. Простейший способ добавить функциональности — добавить меню. Допишите эту функцию в конце 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
}


А затем добавьте этот вызов в конце applicationDidFinishLaunching (_:)

constructMenu()


Мы создаём NSMenu, добавляем к нему 3 экземпляра NSMenuItem и устанавливаем это меню как меню иконки приложения.

Несколько важных замечаний:

  • title элемента меню — это текст, который появится в меню. Хорошее место для локализации приложения (если это необходимо).
  • action, как и action кнопки или другого контрола — это метод, который вызывается, когда пользователь кликает на элементе меню
  • keyEquivalent — это сочетание клавиш, которое можно использовать для выбора элемента меню. Символы в нижнем регистре используют Cmd как модификатор, а в верхнем регистре — Cmd+Shift. Это работает только в том случае, если приложение находится на самом верху и активно. В нашем случае необходимо, чтобы было видно меню или какое-то другое окно, так как у нашего приложения нет иконки в доке
  • separatorItem — это неактивный элемент меню в виде серой линии между другими элементами. Используйте его для группировки
  • printQuote — метод, который вы уже определили в AppDelegate, а terminate — метод, определённый NSApplication.


Запустите приложение, и вы увидите меню, кликнув на иконке приложения.

-w2vlv2nfi_vxgosiihbqd19m5g.png

Попробуйте кликнуть меню — выбор Print Quote выведет цитату в консоль Xcode, а Quit Quotes завершит приложение.

Добавляем всплывающее окно


Вы видели, как легко добавить меню из кода, но показ цитаты в консоли Xcode — это явно не то, что ожидают от приложения пользователи. Сейчас мы добавим простой view controller, чтобы показывать цитаты правильным образом.

Идите в меню File/New/File…, выберите шаблон macOS/Source/Cocoa Class и кликните Next.

m5vd7wewsgjt16zajhbas3ca9mi.png

  • назовите класс QuotesViewController
  • сделайте наследником NSViewController
  • убедитесь, что чекбокс Also create XIB file for user interface не отмечен
  • установите язык в Swift


Наконец, кликните опять Next, выберите место для сохранения файла и кликните Create.
Теперь откройте Main.storyboard. Раскройте View Controller Scene и выберите View Controller instance.

c4ddemmj9moxiu_whrzl7ilme_4.png

Сначала выберите Identity Inspector и измените класс на QuotesViewController, затем, установите Storyboard ID на QuotesViewController

Теперь добавьте следующий код к концу файла QuotesViewController.swift:

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


Что здесь происходит:

  1. получаем ссылку на Main.storyboard.
  2. создаем Scene identifier, который соответствует тому, который мы только что установили чуть выше.
  3. создаём экземпляр QuotesViewController и возвращаем его.


Вы создаёте этот метод, так что теперь всем, кто использует QuotesViewController, не нужно знать, как именно он создаётся. Это просто работает.

Обратите внимание на fatalError внутри оператора guard. Бывает неплохо использовать его или assertionFailure, чтобы, если что-то в разработке пошло не так, самому, да и другим членам команды разработки, быть в курсе.

Теперь вернемся к AppDelegate.swift. Добавим новое свойство.

let popover = NSPopover()


Затем замените applicationDidFinishLaunching (_:) следующим кодом:

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


Вы изменили действие по клику на вызов метода togglePopover (_:), который мы напишем чуть позже. Также, вместо настройки и добавления меню, мы настроили всплывающее окно, которое будет показывать что-то из QuotesViewController.

Добавьте следующие три метода в 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 () показывает всплывающее окно. Вы только указываете, откуда оно появляется, macOS позиционирует его и дорисовывает стрелочку, как будто оно появляется из строки меню.

closePopover () просто закрывает всплывающее окно, а togglePopover () — метод, который либо показывает, либо прячет всплывающее окно, в зависимости от его состояния.

Запустите приложение и кликните на его иконке.

j6guoqowcbou3xwtqr2cdunm4ns.png

Все отлично, но где контент?

Реализуем Quote View Controller


Сначала вам нужна модель для хранения цитат и атрибутов. Идите в меню File/New/File… и выберите macOS/Source/Swift File template, затем Next. Назовите файл Quote и кликните Create.

Откройте файл Quote.swift и добавьте в него следующий код:

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)"
  }
}


Здесь мы определяем простую структуру цитаты и статическое свойство, которое возвращает все цитаты. Так как мы сделали Quote соответствующим протоколу CustomStringConvertible, мы легко можем получить удобно отформатированный текст.

Есть прогресс, но нам еще нужны элементы управления, чтобы все это отобразить.

Добавляем элементы интерфейса


Откройте Main.storyboard и вытащите 3 кноки (Push Button) и метку (Multiline Label) на view controller.

Расположите кнопки и метку так, чтобы они выглядели примерно так:

1vry2_ydfrxoqiqdsklo2uouwne.png

Прикрепите левую кнопку к левому краю с промежутком 20 и отцентрируйте вертикально.
Прикрепите правую кнопку к правому краю с промежутком 20 и отцентрируйте вертикально.
Прикрепите нижнюю кнопку к нижнему краю с промежутком 20 и отцентрируйте горизонтально.
Прикрепите левый и правый край метки к кнопкам с промежутком 20 отцентрируйте вертикально.

jlzitledfqmcexzv636ya6ir6sq.png

Вы увидите несколько ошибок расположения, так как для auto layout недостаточно информации, чтобы разобраться.

Установите у метки Horizontal Content Hugging Priority в 249, чтобы позволить метке изменять размер.

a6qps0r2_vobhsjkbcfiqohklgc.png

Теперь сделайте следующее:

  • установите image левой кнопки в NSGoLeftTemplate и очистите title
  • установите image правой кнопки в NSGoRightTemplate и очистите title
  • установите title кнопки внизу в Quit Quotes.
  • установите text alignment у метки в center.
  • проверьте, что Line Break у метки установлен в Word Wrap.

Теперь откройте QuotesViewController.swift и добавьте следующий код в реализацию класса QuotesViewController:

@IBOutlet var textLabel: NSTextField!

Добавьте этот экстеншн в реализацию класса. Теперь в QuotesViewController.swift у вас два расширения класса.

// MARK: Actions

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

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

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


Мы только что добавили outlet для метки, которую мы будем использовать для показа цитат, и 3 метода-заглушки, которые соединим с кнопками.

Соединяем код с Interface Builder


Обратите внимание: Xcode разместил кружки слева от вашего кода — возле ключевых слов IBAction и IBOutlet.

kv48k_pvgucw0v1jexqsna44dda.png

Мы будем их использовать, чтобы соединить код с UI.

Держа нажатой клавишу alt кликните на Main.storyboard в the project navigator. Таким образом storyboard откроется в Assistant Editor справа, а код — слева.

Перетащите кружок слева от textLabel на метку в interface builder. Таким же образом соедините методы previous, next и quit с левой, правой и нижней кнопками соответственно.

7-quybzu1bexw545popvn_mbeea.png

Запустите ваше приложение.

trb26wseu6kdpeyqyjdbeojwoxu.png

Мы использовали размер всплывающего окна по умолчанию. Если вы хотите всплывающее окно побольше или поменьше, просто измените его размер в storyboard.

Пишем код для кнопок


Если вы еще не скрыли Assistant Editor, нажмите Cmd-Return или View > Standard Editor > Show Standard Editor

Откройте QuotesViewController.swift и добавьте следующие свойства в реализацию класса:

let quotes = Quote.all

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


Свойство quotes содержит все цитаты, а currentQuoteIndex — это индекс цитаты, которая выводится в данный момент. У currentQuoteIndex есть также property observer, чтобы обновить содержимое метки новой цитатой, когда меняется индекс.

Теперь добавьте следующие методы:

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

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


Когда view загружается, мы устанавливаем индекс цитаты в 0, что, в свою очередь, приводит к обновлению интерфейса. updateQuote () просто обновляет текстовую метку, чтобы отобразить цитату. соответствующую currentQuoteIndex.

Наконец, обновите эти методы следующим кодом:

@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)
}


Методы next () и previous () циклически перелистывают все цитаты. quit закрывает приложение.

Запустите приложение:

wiqhjuzkw5c8f7bmtofythu147k.png

Мониторинг событий


Есть ещё одна вещь, которую пользователи ждут от нашего приложения — прятать всплывающее окно, когда пользователь кликает где-то вне него. Для этого нам нужен механизм, называемый macOS global event monitor.

Создадим новый Swift файл, назовём его EventMonitor, и заменим его содержимое следующим кодом:

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
    }
  }
}


При инициализации экземпляра этого класса мы передаем ему маску событий, который будем прослушивать (вроде нажатия клавиш, прокрутки колеса мыши и т.д.) и обработчик события.
Когда мы готовы начать прослушивание, start () вызывает addGlobalMonitorForEventsMatchingMask (_: handler:), который возвращает объект, который мы сохраняем. Как только случается событие, содержавшееся в маске, система вызывает ваш обработчик.

Чтобы прекратить мониторинг событий, в stop () вызывается removeMonitor () и мы удаляем объект путем присваивания ему значения nil.

Все, что нам остается — это вызывать в нужное время start () and stop (). Класс также вызывает stop () в деинициалайзере, чтобы прибрать за собой.

Подключаем Event Monitor


Откройте AppDelegate.swift в последний раз и добавьте новое свойство:

var eventMonitor: EventMonitor?


Затем добавьте этот код, чтобы сконфигурировать event monitor в конце applicationDidFinishLaunching (_:)

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


Это проинформирует ваше приложение при клике левой или правой кнопки. Обратите внимание: обработчик не будет вызван в ответ на клики мышью внутри вашего приложения. Вот почему всплывающее окно не закроется, пока вы кликаете внутри него.

Мы используем weak ссылку на self, чтобы избежать опасности цикла сильных ссылок между AppDelegate и EventMonitor.

Добавьте следующий код в конец метода showPopover (_:):

eventMonitor?.start()


Здесь мы стартуем мониторинг событий, когда появляется всплывающее окно.

Теперь добавьте код в конце метода closePopover (_:):

eventMonitor?.stop()


Здесь мы завершаем мониторинг, когда всплывающее окно закрывается.

Приложение готово!

Заключение


Здесь вы найдете полный код этого проекта.

Вы узнали, как установить меню и всплывающее окно в приложении, размещенном в строке меню. Почему бы не поэкспериментировать с несколькими метками или форматированным текстом, чтобы получше оформить вид цитат? Или подключить бэкенд, чтобы получать цитаты из Интернета? Или вы захотите использовать клавиатуру для навигации между цитатами?

Хорошее место для исследований — официальная документация: NSMenu, NSPopover и NSStatusItem.

© Habrahabr.ru