MacMenuBar 是一个 Swift 软件包,用于在没有 Storyboard 的 SwiftUI 应用程序中创建和使用 macOS 的主菜单。它允许你使用与 SwiftUI 视图相同的声明式风格来创建菜单。
让我们直接深入了解如何使用它。
要使用 MacMenuBar,你需要创建一个配置为使用它的 Xcode 项目。 最简单的方法是使用命令行从此 repo 安装 Xcode 项目模板
git clone https://github.com/chipjarred/MacMenuBar.git
cd MacMenuBar/Templates
./install.bash
安装模板后,在 Xcode 中创建一个新的 macOS 应用程序时,名为“App using MacMenuBar”(使用 MacMenuBar 的应用程序)的模板将成为你的选项之一。 使用该模板创建一个新项目时,你唯一需要做的事情是为 MacMenuBar 添加一个软件包依赖项。 如果你还不知道如何操作,则新建项目中的 README.md 文件包含你需要的所有说明。
如果你希望手动设置项目,请参阅 ManualSetup.md 中的说明。
现在,我们将创建一个极简的菜单栏,其中仅包含应用程序菜单和常用的 File
菜单,以开始使用。
创建一个名为 MenuBar.swift
的新文件,并包含以下代码
import MacMenuBar
struct MainMenuBar: MenuBar
{
public var body: StandardMenuBar
{
StandardMenuBar
{
StandardMenu(title: "$(AppName)")
{
TextMenuItem(title: "About $(AppName)", action: .about)
MenuSeparator()
TextMenuItem(title: "Quit $(AppName)", action: .quit)
}
StandardMenu(title: "File")
{
TextMenuItem(title: "New", action: .new)
TextMenuItem(title: "Open...", action: .open)
StandardMenu(title: "Open Recent...")
MenuSeparator()
TextMenuItem(title: "Close", action: .close)
TextMenuItem(title: "Save...", action: .save)
TextMenuItem(title: "Save As...", action: .saveAs)
TextMenuItem(title: "Revert to Saved", action: .revert)
MenuSeparator()
TextMenuItem(title: "Page Setup...", action: .pageSetup)
TextMenuItem(title: "Print", action: .print)
}
}
}
}
这看起来有点像你编写 SwiftUI View
代码的方式,不是吗?
目前,MacMenuBar 尚未连接到 SwiftUI。 对于纯 SwiftUI 项目,我们在 @main
App
中进行连接
import SwiftUI
import MacMenuBar
@main
struct MyApp: App
{
init() { setMenuBar(to: MainMenuBar()) } // <- ADD THIS
var body: some Scene {
WindowGroup {
return ContentView()
}
}
}
如果你的项目使用提供应用程序委托而不是 @main
的旧模型,请在 AppDelegate.applicationDidFinishLaunching()
的末尾添加对 setMenuBar(to:)
的调用
func applicationDidFinishLaunching(_ aNotification: Notification)
{
// Create the SwiftUI view that provides the window contents.
let contentView = MainContentView()
// Create the window and set the content view.
window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 480, height: 300),
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
backing: .buffered,
defer: false
)
window.isReleasedWhenClosed = false
window.center()
window.setFrameAutosaveName("Main Window")
window.contentView = NSHostingView(rootView: contentView)
window.makeKeyAndOrderFront(nil)
setMenuBar(to: MainMenuBar()) // <- ADD THIS
}
现在,你闪亮的新菜单栏(尽管只是一个空壳)已设置完毕。运行该应用程序以查看其工作。 当然,现在你能做的唯一的事情就是退出并显示“关于”框,但是在全新的项目中,这实际上是你能使用我们在删除的 Main.storyboard
文件中由 Apple 提供的菜单栏所做的全部事情。
在我们开始添加功能之前,让我们仔细看一下一些内容,因此请返回到 MenuBar.swift
。
你会看到 MainMenuBar.body
返回 StandardMenuBar
。 可以将其视为类似于 HStack
,但是适用于菜单,并且我们不会将其嵌套在其他菜单中。它严格来说是一个顶层事物,代表菜单栏。
菜单栏中我们实际的菜单声明为 StandardMenu
。 这些对应于未单击任何内容时在菜单栏中看到的主要项目。 你为每个菜单提供一个 String
用作其标题,但是,该文本比普通的 String
更智能,因为它实际上会自动为你执行两件事。
它查找你指定为标题的 String
的本地化版本。 如果包含 Menus.strings
文件(或应用程序包中的相应本地化子目录之一),则它使用该文件。 如果找到,它将使用其中的本地化字符串。 如果没有找到,它将按原样使用该字符串进行下一步操作。
它在从步骤 1 返回的字符串中执行字符串替换,无论是本地化字符串还是其他字符串。 应用程序菜单的标题字符串包含 "$(AppName)"
。 这会自动替换为你的程序名称。 替换字符串采用 $(SomeName)
的形式。 MacMenuBar
查找要替换括号内内容的值。 它首先检查它是否是预定义的符号(AppName
就是),如果不是,则在应用程序的进程环境 (ProcessInfo.processInfo.environment
) 中查找具有匹配名称的变量。 因此,"$(PATH)"
的计算结果为 PATH
变量在你的应用程序环境中设置的值。 如果找不到此类变量,则其计算结果为自身,按原样。
MacMenuBar
中所有菜单和菜单项的标题都以这种方式工作。 这使得本地化变得容易。 还计划了更多的替换选项。
在顶层的 StandardMenu
实例中,我们有两种菜单项:TextMenuItem
和 MenuSeparator
。
TextMenuItem
是你最常用的基本菜单项。 你为其指定一个标题和一个操作。 上面的代码使用标准的菜单操作,但是正如你稍后将看到的,你可以定义自己的操作。 有关标准操作的完整列表,请参见 StandardMenuItemAction.swift
。 这些标准的菜单项操作使用你从普通 Cocoa 应用程序中熟悉的常用响应者链,以及 Mac 用户期望的常用快捷键。
MenuItemSeparator
给出了熟悉的分隔线,用于分隔菜单中相关菜单项的组。
除了菜单项,我们还可以通过简单地使用另一个 StandardMenu
而不是菜单项类型来嵌套菜单中的菜单,以创建子菜单。 我们的 “文件”菜单中的 “打开最近使用的...” 子菜单就是这样的一个例子。
最后,我们添加到 MyApp
的初始化器实际上是将应用程序的主菜单设置为我们的 MainMenuBar
的东西。
现在我们有了一个菜单栏,并且我们以一种简单的声明式方式创建了它,这很棒,但是它并没有做太多事情。 让我们来补救一下。 你可以通过创建一个具有与之关联的操作的菜单项来做到这一点。
定义自定义操作的最简单方法是使用闭包。 假设你有一个显示日志窗口的 showLog()
函数,并且你想添加一个 Debug
菜单项以显示该日志。 在 MainMenuBar.body
的末尾,你可以添加有条件编译的 Debug
菜单
import MacMenuBar
struct MainMenuBar: MenuBar
{
public var body: StandardMenuBar
{
StandardMenuBar
{
StandardMenu(title: "$(AppName)")
{
TextMenuItem(title: "About $(AppName)", action: .about)
MenuSeparator()
TextMenuItem(title: "Quit $(AppName)", action: .quit)
}
StandardMenu(title: "File")
{
TextMenuItem(title: "New", action: .new)
TextMenuItem(title: "Open...", action: .open)
StandardMenu(title: "Open Recent...")
MenuSeparator()
TextMenuItem(title: "Close", action: .close)
TextMenuItem(title: "Save...", action: .save)
TextMenuItem(title: "Save As...", action: .saveAs)
TextMenuItem(title: "Revert to Saved", action: .revert)
MenuSeparator()
TextMenuItem(title: "Page Setup...", action: .pageSetup)
TextMenuItem(title: "Print", action: .print)
}
}
// >>>>>>>> ADDED THE FOLLOWING BIT <<<<<<<<<<
#if DEBUG
StandardMenu(title: "Debug")
{
TextMenuItem(title: "Show Log", keyEquivalent: .command + .option + "l") { _ in
showLog()
}
}
#endif
}
}
如你所见,这会将一个名为 Debug
的新菜单添加到菜单栏。 它包含一个名为 Show Log
的菜单项,但是与我们之前的 TextMenuItem
示例不同的是,现在我们既为菜单项指定了快捷键,又指定了在选择菜单项时调用的操作闭包。
请注意,在指定快捷键时,我们使用了小写的“L”。 使用大写字母表示还需要按下 Shift 键。 在这种情况下,"L"
和 .shift + "l"
是等效的。 如果未指定 .command
、.option
或 .control
中的至少一个,则暗示 .command
,因此,如果仅使用 "l"
作为快捷键,它将被视为 .command + "l"
。 另请注意,我们不需要修改 NSEvent
修改器标志。 我们可以通过命名修饰符并将其添加到基本键来指定它们。
我们在应用程序和 “文件” 菜单中声明的 StandardMenuItemAction
示例已经隐式地与它们关联了标准的快捷键,因此无需指定它们。
如果你不希望闭包菜单项具有快捷键,则可以指定 .none
。
当然,你还可以使用任意 Selector
指定操作。
很多菜单项更新(尤其是启用和禁用它们)都是通过 Cocoa 的 响应者链 自动发生的,但这基于响应者链中的某些对象是否响应与给定菜单关联的 Objective-C 选择器。 这也是 Cocoa 应用程序在 Swift 中的工作方式,它适用于 MacMenuBar
中基于 Selector
的菜单操作,前提是 SwiftUI 视图的基础 AppKit
对象响应相应的选择器。 另一方面,MacMenuBar
中基于闭包的菜单操作(例如我们在上一个示例中编写的菜单操作)需要更明确的处理。
假设我们只想在显示日志后禁用 “Show Log” 菜单项。 我们可以使用 afterAction
方法指定该行为,该方法将菜单项本身作为其参数
#if DEBUG
StandardMenu(title: "Debug")
{
TextMenuItem(title: "Show Log", keyEquivalent: .command + .option + "l") { _ in
showLog()
}
.afterAction { $0.canBeEnabled = false } // <- ADDED THIS
}
#endif
顾名思义,.afterAction
在菜单项调用其操作闭包后调用。 还有一个 .beforeAction
方法,其工作方式相同,只是它在调用操作闭包之前立即调用。
现在,当你从 “Debug” 菜单中选择 “Show Log” 时,该项目将被禁用。 当然,这实际上不是我们想要的,因为它即使在你关闭日志窗口后仍将保持禁用状态。 我们希望根据日志窗口当前是否可见来启用或禁用它。 我们可以使用 .enabledWhen
代替 .afterAction
#if DEBUG
StandardMenu(title: "Debug")
{
TextMenuItem(title: "Show Log", keyEquivalent: .command + .option + "l") { _ in
showLog()
}
.enabledWhen { !logWindow.isVisible } // <- CHANGED THIS
}
#endif
每当打开项目的父菜单时,都会调用 .enabledWhen
以确定是否启用该项目。“Show Log” 菜单项现在将在日志窗口可见时禁用,并在日志窗口隐藏时启用。
MacMenuBar
用于确定是否应启用或禁用菜单的实际过程是
如果菜单项没有关联的操作,则禁用。 如果确实有一个操作,则验证将进行到下一步。
如果菜单项的操作是基于 Selector
的操作,并且响应者链中没有对象响应该选择器,则该菜单项被禁用。 如果链中的某个响应者确实响应了该选择器,或者如果该操作是闭包操作,则验证将进行到下一步。
如果菜单项的 .canBeEnabled
属性为 false
,则该菜单项将被禁用。 如果它是 true
(这是默认值),则验证过程将继续执行下一步。
如果没有使用 .enabledWhen
来为项目设置验证闭包,则启用该项目。 如果它确实具有验证闭包,则验证将进行到下一步。
使用 .enabledWhen
设置的验证闭包用于确定是否启用菜单项。 如果闭包返回 true
,则启用它。 如果它返回 false
,则禁用它。
上面的列表扩展了 Cocoa 的内置菜单启用规则的逻辑。 特别要注意的是,如果同时使用 .canBeEnabled
和 .enabledWhen
并且 .canBeEnabled
为 false
,则它将覆盖 .enabledWhen
的闭包。 在大多数情况下,使用其中一个而不是两个都有意义,但是这确实使你可以告诉 MacMenuBar
无条件禁用菜单项,即使它正在使用 .enableWhen
。
在当前状态下,我们的 “Show Log” 菜单项现在当然可用,但它是否真正符合 Mac 用户的期望?
或许,当日志可见时,将菜单项更改为“隐藏日志”,而当日志不可见时,改回“显示日志”会更好。这样,我们可以使用快捷键来切换日志窗口的可见状态。我们可以通过修改我们的操作闭包,使其根据日志窗口的当前可见性来显示或隐藏它,并使用 .updatingTitleWith
方法来指定一个用于更新标题的闭包。
#if DEBUG
StandardMenu(title: "Debug")
{
TextMenuItem(title: "Show Log", keyEquivalent: .command + .option + "l") { _ in
if logWindow.isVisible {
hideLog()
}
else { showLog() }
}
.updatingTitleWith { logWindow.isVisible ? "Hide Log" : "Show Log" }
}
#endif
传递给 .updatingTitleWith
的闭包会在用户打开该菜单项的父菜单,并确定该菜单项的启用状态之前被调用。这让你有机会通过更改标题来更新菜单项的外观。
有些菜单项代表一个应用程序设置,用户可以启用或禁用它。当该设置启用时,菜单项旁边会显示一个复选标记。
假设我们的日志记录器允许我们通过将其 .detailedLogging
属性设置为 true
来选择是否需要比平时更详细的日志记录。我们可以使用 TextMenuItem
中的 .updatingStateWith
方法来实现这一点。让我们在“调试”菜单中添加一个新的菜单项来实现这个功能。
#if DEBUG
StandardMenu(title: "Debug")
{
TextMenuItem(title: "Show Log", keyEquivalent: .command + .option + "l") { _ in
if logWindow.isVisible {
hideLog()
}
else { showLog() }
}
.updatingTitleWith { logWindow.isVisible ? "Hide Log" : "Show Log" }
// >>>>>>>> ADDED THE FOLLOWING BIT <<<<<<<<<<
TextMenuItem(title: "Detailed Logging") { _ in
logger.detailedLogging.toggle()
}
.updatingStateWith { logger.detailedLogging ? .on : .off }
}
#endif
现在我们有了一个“详细日志记录”菜单项,它会显示当前是否启用了详细日志记录,并允许我们通过选择它来切换该设置。
传递给 .updatingStateWith
的闭包会在用户打开菜单时被调用,就像 .updatingTitleWith
一样。除了 .on
和 .off
,闭包还可以返回 .mixed
,它在菜单项中显示为破折号而不是复选标记。
让我们暂时离开“调试”菜单,并为我们应用程序的用户提供菜单功能。我们将添加一个新的“主题”菜单,以便用户可以选择我们应用程序使用的配色方案,并将用我们的主题名称填充它。我们可以使用 ForEach
从主题名称数组中生成它们,而不用单独声明每个菜单项。
StandardMenu(title: "Themes")
{
ForEach(["Light", "Dark", "Sahara", "Congo", "Ocean"])
{ themeName in
TextMenuItem(title: themeName) { _ in setTheme(to: themeName) }
.updatingStateWith { currentTheme == themeName ? .on : .off }
}
}
这个自动生成的“主题”菜单比为每个主题的菜单项都输入声明要好得多,但仍然有改进的空间。假设我们稍后允许用户定义并保存他们自己的自定义主题。我们也希望显示这些主题。ForEach
也能做到这一点。事实上,它已经在每次打开菜单时动态填充菜单,只是我们无法分辨,因为我们给它的是静态输入。我们传入数组的参数实际上是一个 @autoclosure
,它会在每次打开菜单时被调用。因此,如果我们传入的是动态的东西,我们的“主题”菜单的内容也会是动态的。
让我们定义一个动态主题列表,不加思索地命名为 themesList
。为了这个例子,我们将把它作为一个计算的全局变量,随机选择我们现有主题的一个子集。
var fixedThemes = ["Light", "Dark", "Sahara", "Congo", "Ocean"]
var themesList: [String] {
return fixedThemes.filter { currentTheme == $0 || Bool.random() }
}
然后我们修改我们的“主题”菜单声明来使用它
StandardMenu(title: "Themes")
{
ForEach(themesList) // <- CHANGED THIS
{ themeName in
TextMenuItem(title: themeName) { _ in setTheme(to: themeName) }
.updatingStateWith { currentTheme == themeName ? .on : .off }
}
}
现在,我们的菜单每次打开时都会列出我们当前的主题,以及其他可用主题的不同随机选择。
这里使用的 ForEach
是故意命名为与 SwiftUI 中的 ForEach
匹配的,因为它服务于类似的目的,但我们使用的是 MacMenuBar.ForEach
而不是 SwiftUI.ForEach
。SwiftUI 的 ForEach
需要在执行期间的任何时间响应动态变化的数据,而 MacMenuBar 的 ForEach
只需要在用户打开其父菜单时才需要这样做,而且它生成的是菜单项而不是 SwiftUI 的 View
。
如果你习惯了 Mac 开发,你可能知道 macOS 会自动将少量自己的菜单项插入到你的菜单中。它将 开始听写…
和 表情符号和符号
注入到你的 编辑
菜单中。如果你提供一个 视图
菜单,它将在那里注入 进入全屏幕
。如果你不提供 视图
菜单,但你提供一个 窗口
菜单,它将在那里插入 进入全屏幕
。
如果你对这些插入的菜单感到满意,MacMenuBar 可以很好地处理它们。问题在于当你决定覆盖这些菜单项的某些方面时。例如,注入的 进入全屏幕
项具有进入全屏模式的命令键快捷键,但没有退出全屏模式的快捷键。很多应用程序都希望使用 escape
键退出全屏模式,这对用户来说很方便。要在 MacMenuBar 中做到这一点,你需要自己实现 进入全屏幕
项。例如
StandardMenu(title: "View")
{
TextMenuItem(title: "Show Toolbar", action: .showToolbar).enabled(false)
TextMenuItem(title: "Customize Toolbar...", action: .customizeToolbar).enabled(false)
MenuSeparator()
TextMenuItem(title: "Show Sidebar", action: .showSidebar).enabled(false)
TextMenuItem(title: "Enter Full Screen", action: .enterFullScreen)
.afterAction
{ menuItem in
if AppDelegate.isFullScreen
{
menuItem.title = "Exit Full Screen"
KeyEquivalent.escape.set(in: menuItem)
}
else
{
menuItem.title = "Enter Full Screen"
(.command + .control + "f").set(in: menuItem)
}
}
}
现在,用户可以使用 command-control-f
进入全屏模式,就像他们通常做的那样,但他们也可以通过按 escape
键退出全屏模式。
但这里有一个小问题。因为 MacMenuBar 支持动态填充的菜单,并且因为对于 进入全屏幕
,macOS 会在每次打开菜单时检查它是否应该插入菜单项,所以它可能会在 MacMenuBar 更新菜单内容时尝试插入其菜单项。这可能会导致菜单项被添加两次。当你深入研究它以发现 macOS 处理其插入到你的 编辑
菜单与你的 视图
或 窗口
菜单的方式不同,前者发生在你应用程序在你的 AppDelegate
中设置主菜单时,后者发生在每次用户打开菜单时,这可能会更加令人沮丧。MacMenuBar 会捕获这些插入,并尝试仅在你尚未为同一选择器实现菜单项时才允许 macOS 插入的项。但是,如果你提供自己的选择器,你仍然可能最终得到重复的菜单项,并且由于 MacMenuBar 如何动态填充菜单,还存在一些其他极端情况。
为了处理这些潜在的问题,MacMenuBar 允许你简单地拒绝允许特定菜单的所有自动注入项。例如,要拒绝允许 macOS 自动将其 进入全屏幕
项插入到你的 视图
菜单中,请将 .refuseAutoinjectedMenuItems()
添加到该菜单的声明中,如下所示
StandardMenu(title: "View")
{
TextMenuItem(title: "Show Toolbar", action: .showToolbar).enabled(false)
TextMenuItem(title: "Customize Toolbar...", action: .customizeToolbar).enabled(false)
MenuSeparator()
TextMenuItem(title: "Show Sidebar", action: .showSidebar).enabled(false)
TextMenuItem(title: "Enter Full Screen", action: .enterFullScreen)
.afterAction
{ menuItem in
if AppDelegate.isFullScreen
{
menuItem.title = "Exit Full Screen"
KeyEquivalent.escape.set(in: menuItem)
}
else
{
menuItem.title = "Enter Full Screen"
(.command + .control + "f").set(in: menuItem)
}
}
}.refuseAutoinjectedMenuItems() // <-- ADDED THIS
在这种情况下,由于 macOS 可能会尝试将该项目添加到 窗口
菜单,请将 .refuseAutoinjectedMenuItems()
附加到你的 窗口
菜单声明中。
为了保持 Mac 用户所期望的体验,仅在你实现自动注入项的替代项时才这样做。