Swift3 - OS X 教程:如何制作像 1Password 那样的状态栏 app

菜单栏 app 很久之前就已经成为 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 的菜单栏里。

本教程会建立一个菜单栏 app,在 popover 中显示名人名言。可以在其中学到:

  • 如何创建菜单栏图标
  • 如何让 app 只存活在菜单栏了里
  • 如何为用户添加一个菜单
  • 如何在用户需要的时候显示、用户离开的时候隐藏 popover —— 也叫事件监督(Event Monitoring)
  • 如何添加基本 UI 元素

注意:本教程假设你熟知 Swift 和 OS X。如果你需要学习,就看 Getting Started With OS X and Swift 教程。

学习如何制作一个带有 popover 的菜单栏 OS X app!

上手

打开 Xcode。选择 File/New/Project… 然后选择 OS X/Application/Cocoa Application 模板然后点击 Next
在下一屏,Product Name 输入 ** Quotes**,输入必要的 Organization NameOrganization Identifier。然后确定选择了 Swift 语言,取消 Use Storyboards, Create Document-Based Application 和 Use Core Data 的勾选。

最后,再次点击 Next,选择一个位置来保存项目然后点击 Create

注意:在 iOS 以及 OS X Yosemite 上你应该优先使用 storyboard。但在这个例子里,使用 storyboard 会让只生存在菜单栏里的 app 变得更复杂。所以你要用 xib 来构建 app 的用户界面。

新项目建立好之后,打开 AppDelegate.swift 然后给类添加下面这个 property:

let statusItem = NSStatusBar.system().statusItem(withLength: NSSquareStatusItemLength)

这样就在菜单栏用固定长度创建了一个 Status Item —— 也叫作应用程序图标,用户能看见以及使用它。

下一步,需要给 status item 配一张图片,让 app 在菜单栏可以被识别出来。

打开 Images.xcassets。然后下载这个图片 StatusBarButtonImage@2x.png,把它拖到 asset catelog里。

选择图片然后打开 attributes inspector。设置 Devices 为 Device Specific,然后确定 Mac 选项是选上的。改变 Render As 选项为 Template Image。

如果你要使用自定义的图片,确保图片是黑白的,并且配置为 template image,这样 Status Item 在 light 和 dark 模式下看起来都很完美。

回到 AppDelegate.swift,然后把下面的代码添加到 applicationDidFinishLaunching(_:)

if let button = statusItem.button {
  button.image = NSImage(named: "StatusBarButtonImage")
  button.action = Selector("printQuote:")
}

这样就用刚刚添加的图片作为图标配置了 status item,点击 item 的时候也会有一个动作。

在你测试 app 之前,需要添加那个按钮方法。把下面的方法添加到类里:

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

这个方法会输出一条简单的马克·吐温名言到控制台里。
编译运行 app,就能看到一个可用的新菜单栏 app 了。你做到了!

注意:如果你有太多菜单栏 app,可能就没法看到自己的那个了。切换到菜单比 Xcode 少的 app(例如 Finder)你应该就能看到了。

每次点击菜单栏图标,就会看见 Xcode 控制台输出了名言。

隐藏 Dock 图标和主窗口

在成为一个有用的菜单栏 app 之前,还要做两件小事:隐藏 dock 图标并且干掉主窗口。
要禁用 dock 图标,打开 Info.plist。然后添加一个新键 Application is agent (UIElement) 然后设置值为 YES

注意:如果你很擅长编辑 plist 文件,也可以手动把键设置为 ** LSUIElement**。

现在是时候修理主窗口了。打开 MainMenu.xib 然后选择窗口对象。然后,在 attributes inspector 里设置窗口,让它在启动的时候不可见。

编译运行。你会看到 app 没有主窗口了,也没有讨厌的 dock 图标,只有一个可爱的 status item 在菜单栏里!

给 Status Item 添加一个菜单

一般情况下,菜单栏 app 只有一次少得可怜的点击是不够用的。添加更多功能最贱的方式就是增加一个菜单。把下面的代码添加到 applicationDidFinishLaunching(_:) 的结尾:

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

这样就创建了一个 NSMenu,给它增加了几个 NSMenuItem 的实例,然后设置 status item 的菜单为这个新的菜单。

这里需要注意几点:

  • menu item 的 title 很明显;就是显示在 menu item 上的文字。
  • action,就像按钮或任意其它控件的 action,点击 menu item 的时候会调用的那个方法。
  • ** KeyEquivalent** 是快捷键,可以用来激活 menu item。小写表示使用 Cmd 作为辅助键,大写表示使用 Cmd+Shift。这个键盘快捷键只在应用在最前端并且活动的情况下有效。所以,在这个例子里,menu 或所有其它窗口需要是可以被看见的,因为这个 app 没有 dock 图标。
  • ** separatorItem** 是一个处于非激活状态的 menu item,在其它 menu item 之间显示为一条简单的灰线。用它来给菜单里的功能分组。
  • printQuote: 动作是已经在 AppDelegate 里定义好的方法。对于另一个,terminate: 是定义在 shared application instance 里的动作方法。因为你没有实现它,动作给发送到响应链里,直到它到达 shared application,然后应用就推出了。

