菜單欄 app 很久之前就已經(jīng)成為 OS X 的重要組件。例如 [1Password]( Features - 1Password ) 和 [Day One]( Day One | A simple and elegant journal for iPhone, iPad, and Mac. ) 有菜單欄 app 作為組件。另外一些比如 [Fantastical]( Flexibits | Fantastical 2 for Mac | Meet your Mac’s new calendar. ) 就只生存在 OS X 的菜單欄里。
本教程會(huì)建立一個(gè)菜單欄 app,在 popover 中顯示名人名言。可以在其中學(xué)到:
- 如何創(chuàng)建菜單欄圖標(biāo)
- 如何讓 app 只存活在菜單欄了里
- 如何為用戶(hù)添加一個(gè)菜單
- 如何在用戶(hù)需要的時(shí)候顯示、用戶(hù)離開(kāi)的時(shí)候隱藏 popover —— 也叫事件監(jiān)督(Event Monitoring)
- 如何添加基本 UI 元素
注意:本教程假設(shè)你熟知 Swift 和 OS X。如果你需要學(xué)習(xí),就看 Getting Started With OS X and Swift 教程。
上手
打開(kāi) Xcode。選擇 File/New/Project… 然后選擇 OS X/Application/Cocoa Application 模板然后點(diǎn)擊 Next。
在下一屏,Product Name 輸入 ** Quotes**,輸入必要的 Organization Name 和 Organization Identifier。然后確定選擇了 Swift 語(yǔ)言,取消 Use Storyboards, Create Document-Based Application 和 Use Core Data 的勾選。
最后,再次點(diǎn)擊 Next,選擇一個(gè)位置來(lái)保存項(xiàng)目然后點(diǎn)擊 Create。
注意:在 iOS 以及 OS X Yosemite 上你應(yīng)該優(yōu)先使用 storyboard。但在這個(gè)例子里,使用 storyboard 會(huì)讓只生存在菜單欄里的 app 變得更復(fù)雜。所以你要用 xib 來(lái)構(gòu)建 app 的用戶(hù)界面。
新項(xiàng)目建立好之后,打開(kāi) AppDelegate.swift 然后給類(lèi)添加下面這個(gè) property:
let statusItem = NSStatusBar.system().statusItem(withLength: NSSquareStatusItemLength)
這樣就在菜單欄用固定長(zhǎng)度創(chuàng)建了一個(gè) Status Item —— 也叫作應(yīng)用程序圖標(biāo),用戶(hù)能看見(jiàn)以及使用它。
下一步,需要給 status item 配一張圖片,讓 app 在菜單欄可以被識(shí)別出來(lái)。
打開(kāi) Images.xcassets。然后下載這個(gè)圖片 StatusBarButtonImage@2x.png,把它拖到 asset catelog里。
選擇圖片然后打開(kāi) attributes inspector。設(shè)置 Devices 為 Device Specific,然后確定 Mac 選項(xiàng)是選上的。改變 Render As 選項(xiàng)為 Template Image。
如果你要使用自定義的圖片,確保圖片是黑白的,并且配置為 template image,這樣 Status Item 在 light 和 dark 模式下看起來(lái)都很完美。
回到 AppDelegate.swift,然后把下面的代碼添加到 applicationDidFinishLaunching(_:):
if let button = statusItem.button {
button.image = NSImage(named: "StatusBarButtonImage")
button.action = Selector("printQuote:")
}
這樣就用剛剛添加的圖片作為圖標(biāo)配置了 status item,點(diǎn)擊 item 的時(shí)候也會(huì)有一個(gè)動(dòng)作。
在你測(cè)試 app 之前,需要添加那個(gè)按鈕方法。把下面的方法添加到類(lèi)里:
func printQuote(sender: AnyObject) {
let quoteText = "Never put off until tomorrow what you can do the day after tomorrow."
let quoteAuthor = "Mark Twain"
println("\(quoteText) — \(quoteAuthor)")
}
這個(gè)方法會(huì)輸出一條簡(jiǎn)單的馬克·吐溫名言到控制臺(tái)里。
編譯運(yùn)行 app,就能看到一個(gè)可用的新菜單欄 app 了。你做到了!
注意:如果你有太多菜單欄 app,可能就沒(méi)法看到自己的那個(gè)了。切換到菜單比 Xcode 少的 app(例如 Finder)你應(yīng)該就能看到了。
每次點(diǎn)擊菜單欄圖標(biāo),就會(huì)看見(jiàn) Xcode 控制臺(tái)輸出了名言。
隱藏 Dock 圖標(biāo)和主窗口
在成為一個(gè)有用的菜單欄 app 之前,還要做兩件小事:隱藏 dock 圖標(biāo)并且干掉主窗口。
要禁用 dock 圖標(biāo),打開(kāi) Info.plist。然后添加一個(gè)新鍵 Application is agent (UIElement) 然后設(shè)置值為 YES。
注意:如果你很擅長(zhǎng)編輯 plist 文件,也可以手動(dòng)把鍵設(shè)置為 ** LSUIElement**。
現(xiàn)在是時(shí)候修理主窗口了。打開(kāi) MainMenu.xib 然后選擇窗口對(duì)象。然后,在 attributes inspector 里設(shè)置窗口,讓它在啟動(dòng)的時(shí)候不可見(jiàn)。
編譯運(yùn)行。你會(huì)看到 app 沒(méi)有主窗口了,也沒(méi)有討厭的 dock 圖標(biāo),只有一個(gè)可愛(ài)的 status item 在菜單欄里!
給 Status Item 添加一個(gè)菜單
一般情況下,菜單欄 app 只有一次少得可憐的點(diǎn)擊是不夠用的。添加更多功能最賤的方式就是增加一個(gè)菜單。把下面的代碼添加到 applicationDidFinishLaunching(_:) 的結(jié)尾:
let menu = NSMenu()
menu.addItem(NSMenuItem(title: "Print Quote", action: Selector("printQuote:"), keyEquivalent: "P"))
menu.addItem(NSMenuItem.separatorItem())
menu.addItem(NSMenuItem(title: "Quit Quotes", action: Selector("terminate:"), keyEquivalent: "q"))
statusItem.menu = menu
這樣就創(chuàng)建了一個(gè) NSMenu,給它增加了幾個(gè) NSMenuItem 的實(shí)例,然后設(shè)置 status item 的菜單為這個(gè)新的菜單。
這里需要注意幾點(diǎn):
- menu item 的 title 很明顯;就是顯示在 menu item 上的文字。
- action,就像按鈕或任意其它控件的 action,點(diǎn)擊 menu item 的時(shí)候會(huì)調(diào)用的那個(gè)方法。
- ** KeyEquivalent** 是快捷鍵,可以用來(lái)激活 menu item。小寫(xiě)表示使用 Cmd 作為輔助鍵,大寫(xiě)表示使用 Cmd+Shift。這個(gè)鍵盤(pán)快捷鍵只在應(yīng)用在最前端并且活動(dòng)的情況下有效。所以,在這個(gè)例子里,menu 或所有其它窗口需要是可以被看見(jiàn)的,因?yàn)檫@個(gè) app 沒(méi)有 dock 圖標(biāo)。
- ** separatorItem** 是一個(gè)處于非激活狀態(tài)的 menu item,在其它 menu item 之間顯示為一條簡(jiǎn)單的灰線。用它來(lái)給菜單里的功能分組。
- printQuote: 動(dòng)作是已經(jīng)在 AppDelegate 里定義好的方法。對(duì)于另一個(gè),terminate: 是定義在 shared application instance 里的動(dòng)作方法。因?yàn)槟銢](méi)有實(shí)現(xiàn)它,動(dòng)作給發(fā)送到響應(yīng)鏈里,直到它到達(dá) shared application,然后應(yīng)用就推出了。
編譯運(yùn)行,點(diǎn)擊 status item,你會(huì)看見(jiàn)一個(gè)菜單。有進(jìn)步!
試一下這些選項(xiàng) —— 選擇 Print Quotes 會(huì)在 Xcode 控制臺(tái)里顯示名言,Quit Quotes 會(huì)退出 app。
給 Status Item 添加 Popover
可以看到,用代碼設(shè)置一個(gè)菜單是這么簡(jiǎn)單,但在 Xcode 控制臺(tái)里顯示名言對(duì)于用戶(hù)來(lái)說(shuō)并沒(méi)有什么卵用。下一步是替換菜單為一個(gè)簡(jiǎn)單的視圖控制器來(lái)顯示名言。
選擇 File/New/File…,選擇 OS X/Source/Cocoa Class 模板然后點(diǎn)擊 Next。
把類(lèi)命名為 ** QuotesViewController,父類(lèi)設(shè)置為 ** NSViewController,勾上 Also create XIB file for user interface,設(shè)置語(yǔ)言為 Swift。
最后,再次點(diǎn)擊 Next,選擇一個(gè)地方來(lái)保存文件(項(xiàng)目文件夾里的 Quotes 子文件夾是一個(gè)好地方)然后點(diǎn)擊 Create。
選擇,把新文件放到一邊,回到 AppDelegate.swift。給這個(gè)類(lèi)添加一個(gè)新的 property 聲明:
let popover = NSPopover()
下一步,替換 applicationDidFinishLaunching(_:) 為下面這段代碼:
func applicationDidFinishLaunching(notification: NSNotification) {
if let button = statusItem.button {
button.image = NSImage(named: "StatusBarButtonImage")
button.action = #selector(AppDelegate.togglePopover(sender:))
}
popover.contentViewController = QuotesViewController(nibName: "QuotesViewController", bundle: nil)
}
已經(jīng)把按鈕動(dòng)作改為 togglePopover:,接下來(lái)就要實(shí)現(xiàn)它。還有,不是在設(shè)置一個(gè)菜單,而是設(shè)置了 popover 來(lái)顯示 QuotesViewController 里所有東西。
最后,移除 printQuote(),添加下面三個(gè)方法到原本的位置:
func showPopover(sender: AnyObject?) {
if let button = statusItem.button {
popover.show(relativeTo: button.bounds, of: button, preferredEdge: NSRectEdge.minY)
}
}
func closePopover(sender: AnyObject?) {
popover.performClose(sender)
}
func togglePopover(sender: AnyObject?) {
if popover.isShown {
closePopover(sender: sender)
} else {
showPopover(sender: sender)
}
}
showPopover 為用戶(hù)顯示 popover。和 iOS 上的 popover 相似,你只需要提供一個(gè) source rect,然后 OS X 就會(huì)放置 popover 和箭頭,這樣它看起來(lái)就是從菜單欄圖標(biāo)里出來(lái)的。
closePopover() 很簡(jiǎn)單,就是關(guān)閉 popover,togglePopover() 是 action 方法,基于當(dāng)前狀態(tài)來(lái)打開(kāi)或關(guān)閉 popover。
編譯運(yùn)行,然后點(diǎn)擊菜單欄圖標(biāo)來(lái)檢查一下是否顯示和隱藏了一個(gè)空的 popover。
popover 工作起來(lái)很棒,但激發(fā)靈感的名人名言都去哪了?可以看到的只是一個(gè)空 view 也沒(méi)有名言。猜猜接下來(lái)要修復(fù)什么?
實(shí)現(xiàn) Quote View Controller
首先,需要模型化來(lái)保存名言和作者。選擇 File/New/File… 然后選擇 OS X/Source/Swift File 模板,然后點(diǎn)擊 Next。把文件命名為 Quote 然后點(diǎn)擊 Create。
打開(kāi) 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")
]
}
// MARK: - Printable
extension Quote: Printable {
var description: String {
return "\"\(text)\" — \(author)"
}
}
這樣就定義了一個(gè)簡(jiǎn)單的名言結(jié)構(gòu)體,還有一個(gè)靜態(tài) property 可以返回所有的名言。因?yàn)橐沧?Quote 遵循 Printable 了,就可以輕易得到一個(gè)格式優(yōu)雅的字符串。
又有進(jìn)展了,但在 UI 里還需要更多函數(shù)。不如用一些 wrapping 和 auto constraint 來(lái)讓它更漂亮?
打開(kāi) QuotesViewController.xib 然后拖兩個(gè) bevel button 實(shí)例,一個(gè) label 和 push button 到自定義視圖里。
設(shè)置第一個(gè) bevel button 的圖片為 NSGoLeftTemplate,第二個(gè)按鈕的圖片設(shè)置為 NSGoRightTemplate,設(shè)置 label 的文字 alignment 為 Center 以及 line break 模型設(shè)置為 Word Wrap。最后,設(shè)置 push button 的 title 為 Quit Quotes。
最后的布局應(yīng)該看起來(lái)像這樣:
你會(huì)添加 auto layout constraints 來(lái)讓用戶(hù)界面匹配嗎?在劇透前給一點(diǎn)好的暗示。如果你會(huì)的話,跳過(guò)暗示然后給自己一朵小紅花。
解決方式
要獲得正確的布局,需要添加如下 auto layout constraints:
- Pin go-left 和 go-right 按鈕的 top 和 bottom,然后給它們固定的寬度 32。go-left 也應(yīng)該被固定到 leading 邊,go-right 應(yīng)該被固定到 trailing 邊。
- 把 label 放到按鈕中間,然后添加 trailing 和 leading space constraints。還要把 label 設(shè)置為垂直居中。
- 固定 Quit 按鈕到底邊,水平居中。
把 constraint 設(shè)置完美后,在畫(huà)布的右下角選擇 Resolve Auto Layout Issues 來(lái)選擇 Update Constraints。
現(xiàn)在打開(kāi) QuotesViewController.swift 然后用下面這段代碼替換文件內(nèi)容:
import Cocoa
class QuotesViewController: NSViewController {
@IBOutlet var textLabel: NSTextField!
}
// MARK: Actions
extension QuotesViewController {
@IBAction func goLeft(sender: NSButton) {
}
@IBAction func goRight(sender: NSButton) {
}
@IBAction func quit(sender: NSButton) {
}
}
這個(gè) starter implementation 就是一個(gè)標(biāo)準(zhǔn)的 NSViewController 實(shí)例。text label 有一個(gè) outlet,用來(lái)更新名言警句。三個(gè) action 是給三個(gè)按鈕準(zhǔn)備的。
然后回到 QuotesViewController.xib 然后把 outlet 連接到 text label,只要按住 control 從 File’s Owner 拖到 label 上。再按住 control 把按鈕拖到 File’s Owner 來(lái)連接對(duì)應(yīng)的 action。
注意:如果你對(duì)上面的步驟有什么困惑,參考我們的 OS X tutorials,這是介紹性教程,介紹了 OS X 開(kāi)發(fā)的多個(gè)方面,包括在 interface builder 里添加 views/constraints 以及連接 outlets 和 actions。
站起來(lái),伸個(gè)懶腰或者繞著辦公桌轉(zhuǎn)一圈,因?yàn)槟銊倓偼瓿闪艘淮蠖?interface builder 工作。
編譯運(yùn)行,你的 popover 現(xiàn)在看起來(lái)就會(huì)像這樣:
注意:上面的 popover 使用了 view controller 的默認(rèn)尺寸。如果你想要更少或更大的 popover,只需要在 xib 里改變 view controller 的大小即可。試試看!
界面完成了,但還沒(méi)有做完所有的工作。這些按鈕在等你通知它們,當(dāng)用戶(hù)點(diǎn)擊的時(shí)候要做什么——不要把它們掛在這兒。
打開(kāi) QuotesViewController.swift 然后把下面的 property 添加到類(lèi)里:
let quotes = Quote.all
var currentQuoteIndex: Int = 0 {
didSet {
updateQuote()
}
}
第一個(gè) property 管理所有的 quote,第二個(gè)管理當(dāng)前 quote 的索引。currentQuoteIndex 還有一個(gè) property observer 來(lái)更新 text label 字符串為新的名言,就在每次 index 被改變的時(shí)候。
接下來(lái),為類(lèi)添加下面的方法:
override func viewWillAppear() {
super.viewWillAppear()
currentQuoteIndex = 0
}
func updateQuote() {
textLabel.stringValue = toString(quotes[currentQuoteIndex])
}
當(dāng) view 顯示的時(shí)候,把當(dāng)前名言 index 設(shè)置為 0,然后就會(huì)更新用戶(hù)界面。updateQuote() 只是更新 text label 來(lái)顯示當(dāng)前選中的是哪個(gè) quote,參考了 currentQuoteIndex。
要把它全部綁在一起,實(shí)現(xiàn)如下的三個(gè) action 方法:
@IBAction func goLeft(sender: NSButton) {
currentQuoteIndex = (currentQuoteIndex - 1 + quotes.count) % quotes.count
}
@IBAction func goRight(sender: NSButton) {
currentQuoteIndex = (currentQuoteIndex + 1) % quotes.count
}
@IBAction func quit(sender: NSButton) {
NSApplication.sharedApplication().terminate(sender)
}
在 goLeft() 和 goRight() 里,循環(huán)了所有的名言,如果到達(dá)數(shù)組的末尾就回頭。quit() 關(guān)閉了 app,之前已經(jīng)解釋過(guò)了。
再次編譯運(yùn)行,現(xiàn)在你可以看到所有的 quote 并且可以退出 app!
Event Monitoring
這個(gè)毫不起眼的小菜單欄 app 還需要一個(gè)功能,那就是當(dāng)你點(diǎn)擊到 app 之外的任意地方的時(shí)候,popover 會(huì)自動(dòng)關(guān)閉。
菜單欄 app 應(yīng)該在點(diǎn)擊或滑過(guò)的時(shí)候打開(kāi) popover,然后再用戶(hù)移到下一個(gè)東西的時(shí)候消失。對(duì)于這點(diǎn),我們需要一個(gè) OS X 全局 event monitor。
Here’s where you’ll take the concept to the next level. 要讓 event monitor 在所有項(xiàng)目里可復(fù)用,還要保持示例 app 模塊化,我們會(huì)定義一個(gè) Swift wrapper 類(lèi),然后在顯示 popover 的時(shí)候使用它。
Bet you’re feeling smarter already!
創(chuàng)建一個(gè) Swift 文件,命名為 EventMonitor,然后用如下類(lèi)定義來(lái)替換內(nèi)容:
import Cocoa
public class EventMonitor {
private var monitor: AnyObject?
private let mask: NSEventMask
private let handler: NSEvent? -> ()
public init(mask: NSEventMask, handler: NSEvent? -> ()) {
self.mask = mask
self.handler = handler
}
deinit {
stop()
}
public func start() {
monitor = NSEvent.addGlobalMonitorForEventsMatchingMask(mask, handler: handler)
}
public func stop() {
if monitor != nil {
NSEvent.removeMonitor(monitor!)
monitor = nil
}
}
}
傳遞要監(jiān)聽(tīng)的事件 mask 即可初始化這個(gè)類(lèi) —— 比如說(shuō)按下某個(gè)鍵、滾輪滑動(dòng)、單擊鼠標(biāo)左鍵,等等,還要另外傳遞一個(gè) event handler。
當(dāng)你準(zhǔn)備好開(kāi)始監(jiān)聽(tīng)的時(shí)候,start() 調(diào)用了 addGlobalMonitorForEventsMatchingMask(_:handler:),which returns an object for you to hold on to。每次 mask 中特定的事件發(fā)生后,系統(tǒng)會(huì)調(diào)用你的 handler。
要移除全局 event monitor 的話,在 stop() 里調(diào)用 removeMonitor() 然后通過(guò)設(shè)置返回的對(duì)象為 nil 來(lái)刪除它。
剩下需要做的就是在需要的時(shí)候調(diào)用 start() 和 stop()。多簡(jiǎn)單呀?這個(gè)類(lèi)也為你在 deinitializer 中調(diào)用 stop(),來(lái)清理自己。
最后一次打開(kāi) AppDelegate.swift,為類(lèi)添加一個(gè)新的 property 聲明:
var eventMonitor: EventMonitor?
接下來(lái),在 applicationDidFinishLaunching(_:) 結(jié)尾的地方添加代碼來(lái)配置 event monitor:
eventMonitor = EventMonitor(mask: .LeftMouseDownMask | .RightMouseDownMask) { [unowned self] event in
if self.popover.shown {
self.closePopover(event)
}
}
eventMonitor?.start()
這樣就在系統(tǒng)檢測(cè)到任意左鍵或右鍵按下事件的時(shí)候通知你的 app,然后關(guān)閉 popover。注意你的 handler 不會(huì)被發(fā)送到自己的應(yīng)用事件被調(diào)用。所以 popover 當(dāng)你點(diǎn)擊內(nèi)部的時(shí)候不會(huì)關(guān)閉 popover。:]
添加如下代碼到 showPopover(_:) 的結(jié)尾處:
eventMonitor?.start()
這會(huì)在 popover 顯示的時(shí)候啟動(dòng) event monitor。
然后,需要把下面的代碼添加到 closePopover(_:) 的結(jié)尾:
eventMonitor?.stop()
這會(huì)在 popover 關(guān)閉的時(shí)候停止 event monitor。
全部完成了!再一次編譯運(yùn)行 app。點(diǎn)擊菜單欄圖標(biāo)來(lái)顯示 popover,然后點(diǎn)擊任意其他位置,popover 就會(huì)關(guān)閉。酷炫!
下面看什么?
可以在這里下載最終項(xiàng)目,帶有你在上面的教程里開(kāi)發(fā)的所有代碼。
你已經(jīng)看到如何在菜單欄 status item 里設(shè)置菜單和 popover —— 為什么不繼續(xù)實(shí)驗(yàn)顯示隨機(jī)的名言,連接到 web 后端來(lái)獲取新的名言,甚至提供一個(gè)“pro”版來(lái)賺點(diǎn)錢(qián)呢?
尋找其它機(jī)會(huì)的好地方是閱讀 [NSMenu]( NSMenu - AppKit | Apple Developer Documentation ),[NSPopover]( NSPopover - AppKit | Apple Developer Documentation ) 和 [NSStatusItem]( NSStatusItem - AppKit | Apple Developer Documentation ) 的官方文檔。
專(zhuān)業(yè)提示:小心 NSStatusItem 文檔。API 在 Yosemite 里發(fā)生了重大變更,不幸的是,文檔把所有舊的方法都標(biāo)記為 deprecated,但沒(méi)有記載新的替換方法。對(duì)于這點(diǎn),你需要在 Xcode 里按住 command 點(diǎn)擊 NSStatusItem 來(lái)查看生成的 Swift 頭文件。現(xiàn)在只有少數(shù)幾個(gè)方法了,所有功能都在一個(gè) NSButton 里,所以理解起來(lái)相當(dāng)?shù)妮p松。
感謝花時(shí)間學(xué)習(xí)如何制作一個(gè)酷酷的 OS X popover 菜單 app。現(xiàn)在看是相當(dāng)簡(jiǎn)單了,但你可以看到在這里學(xué)習(xí)的概念對(duì)于許多種 app 都是絕佳的基礎(chǔ)。
如果你在 app 里配置 status item、菜單或 popover 的時(shí)候有任何疑問(wèn),新奇的發(fā)現(xiàn)或想法,想告訴其他人,可以在下面評(píng)論來(lái)告訴我!:]