原文地址 翻译:DeveloperLx
更新日志: 本教程已由Warren Burton更新至支持Xcode 9及Swift 4的版本。 原教材 由Mikael Konutgan撰写。
菜单栏app长期以来都是macOS中的主打项。很多app例如 1Password 和 Day One 都有着相应的菜单栏app。其它app诸如 Fantastical 甚至仅仅存在于macOS的菜单栏中。
在本教程中,你将会构建一个菜单栏app,它会在一个popover中展示鼓舞人心的名言。你会在其中学到:
- 如何创建一个菜单栏的图标
- 如何让app仅仅存在于菜单栏中
- 如何为用户添加菜单
- 如何让popover按照需求展示和隐藏 - 也就是说事件监视
打开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 均不选中。
最后,再次点击 Next ,选择一个地方来保存项目,并点击 Create 。
设置完成新项目之后,打开 AppDelegate.swift 并添加下列的property到类中:
let statusItem = NSStatusBar.system.statusItem(withLength:NSStatusItem.squareLength)
这就创建了一个 Status Item - 也就是说app的icon - 带有一个固定的长度并放置在菜单栏中,用户能够看到并使用它。
接下来,你需要为status item关联一个图像,让你的app能够在菜单栏中易于识别。
在project navigator中周到
Assets.xcassets
,下载图片
StatusBarButtonImage@2x.png
并将它拖拽到asset目录中。
选择图片并打开attributes inspector。将 Render As 选项改变为Template Image。
如果你想使用自己的图片,请确保它是黑白图片,并将它配置为template image。这样Status Item才能在亮色和暗色主题下的菜单栏中都看起来比较不错。
回到
AppDelegate.swift
,添加下列的代码到
applicationDidFinishLaunching(\_:)
中
if let button = statusItem.button {
button.image = NSImage(named:NSImage.Name("StatusBarButtonImage"))
button.action = #selector(printQuote(_:))
}
这会把你刚刚添加的图片作为icon配置到status item的上面,然后配置了一个当你点击这个item时会发生的动作。现在这里会出现一个错误,但你很快就会修复它。
在类中添加下列的代码:
@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
这个标记。它会把这个方法暴露给Objective-C的运行时,来让按钮响应这里的动作。
运行app,你就会看到新的菜单栏app了。你办到了!
注意 :如果菜单栏中的app太多了,可能你会无法看到这个按钮。可以切换到一个比Xcode的菜单少的app(例如Finder),你应该就可以看到它了。
每次你点击菜单栏的icon的时候,你就会看到名言被打印到了Xcode的控制台上。
在你完成完整功能的菜单栏app之前,还有两件小事需要做一下。
- 禁用dock中的icon。
- 移除主窗口。
要禁用dock icon,只需打开 Info.plist ,添加一个新的key Application is agent (UIElement) 并将它的值设为 YES 。
LSUIElement
键来进行设置。
现在该来处理主窗口了。
-
打开
Main.storyboard
- 选择Window Controller场景并删除它。
- 只留下View Controller场景。你很快就会用到它。
运行项目。你就会看到这个app已经没有主窗口了,也没有讨厌的dock icon,只有一个简单的status item放在菜单栏中。为自己庆祝一下吧 :]
通常,点击它只有可怜兮兮的一个动作,是不值得成为一个菜单栏的app的。为你的app添加更多功能,最简单的办法就是添加菜单了。因此添加下列的方法到
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
,并添加了三个
NSMenuItem
的实例,然后把status item的菜单设置为这个新的菜单。
这里有一些事值得去关注:
- menu item的title是将会出现在菜单中的文本。如果需要的话,这里是实现本地化的一个好地方。
- 动作,类似于按钮或者其它控件的动作,是当你在点击菜单项的时候将会调用的方法。
-
keyEquivalent
是用来激活菜单项的键盘快捷键。小写的字母将使用 Cmd 作为修饰键,而大写的字母则使用 Cmd+Shift 作为修饰键。这里的键盘快捷键只有当app位于应用的最前方且被激活的时候才会work。因此,在这个case中,这个菜单或其它的窗口必须在可见的情况下,键盘快捷键才可以使用,因为我们的app没有dock的icon。 -
separatorItem
则是一个不活动的菜单项,它只会表现为其它菜单项之间的一条灰色的线。我们可以用它来对菜单中的功能进行分组。 -
printQuote:
动作是你早已定义在AppDelegate
中的方法,而terminate:
动作则是定义在NSApplication
中的方法。
运行项目,然后点击status item,你就会看到一个菜单。庆祝一下进展吧!
试验一下你的选项吧 - 选择 Print Quote 会在Xcode的控制台中打出一句名言,而选择 Quit Quotes 则会退出app。
你已经看到了,使用代码来创建菜单是多么得容易,但在Xcode的控制台中展示名言,显然是无法被大多数你的终端用户所接受的。接来下,我们就会使用一个简单的view controller,来替代菜单展示名言。
点击 File/New/File… ,选择 macOS/Source/Cocoa Class 模板并点击 Next 。
-
将这个类命名为
QuotesViewController
。 -
将它成为
NSViewController
的子类。 - 确保 Also create XIB file for user interface 未被勾选。
- 将语言设置为 Swift 。
最后,再次点击 Next ,选择一个位置来保存文件(在项目目录下的 Quotes 子目录是一个不错的地方),然后点击 Create 。
现在打开 Main.storyboard 。展开 View Controller场景 并选择 View Controller 实例。
首选选择
Identity Inspector
并将Class修改为
QuotesViewController
,将
Storyboard ID
设置为
QuotesViewController
下面添加下列的代码到 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
}
}
上述代码...
- 获取到对 Main.storyboard 的引用。
- 创建一个能够匹配你刚设定的identifier的 Scene identifier 。
- 实例化 QuotesViewController 并返回。
创建了这个方法之后,其它用到
QuotesViewController
的东西就不需要了解如何去实例化它了。直接调用它就阔以了:]
注意位于
guard
语句中的
fatalError
。使用它或
assertionFailure
,来让自己或团队其它的成员知晓这里出现了问题,是一个非常好的习惯。
现在返回到 AppDelegate.swift 。添加一个新的property声明到类中:
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(_:)
,接下来我们就会实现它,用popover去展示存在于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()
方法会向用户展示popover。你只需要提供一个源rect,macOS会基于此来展示popover和箭头,这样看起来popover就像是从菜单栏中的icon中弹出来的。
closePopover()
会把popover关闭掉,而
togglePopover()
则会基于当前的状态,来确定是打开还是关闭popover。
运行项目,然后点击菜单栏中的icon,来查看展示空popover和关闭它的效果。
你的popover已能够正常地工作,但那些令人鼓舞的内容在哪里?你看到的只有一个空空的view,没有名言。猜猜看接下来该做什么了?
首先,你需要一个model来储存名言和属性。点击 File/New/File… 并选择 macOS/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")
]
}
extension Quote: CustomStringConvertible {
var description: String {
return ""(text)" — (author)"
}
}
上述代码定义了一个简单的名言结构体,其中包含一个静态的property来返回全部的名言。同时,你让
Quote
遵循
CustomStringConvertible
协议,这样你就能够很轻松地获得一个准备好的字符串。
你已经取得了很大的进展,但你现在需要一些方法,来在UI中展示全部的名言。
打开
Main.storyboard
并拖拽三个
Push Button
,一个
Multiline Label
到view controller中。
把按钮和标签拖拽成就像下面的样子。蓝色的虚线会帮助你去定位到正确的位置去进行布局:
你可以独自完成添加自动布局的约束,来适配用户的交互么?在查看下面的详解之前,可以首先自己进行一下尝试。如果成功的话,就可以略过下面这部分了,你可以直接奖励给自己一颗星星。
下列是获取正确布局所需的约束:
你会发现出现了一些布局的错误,因为没有足够的信息以确定布局。
因此将label的 Horizontal Content Hugging Priority 设置为249,以允许label恰当地增长。
在得到满意的布局之后:
-
设置左侧按钮的图片为
NSGoLeftTemplate
,并删除title。 -
设置右侧按钮的图片为
NSGoRightTemplate
,并删除title。 - 设置下侧按钮的title为 Quit Quotes 。
- 设置label的对齐方式为居中对齐。
- 设置label的 Line Break 模式为 Word Wrap 。
现在,打开
QuotesViewController.swift
,并添加下列的代码到
QuotesViewController
类的实现中:
@IBOutlet var textLabel: NSTextField!
添加下列的extension到类的实现之后。现在在 QuotesViewController.swift 中你会有两个extension。
// MARK: Actions
extension QuotesViewController {
@IBAction func previous(_ sender: NSButton) {
}
@IBAction func next(_ sender: NSButton) {
}
@IBAction func quit(_ sender: NSButton) {
}
}
你现在已为label添加了一个outlet,你将会用这个label来展示鼓舞人心的名言。还添加了三个动作,你将把它们分别连接到三个按钮上。
你会注意到源码编辑器的左侧已出现了一些小小的圆形。只要你使用
@IBAction
和
@IBOutlet
关键字,这些原型就会出现。
现在,你就会使用它们来将代码和UI进行连接。
在project navigator中按住 alt 点击 Main.storyboard ,就会把storyboard打开到Assistant Editor的右侧,而源码位于左侧。
拖拽靠近
textLabel
的小圆形到interface builder中的label上。并用相同的方式把previous,next和quit动作连接到相应的按钮上。
注意 :如果你在上面的步骤中遇到了困难,请参考我们的 macOS教程 ,你会在这里找到macOS开发的引导性的教程,涉及到其中的方方面面,包括在interface builder中添加view和约束,以及连接outlets和actions。
站起来伸一下懒腰吧,做一个胜利的手势,你已成功地完成了一堆interface builder的工作。
运行项目,你的popover现在应该看起来是下面这个样子了:
你对上面的popover采取了view controller的默认尺寸。如果你想要一个更小或更大的popover,只需在storyboard中调整view controller即可。
关于交互的部分现在已完成了,但还并没有实现。这些按钮现在正等着你去实现,当你点击它们的时候,该做些什么事情 - 不要把它们挂在那里。
如果你尚未使用 Cmd-Return 或 View > Standard Editor > Show Standard Editor 来关闭Assistant Editor
打开 QuotesViewController.swift 并添加下列的property到类的实现中:
let quotes = Quote.all
var currentQuoteIndex: Int = 0 {
didSet {
updateQuote()
}
}
quotes
property会持有所有的名言,而
currentQuoteIndex
则持有着当前的名言。
currentQuoteIndex
同时会作为一个property的观察器,他会在序号发生变化的时候,使用新的名言来更新文本标签。
接下来,添加下列的方法到类中:
override func viewDidLoad() {
super.viewDidLoad()
currentQuoteIndex = 0
}
func updateQuote() {
textLabel.stringValue = String(describing: quotes[currentQuoteIndex])
}
当这个view被加载的时候,你就会把当前名言的序号设置为0,相应地来更新UI。
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
则会退出当前的app。
再次运行项目, 现在 你就可以循环地浏览名言及退出app了!
你的用户会在你小小的谦逊的菜单栏app上期待一个特性,就是当你点击app之外的任何地方,让popover自动地关闭。
菜单栏的app应当在点击它的时候打开UI,而当用户移动到下一个项目的时候就消失。因此,你需要一个macOS的全局事件监听器。
接下来我们就会创建一个时间监听器,它可以复用到你所有的项目上,例如当展示popover的时候就可以用到它。
我赌你早已变得更聪明了!
创建一个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()
和
stop()
方法了。是不是很容易?当然这个类也会在它的析构器中调用
stop()
方法来实现清理自己。
最后一次打开 AppDelegate.swift ,并为其添加一个新的property声明:
var eventMonitor: EventMonitor?
然后,在
applicationDidFinishLaunching(_:)
的尾部添加下列代码来配置事件监听器:
eventMonitor = EventMonitor(mask: [.leftMouseDown, .rightMouseDown]) { [weak self] event in
if let strongSelf = self, strongSelf.popover.isShown {
strongSelf.closePopover(sender: event)
}
}
上述代码会监听系统的鼠标左键和有件按下的事件,当这些事件发生的时候,就会将popover关闭。注意,发送到你的自己app上的事件,这里是不会响应的。这就是为何当你点击popover中的内容时,它并不会消失的原因。:]
你使用了一个指向self的
weak
引用以避免
AppDelegate
和
EventMonitor
之间潜在的
循环引用
。在本例中它并非是必要的,因为这里只有一次循环,但这却是一个值得在你自己的代码中关注的地方,尤其是你在两个对象之间使用block处理回调的时候。
添加下列的代码到
showPopover(_:)
的尾部:
eventMonitor?.start()
这样就会在popover出现的时候启动事件监听器。
然后添加下列的代码到
closePopover(_:)
的尾部:
eventMonitor?.stop()
这样当popover关闭的时候,就会停止事件监听器的监听。
全部都搞定了!再次运行项目。点击菜单栏的icon来展示popover,然后点击外面的任意地方来关闭它。Awesome!
这里是 最终的项目 ,包含了上述教程中你所开发的所有的代码。
你已经明白了如何在你菜单栏的status items上设置菜单和popovers – 为何不尝试下使用属性字符创或多个label来美化名言,或是连接web的后端来拉群新的名言。也许你还可以找到使用键盘快捷键去循环浏览名言的方法。
一个寻找其它可能性的很棒的地方,就是阅读苹果的官方文档,如
NSMenu
,
NSPopover
和
NSStatusItem
。
需要考虑的一件事是你希望你的用户把你的app当做一个非常珍贵的屏幕上的不动产,因此当你感觉在状态栏上有一个item很酷的时候,可能你的用户并不这么认为。很多的app会提供偏好选项来让用户确定是打开还是隐藏这个item。你可以把它作为自己的一个进阶的练习。
感谢你抽出时间来学习如何为macOS打造一个很酷的popover菜单的app。它相当得简单,但可以看出来,你所学到的这些概念将会成为你开发各种app的绝佳基础。