编译运行,点击 status item,你会看见一个菜单。有进步!

试一下这些选项 —— 选择 Print Quotes 会在 Xcode 控制台里显示名言,Quit Quotes 会退出 app。

给 Status Item 添加 Popover

可以看到,用代码设置一个菜单是这么简单,但在 Xcode 控制台里显示名言对于用户来说并没有什么卵用。下一步是替换菜单为一个简单的视图控制器来显示名言。
选择 File/New/File…,选择 OS X/Source/Cocoa Class 模板然后点击 Next
把类命名为 ** QuotesViewController,父类设置为 ** NSViewController,勾上 Also create XIB file for user interface,设置语言为 Swift
最后,再次点击 Next,选择一个地方来保存文件(项目文件夹里的 Quotes 子文件夹是一个好地方)然后点击 Create
选择,把新文件放到一边,回到 AppDelegate.swift。给这个类添加一个新的 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)
}

已经把按钮动作改为 togglePopover:,接下来就要实现它。还有,不是在设置一个菜单,而是设置了 popover 来显示 QuotesViewController 里所有东西。
最后,移除 printQuote(),添加下面三个方法到原本的位置:

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 为用户显示 popover。和 iOS 上的 popover 相似,你只需要提供一个 source rect,然后 OS X 就会放置 popover 和箭头,这样它看起来就是从菜单栏图标里出来的。

closePopover() 很简单,就是关闭 popover,togglePopover() 是 action 方法,基于当前状态来打开或关闭 popover。

编译运行,然后点击菜单栏图标来检查一下是否显示和隐藏了一个空的 popover。

popover 工作起来很棒,但激发灵感的名人名言都去哪了?可以看到的只是一个空 view 也没有名言。猜猜接下来要修复什么?

实现 Quote View Controller

首先,需要模型化来保存名言和作者。选择 File/New/File… 然后选择 OS X/Source/Swift File 模板,然后点击 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")
  ]
}
 
// MARK: - Printable
 
extension Quote: Printable {
  var description: String {
    return "\"\(text)\" — \(author)"
  }
}

这样就定义了一个简单的名言结构体,还有一个静态 property 可以返回所有的名言。因为也让 Quote 遵循 Printable 了,就可以轻易得到一个格式优雅的字符串。

又有进展了,但在 UI 里还需要更多函数。不如用一些 wrapping 和 auto constraint 来让它更漂亮?

打开 QuotesViewController.xib 然后拖两个 bevel button 实例,一个 labelpush button 到自定义视图里。

设置第一个 bevel button 的图片为 NSGoLeftTemplate,第二个按钮的图片设置为 NSGoRightTemplate,设置 label 的文字 alignment 为 Center 以及 line break 模型设置为 Word Wrap。最后,设置 push button 的 title 为 Quit Quotes

最后的布局应该看起来像这样:

你会添加 auto layout constraints 来让用户界面匹配吗?在剧透前给一点好的暗示。如果你会的话,跳过暗示然后给自己一朵小红花。

解决方式

要获得正确的布局,需要添加如下 auto layout constraints:

  1. Pin go-left 和 go-right 按钮的 top 和 bottom,然后给它们固定的宽度 32。go-left 也应该被固定到 leading 边,go-right 应该被固定到 trailing 边。
  2. 把 label 放到按钮中间,然后添加 trailing 和 leading space constraints。还要把 label 设置为垂直居中。
  3. 固定 Quit 按钮到底边,水平居中。
    把 constraint 设置完美后,在画布的右下角选择 Resolve Auto Layout Issues 来选择 Update Constraints
    现在打开 QuotesViewController.swift 然后用下面这段代码替换文件内容:
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) {
  }
}

这个 starter implementation 就是一个标准的 NSViewController 实例。text label 有一个 outlet,用来更新名言警句。三个 action 是给三个按钮准备的。
然后回到 QuotesViewController.xib 然后把 outlet 连接到 text label,只要按住 control 从 File’s Owner 拖到 label 上。再按住 control 把按钮拖到 File’s Owner 来连接对应的 action。

注意:如果你对上面的步骤有什么困惑,参考我们的 OS X tutorials,这是介绍性教程,介绍了 OS X 开发的多个方面,包括在 interface builder 里添加 views/constraints 以及连接 outlets 和 actions。

站起来,伸个懒腰或者绕着办公桌转一圈,因为你刚刚完成了一大堆 interface builder 工作。
编译运行,你的 popover 现在看起来就会像这样:

注意:上面的 popover 使用了 view controller 的默认尺寸。如果你想要更少或更大的 popover,只需要在 xib 里改变 view controller 的大小即可。试试看!


界面完成了,但还没有做完所有的工作。这些按钮在等你通知它们,当用户点击的时候要做什么——不要把它们挂在这儿。
打开 QuotesViewController.swift 然后把下面的 property 添加到类里:

let quotes = Quote.all
 
var currentQuoteIndex: Int = 0 {
  didSet {
    updateQuote()
  }
}

