Skip to content

Latest commit

 

History

History
1395 lines (1374 loc) · 55.4 KB

Menus and Popovers in Menu Bar Apps for macOS.md

File metadata and controls

1395 lines (1374 loc) · 55.4 KB

macOS教程:在Menu Bar App中的Menu和Popover

更新日志: 本教程已由Warren Burton更新至支持Xcode 9及Swift 4的版本。 原教材 由Mikael Konutgan撰写。

Learn how to make a menu bar macOS app with a popover!

学习如何使用popover来制作一个macOS app的菜单栏!

菜单栏app长期以来都是macOS中的主打项。很多app例如 1Password Day One 都有着相应的菜单栏app。其它app诸如 Fantastical 甚至仅仅存在于macOS的菜单栏中。

在本教程中,你将会构建一个菜单栏app,它会在一个popover中展示鼓舞人心的名言。你会在其中学到:

  • 如何创建一个菜单栏的图标
  • 如何让app仅仅存在于菜单栏中
  • 如何为用户添加菜单
  • 如何让popover按照需求展示和隐藏 - 也就是说事件监视
注意: 本教程假定你已熟悉了Swift和macOS。如果你需要再温习一下,欢迎关注我们的 macOS Development for Beginners 系列教程。

入门

打开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 均不选中。

configure new project

最后,再次点击 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了。你办到了!

light mode menu bar

dark mode menu bar

注意 :如果菜单栏中的app太多了,可能你会无法看到这个按钮。可以切换到一个比Xcode的菜单少的app(例如Finder),你应该就可以看到它了。

每次你点击菜单栏的icon的时候,你就会看到名言被打印到了Xcode的控制台上。

隐藏Dock中的Icon和主窗口

在你完成完整功能的菜单栏app之前,还有两件小事需要做一下。

  1. 禁用dock中的icon。
  2. 移除主窗口。

要禁用dock icon,只需打开 Info.plist ,添加一个新的key Application is agent (UIElement) 并将它的值设为 YES

注意: 如果你是一个编辑plist editor的专家,你可以随意地使用 LSUIElement 键来进行设置。

现在该来处理主窗口了。

  • 打开 Main.storyboard
  • 选择Window Controller场景并删除它。
  • 只留下View Controller场景。你很快就会用到它。

delete window scene

运行项目。你就会看到这个app已经没有主窗口了,也没有讨厌的dock icon,只有一个简单的status item放在菜单栏中。为自己庆祝一下吧 :]

添加一个菜单到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,你就会看到一个菜单。庆祝一下进展吧!

status bar item menu

试验一下你的选项吧 - 选择 Print Quote 会在Xcode的控制台中打出一句名言,而选择 Quit Quotes 则会退出app。

给Status Item添加一个Popover

你已经看到了,使用代码来创建菜单是多么得容易,但在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
}
}

上述代码...

  1. 获取到对 Main.storyboard 的引用。
  2. 创建一个能够匹配你刚设定的identifier的 Scene identifier
  3. 实例化 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,没有名言。猜猜看接下来该做什么了?

实现Quote View Controller

首先,你需要一个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中展示全部的名言。

设置View Controller的UI

打开 Main.storyboard 并拖拽三个 Push Button ,一个 Multiline Label 到view controller中。

把按钮和标签拖拽成就像下面的样子。蓝色的虚线会帮助你去定位到正确的位置去进行布局:

view controller layout

你可以独自完成添加自动布局的约束,来适配用户的交互么?在查看下面的详解之前,可以首先自己进行一下尝试。如果成功的话,就可以略过下面这部分了,你可以直接奖励给自己一颗星星。

解决方案

下列是获取正确布局所需的约束:

  • 将左侧的按钮固定到距左边20,且竖直居中的位置。
  • 将右侧的按钮固定到距右边20,且竖直居中的位置。
  • 将底部的按钮固定到距底部20,且水平居中的位置。
  • 将标签固定到距离左右两边均为20,且竖直居中的位置。
  • constraints for layout

    你会发现出现了一些布局的错误,因为没有足够的信息以确定布局。

    因此将label的 Horizontal Content Hugging Priority 设置为249,以允许label恰当地增长。

    resolve constraint conflicts

    在得到满意的布局之后:

    • 设置左侧按钮的图片为 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来展示鼓舞人心的名言。还添加了三个动作,你将把它们分别连接到三个按钮上。

    将代码连接到Interface Builder上

    你会注意到源码编辑器的左侧已出现了一些小小的圆形。只要你使用 @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了!

    final UI

    事件监听

    你的用户会在你小小的谦逊的菜单栏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的绝佳基础。