第一个 property 管理所有的 quote,第二个管理当前 quote 的索引。currentQuoteIndex 还有一个 property observer 来更新 text label 字符串为新的名言,就在每次 index 被改变的时候。
接下来,为类添加下面的方法:

override func viewWillAppear() {
  super.viewWillAppear()
 
  currentQuoteIndex = 0
}
 
func updateQuote() {
  textLabel.stringValue = toString(quotes[currentQuoteIndex])
}

当 view 显示的时候,把当前名言 index 设置为 0,然后就会更新用户界面。updateQuote() 只是更新 text label 来显示当前选中的是哪个 quote,参考了 currentQuoteIndex
要把它全部绑在一起,实现如下的三个 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() 里,循环了所有的名言,如果到达数组的末尾就回头。quit() 关闭了 app,之前已经解释过了。
再次编译运行,现在你可以看到所有的 quote 并且可以退出 app!

Event Monitoring

这个毫不起眼的小菜单栏 app 还需要一个功能,那就是当你点击到 app 之外的任意地方的时候,popover 会自动关闭。
菜单栏 app 应该在点击或滑过的时候打开 popover,然后再用户移到下一个东西的时候消失。对于这点,我们需要一个 OS X 全局 event monitor。
Here’s where you’ll take the concept to the next level. 要让 event monitor 在所有项目里可复用,还要保持示例 app 模块化,我们会定义一个 Swift wrapper 类,然后在显示 popover 的时候使用它。
Bet you’re feeling smarter already!

I feel S-M-R-T!

创建一个 Swift 文件,命名为 EventMonitor,然后用如下类定义来替换内容:

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

传递要监听的事件 mask 即可初始化这个类 —— 比如说按下某个键、滚轮滑动、单击鼠标左键,等等,还要另外传递一个 event handler。
当你准备好开始监听的时候,start() 调用了 addGlobalMonitorForEventsMatchingMask(_:handler:),which returns an object for you to hold on to。每次 mask 中特定的事件发生后,系统会调用你的 handler。
要移除全局 event monitor 的话,在 stop() 里调用 removeMonitor() 然后通过设置返回的对象为 nil 来删除它。
剩下需要做的就是在需要的时候调用 start()stop()。多简单呀?这个类也为你在 deinitializer 中调用 stop(),来清理自己。
最后一次打开 AppDelegate.swift,为类添加一个新的 property 声明:
var eventMonitor: EventMonitor?
接下来,在 applicationDidFinishLaunching(_:) 结尾的地方添加代码来配置 event monitor:

eventMonitor = EventMonitor(mask: .LeftMouseDownMask | .RightMouseDownMask) { [unowned self] event in
  if self.popover.shown {
    self.closePopover(event)
  }
}
eventMonitor?.start()

这样就在系统检测到任意左键或右键按下事件的时候通知你的 app,然后关闭 popover。注意你的 handler 不会被发送到自己的应用事件被调用。所以 popover 当你点击内部的时候不会关闭 popover。:]
添加如下代码到 showPopover(_:) 的结尾处:
eventMonitor?.start()
这会在 popover 显示的时候启动 event monitor。
然后,需要把下面的代码添加到 closePopover(_:) 的结尾:
eventMonitor?.stop()
这会在 popover 关闭的时候停止 event monitor。
全部完成了!再一次编译运行 app。点击菜单栏图标来显示 popover,然后点击任意其他位置,popover 就会关闭。酷炫!

下面看什么?

可以在这里下载最终项目,带有你在上面的教程里开发的所有代码。
你已经看到如何在菜单栏 status item 里设置菜单和 popover —— 为什么不继续实验显示随机的名言,连接到 web 后端来获取新的名言,甚至提供一个“pro”版来赚点钱呢?
寻找其它机会的好地方是阅读 [NSMenu]( NSMenu - AppKit | Apple Developer Documentation ),[NSPopover]( NSPopover - AppKit | Apple Developer Documentation ) 和 [NSStatusItem]( NSStatusItem - AppKit | Apple Developer Documentation ) 的官方文档。

专业提示:小心 NSStatusItem 文档。API 在 Yosemite 里发生了重大变更,不幸的是,文档把所有旧的方法都标记为 deprecated,但没有记载新的替换方法。对于这点,你需要在 Xcode 里按住 command 点击 NSStatusItem 来查看生成的 Swift 头文件。现在只有少数几个方法了,所有功能都在一个 NSButton 里,所以理解起来相当的轻松。

感谢花时间学习如何制作一个酷酷的 OS X popover 菜单 app。现在看是相当简单了,但你可以看到在这里学习的概念对于许多种 app 都是绝佳的基础。
如果你在 app 里配置 status item、菜单或 popover 的时候有任何疑问,新奇的发现或想法,想告诉其他人,可以在下面评论来告诉我!:]

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,015评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,262评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,727评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,986评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,363评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,610评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,871评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,582评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,297评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,551评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,053评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,385评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,035评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,079评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,841评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,648评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,550评论 2 270

推荐阅读更多精彩内容