11.添加菜单控件¶
在上一章中,你创建了一个基于文档的应用程序。你导入了一个将Markdown
转换为HTML
的包,并添加了控件以允许用户选择预览样式。你甚至在SwiftUI
应用中包含了一个AppKit
视图。
在本章中,你将深入研究菜单。在第1节中,你为你的应用程序添加了菜单,所以其中一些内容你是熟悉的。但这些菜单只适用于整个应用程序的设置。现在你将学习一些不同的菜单技巧,以及如何跟踪活动窗口,以便你可以只对该窗口应用菜单操作。
添加样式文件¶
网页视图使用默认的样式来渲染HTML
,但如果能够通过使用层叠样式表(CSS
)来选择自己的样式,那就更好了。
打开你上一章的项目,或者打开本章下载的资料中的starter
项目。
接下来,打开本章下载资料中的assets
文件夹,找到StyleSheets
文件夹。
把这个文件夹拖到你的项目导航器中,选择Copy items if needed
,Create groups
和MarkDowner
目标:
这个新文件夹包含一组CSS
文件,有一些不同的样式,还有一个.swift
文件,为这些样式设置了一个枚举。你将使用这个枚举中的数据来生成菜单项。
创建一个新的菜单¶
你将会有很多的菜单代码,所以为了容纳这些代码,创建一个新的Swift
文件,叫做MenuCommands.swift
。将其内容替换为:
// 1
import SwiftUI
// 2
struct MenuCommands: Commands {
// 3
var body: some Commands {
// 4
EmptyCommands()
}
}
这有什么作用?
- 导入
SwiftUI
,因为菜单是SwiftUI
框架的一部分。 - 创建一个符合
Commands
的MenuCommands
结构,以便SwiftUI
将其识别为可以出现在菜单中的东西。 - 添加一个同样符合
Commands
的body
。 - 返回一个空菜单以避免错误。
这为你的菜单设置了基本的结构。现在,你可以开始创建它们。但是在你改变样式表之前,你需要一个方法来存储用户的选择。
首先,把这个添加到MenuCommands
的顶部,在body
之前:
@AppStorage("styleSheet")
var styleSheet: StyleSheet = .raywenderlich
这是使用@AppStorage
属性包装器来设置一个属性。正如你在前面看到的,这个属性包装器会自动将其值保存到UserDefaults
中,并在需要时进行检索。
生成菜单项¶
接下来,你将使用枚举法来为这个新菜单创建项目。将EmptyCommands()
替换为:
// 1
CommandMenu("Display") {
// 2
ForEach(StyleSheet.allCases, id: \.self) { style in
// 3
Button {
// 4
styleSheet = style
} label: {
// 5
Text(style.rawValue)
}
// keyboard shortcut goes here
}
// more menu items
}
// more menus
踏过这些线路,你:
- 创建一个新菜单,将其标题设为
Display
。 - 循环浏览
StyleSheet
枚举中的案例。因为这个枚举不符合Identifiable
,所以使用每个案例作为它自己的标识符。这些案例总是唯一的,而且顺序也不会改变,所以这不会造成问题。 - 为每个样式创建一个
Button
。 - 给每个按钮应用一个动作,设置
styleSheet
属性。 - 将按钮的内容设置为
Text
视图,显示每个样式的rawValue
。
你已经创建了一个菜单,但你还没有告诉你的应用程序要显示它。打开MarkDownerApp.swift
,在DocumentGroup
上添加这个修改器:
.commands {
MenuCommands()
}
这就把MenuCommands
附加到DocumentGroup
上。你添加到该结构中的任何菜单或菜单项现在都会出现在主菜单栏中。
构建并运行以检查你的新菜单:
它看起来不错,而且整洁的地方在于,它使用StyleSheet
枚举中的案例来建立自己,所以如果你添加任何新的样式表,它们会自动出现在菜单中。但是到目前为止,你的菜单什么都没做。
样式化的HTML
¶
为了让你的WebView
使用选定的样式,打开WebView.swift
。
首先,通过在顶部添加这个声明来让它访问存储的设置:
@AppStorage("styleSheet")
var styleSheet: StyleSheet = .raywenderlich
当你看到由MarkdownKit
包生成的HTML
代码时,你可能已经注意到它没有<head>
部分。因为那是HTML
文件指定样式的地方,所以你必须在WebView
加载HTML
字符串之前手动创建该部分。
给WebView
添加这个计算属性:
var formattedHtml: String {
return """
<html>
<head>
<link href="\(styleSheet).css" rel="stylesheet">
</head>
<body>
\(html)
</body>
</html>
"""
}
这使用了Swift
的多行字符串语法,将html
和styleSheet
属性包装成一个完整的HTML
文档。因为你把web
视图的baseURL
设置为应用程序的Bundle.main.resourceURL
,文件名,没有任何文件夹路径,就足以定位CSS
文件。
接下来,你必须告诉WebView
使用这个版本的HTML
,所以用这个版本替换updateNSView
:
func updateNSView(_ nsView: WKWebView, context: Context) {
nsView.loadHTMLString(
formattedHtml,
baseURL: Bundle.main.resourceURL)
}
现在你可以测试它了。构建并运行该应用程序,确保你有一些Markdown
样本,并测试所有的样式:
添加键盘快捷键¶
在第一节中,你添加了一个使用Picker
的菜单。这让你在选择的选项旁边有一个复选标记,但不允许使用键盘快捷键。由于这是一个编辑器应用程序,用户可以把他们的手放在键盘上是非常重要的,所以有键盘快捷键是首要任务。
在本书的早些时候,你用这种语法添加了一个键盘快捷方式:
.keyboardShortcut("t", modifiers: .command)
你可能认为第一个参数是一个String
,但它不是。它实际上是一个KeyEquivalent
,你可以用一个Character
来初始化它。这使得生成动态快捷方式比预期的要复杂,但你可以这样做。
在MenuCommands.swift
中,将// keyboard shortcut goes here
替换为:
.keyboardShortcut(KeyEquivalent(style.rawValue.first!))
这一行做了大量的工作!
从里到外,style.rawValue.first
得到样式的rawValue
的第一个字符。因为它必须存在以显示菜单,强制解包在这里是安全的,尽管它通常不是一个好的做法。
接下来,你用这个字符初始化一个KeyEquivalent
,最后,你有正确的对象类型传递给keyboardShortcut
修改器。
command
键是任何快捷方式的默认修改键,所以严格来说没有必要在调用中包括它。
现在构建并运行,在菜单中看到你的快捷方式。对它们进行全部测试。
即使你已经决定优先使用键盘快捷键而不是复选标记,你仍然可以使用造型来表示当前的选择。你不想在文本中手动添加一个复选标记。它出现在错误的地方,看起来不对。但是你可以修改菜单项Button
中的文本。
菜单项中的文本忽略了很多样式修改器,但它可以使用不同的颜色。
在Text(style.rawValue)
中添加这个修改器:
.foregroundColor(style == styleSheet ? .accentColor : .primary)
现在建立并运行,可以看到活动的样式表被清楚地显示出来:
因为这使用了语义学的颜色名称,所以它在浅色和深色模式下都能工作。有趣的是,它没有使用实际的重点颜色,在这个系统上是蓝色的,而是使用一种对比色,即使你把鼠标放在菜单项上,也会显示出它的背景颜色。
插入一个子菜单¶
另一个有用的功能是能够改变编辑器的字体大小。你可以把它作为一个子菜单添加到Display
菜单中,其中有增加、减少和重置字体大小的项目。
这是另一个应用程序范围内的设置,所以首先要在MenuCommands
的顶部定义另一个@AppStorage
属性:
@AppStorage("editorFontSize") var editorFontSize: Double = 14
接下来,将// more menu items
替换为:
// 1
Divider()
// 2
Menu("Font Size") {
// 3
Button("Smaller") {
if editorFontSize > 8 {
editorFontSize -= 1
}
}
// 4
.keyboardShortcut("-")
// 5
Button("Reset") {
editorFontSize = 14
}.keyboardShortcut("0")
Button("Larger") {
editorFontSize += 1
}.keyboardShortcut("+")
}
那么这段代码在做什么呢?
- 在菜单上添加一个分隔线,以表明下一个条目不属于样式表组。
- 创建一个名为
Font Size
的子菜单。 - 添加一个
Button
来减少字体大小的下限。 - 给它一个键盘快捷方式。
Command-Minus
,Command-Plus
和Command-Zero
通常用于改变和重设尺寸。 - 再插入两个按钮,用于将字体大小重设为默认值和增加大小。
这给了你设置所需字体大小的接口,所以现在你必须应用它。
打开ContentView.swift
,在结构的顶部添加属性声明:
@AppStorage("editorFontSize") var editorFontSize: Double = 14
将此修改器应用于HSplitView
,就在toolbar
修改器之前:
.font(.system(size: editorFontSize))
建立和运行,并检查你的新子菜单。选择项目来改变字体大小,也可以使用键盘快捷键。将预览切换到HTML
代码视图,你会发现字体大小的设置也适用于此:
使用帮助菜单¶
在第1节中,你删除了帮助菜单项,因为它并没有做任何有用的事情。在这个应用程序中,你将使它显示一个新的窗口,实际上提供一些有用的信息。
但在这之前,你需要升级你的WebView
,使它能显示一个实时的网页和一个HTML
字符串。
在项目导航器中,删除WebView.swift
,把它移到垃圾箱。
接下来,打开下载材料中本章的assets
文件夹,把新版本的WebView.swift
拖入你的项目导航器。
新的特点是,你现在初始化WebView
时有两个可选参数:一个HTML
字符串或一个网站地址。updateNSView(_:context:)
使用其中一个来填充视图。
有了这个,你可以替换Help
菜单项。
在MenuCommands.swift
中,将// more menus
替换为:
// 1
CommandGroup(replacing: .help) {
// 2
NavigationLink(
// 3
destination:
WebView(
html: nil,
address: "https://bit.ly/3x55SNC")
// 4
.frame(minWidth: 600, minHeight: 600)
) {
// 5
Text("Markdown Help")
}
}
这里发生了什么事?
- 使用
CommandGroup
在一个标准菜单中插入一个菜单项。这里,它取代了help
菜单项。 - 插入一个
NavigationLink
作为菜单项。 NavigationLink
导航到一个WebView
,它的URL指向一个Markdown
小抄。- 设置新窗口的最小框架。
- 为菜单项的标题使用一个
Text
视图。
建立并运行应用程序,并测试你的新帮助菜单项:
你已经看到菜单项可以是许多不同类型的SwiftUI
视图。最常见的是使用Button
,但Toggle
和Picker
也很有用,你可以使用Menu
来添加一个子菜单。
现在,你已经看到一个菜单项也可以是一个NavigationLink
。这就是你如何让一个菜单项打开一个新的窗口。新窗口中的视图可以是任何SwiftUI
视图。
聚焦于一个窗口¶
到目前为止,你所添加的所有菜单项都将其动作应用于整个应用程序。但这是一个基于文档的应用程序,所以你想把一些菜单项只指向活动窗口。你怎么能知道哪个是活动窗口呢?
苹果的文档建议使用@FocusedBinding
。这很有效--有时候。问题似乎是,它只在焦点改变到一个全新的窗口时才会检测到。在已经打开窗口的情况下打开应用程序,无法检测到一个活动的窗口,而将应用程序发送到后面,然后再将其带到前面,也会失败。
幸运的是,有一个名为KeyWindow的Swift
包,它一直在发挥作用。它使用AppKit
的观察者来跟踪一个窗口何时来到前面,并通过一个自定义的EnvironmentKey
将其公开。这个包的作者在在SwiftUI生命周期应用中从窗口读取这篇文章中解释了这个过程。
所以现在,你要导入这个包并将其投入使用。
就像你在导入MarkdownKit
包时做的那样,在项目导航器的顶部选择项目,并点击项目,而不是目标。
选择顶部的Package Dependencies
,点击+
按钮,向你的项目添加第二个包。
在搜索框中输入这个URL
:
https://github.com/LostMoa/KeyWindow
你已经加载了一个软件包,所以这一次,你会看到两个软件包被列出。请确保在点击Add Package
之前选择KeyWindow
软件包:
库已经在下一个对话框中被选中,所以再次点击Add Package
,将其添加到你的项目中。现在你有两个包的依赖关系被列出:
接下来,在开始跟踪活动窗口之前,还需要做一些设置工作。
配置库¶
首先打开MarkDownerDocument.swift
,在文件的顶部添加这一行来导入新的包:
import KeyWindow
接下来,在现有结构外添加这个扩展:
extension MarkDownerDocument: KeyWindowValueKey {
public typealias Value = Binding<Self>
}
这定义了一个与MarkDownerDocument
的绑定,作为一个新的KeyWindowValueKey
的有效type
。你可以使用非常类似的代码来定义一个自定义的EnvironmentKey
,尽管在这种情况下,你可能会设置一个默认值,然后让Swift
从中找出类型。这里的默认值是nil
,所以你必须指定类型。
现在,打开MarkDownerApp.swift
,在其他import
语句中加入以下内容:
import KeyWindow
然后将DocumentGroup
的内容改为:
ContentView(document: file.$document)
.observeWindow()
你没有改变ContentView
,但你给它应用了一个新的修改器。这允许KeyWindow
观察其窗口。
最后,打开ContentView.swift
。将这个修改器添加到HSplitView
中,就在font
修改器之后:
.keyWindow(
MarkDownerDocument.self,
$document)
这个keyWindow
修改器公布了关键窗口信息。你给它两个参数--一个键-值对。键是你设置的keyWindowValueKey
的对象的类型。值是对象本身。绑定到你的文档。
现在所有的部分都到位了,所以你可以在你的菜单中实际使用它们来针对前面的窗口。
添加一个针对窗口的菜单¶
在MenuCommands.swift
中,在顶部添加以下内容以导入该库:
import KeyWindow
接下来,在MenuCommands
的顶部添加这个属性定义:
@KeyWindowValueBinding(MarkDownerDocument.self)
var document: MarkDownerDocument?
这使用了一个自定义的属性包装器来让你访问关键窗口的文档,如果它存在的话。你可以在你的菜单动作中使用这个。
MenuCommand
结构越来越长,但你可以使用代码折叠使其更容易浏览。如果你没有看到代码折叠功能区,去Xcode
的Preferences ▸ Text Editing ▸ Display
并勾选Code folding ribbon
。现在,点击行号和代码之间的那一栏,就可以折叠部分了:
在CommandGroup
后面添加一些空行,然后插入这个:
// 1
CommandMenu("Markdown") {
// 2
Button("Bold") {
// 3
document?.text += "`BOLD`"
}
// 4
.keyboardShortcut("b")
// 5
Button("Italic") {
document?.text += "_Italic_"
}.keyboardShortcut("i", modifiers: .command)
Button("Link") {
let linkText = "[Title](https://link_to_page)"
document?.text += linkText
}
Button("Image") {
let imageText = "![alt text](https://link_to_image)"
document?.text += imageText
}
}
以此为例,你:
- 创建一个名为
Markdown
的新菜单。 - 添加一个
Bold
菜单项。 - 将一些文本添加到焦点文件中,如果它存在的话。
- 应用一个标准的键盘快捷方式。
- 用同样的方法再制作一些按钮。图片的
Markdown
语法与链接的语法非常相似,所以这些特别有用。
构建并运行该应用程序。打开一个新的窗口,这样你至少有两个窗口打开。然后,尝试一下新的菜单:
伟大的工作。那里发生了很多事情,但你成功了,现在你有一个只针对最前面的窗口的菜单。
导出HTML
¶
你可以输入Markdown
并将其作为HTML预览,但你可能想导出HTML
代码以便在网站上使用。
打开MenuCommands.swift
并折叠新的CommandMenu
。在它后面插入一些空行,然后添加这个:
// 1
CommandGroup(after: .importExport) {
// 2
Button("Export HTML…") {
// exportHTML()
}
// 3
.disabled(document == nil)
}
这有什么作用?
- 在
importExport
组之后创建一个新的CommandGroup
。这将它放在File
菜单的最后。 - 添加一个
Button
来导出HTML
。这将打开一个保存对话框,按照惯例,如果一个菜单项或按钮要询问进一步的信息,它的标题要以省略号结束。 - 如果没有重点关注的文件,就禁用这个菜单项。
建立并运行,检查你在File
菜单中的新菜单项:
关闭所有的窗口,你会看到Export HTML
菜单项被禁用。
菜单项已经到位了,所以现在你需要让它工作。
在MenuCommands
的末尾添加这个新方法,在body
之外:
func exportHTML() {
// 1
guard let document = document else {
return
}
// 2
let savePanel = NSSavePanel()
savePanel.title = "Save HTML"
savePanel.nameFieldStringValue = "Export.html"
// 3
savePanel.begin { response in
// 4
if response == .OK, let url = savePanel.url {
// 5
try? document.html.write(
to: url,
atomically: true,
encoding: .utf8)
}
}
}
那么,这里发生了什么?
- 检查是否有一个活动文件。应该总是有的,因为如果没有的话,这个菜单项就会被禁用,但最好还是确定一下。
- 创建一个
NSSavePanel
,它是标准的系统保存对话框。给它一个标题并设置文件的默认名称。 - 显示保存面板并等待响应。
- 检查响应是否为
.OK
,这是NSApplication.ModalResponse.OK
的缩写,并且用户选择了一个URL
。 - 尝试将文档的
HTML
代码写到URL
上。
最后一步是使用这个方法。取消对按钮动作中的// exportHTML()
一行的注释,这样它就可以调用这个新方法。
是时候测试一下了。构建并运行该应用程序。打开一个文档或用一些Markdown
创建一个新的文档。然后从File
菜单中选择Export HTML...
。
一旦你保存了该文件,双击它在你的默认浏览器中打开它:
你可能想知道为什么沙盒不限制你把这个文件保存在某些位置。默认情况下,沙盒允许Read/Write
访问User Selected Files
。这意味着,只要你要求用户选择一个文件位置,你就可以保存在他们想要的任何地方。
Note
如果你在你的Markdown
中包含任何本地图片,由于苹果的安全设置,它们不会在网页预览中显示出来。但它们会在HTML
输出中如期出现。
为触摸条编码¶
很多MacBook
都有一个触摸条,它经常被用来提供自动完成建议或格式化选项。因此,如果你的一些Markdown
菜单项也能在触摸条上使用,那将是一种很好的触摸。
你可能想知道,如果你没有一台带触摸条的Mac
,你怎么能测试这个,但Xcode
已经涵盖了这个问题。打开Window
菜单,进入Touch Bar ▸ Show Touch Bar
,或者按Shift-Command-8
来打开一个完整的触摸条模拟器,它可以与你的Mac
上的所有应用程序一起使用。
向触摸条添加命令与向菜单添加命令非常相似。但是你在菜单中使用的是文本标签,你可以在更多的图形化触摸条中使用风格化的字符和图标。
打开本章下载的资料中的assets
文件夹,将TouchbarCommands.swift
拖入你的项目。
看一下这个文件,你会发现与MenuCommands
不同,它不需要符合任何特殊的协议。你可以在触摸条上显示任何SwiftUI
的视图,这带来了很多可能性。
TouchbarCommands
结构可以访问关键窗口的文档,就像MenuCommands
一样,它有和Markdown
菜单一样的四种能力,但有不同的按钮标签。
现在你有了这个结构,打开ContentView.swift
并在HSplitView
上添加这个修改器:
.touchBar {
TouchbarCommands()
}
这就是你需要做的,为这个视图应用触摸条。这将它设置为使用你刚刚添加的命令,以及默认的触摸条命令。
构建并运行该应用程序以检查它们:
现在有很多MacBook
都有触摸条,SwiftUI
可以很容易地支持它们,所以为什么不这样做呢。
挑战¶
挑战1:键盘快捷键¶
当你添加Display
菜单时,你为每个菜单项创建了键盘快捷方式。从那时起,你为File
和Help
菜单添加了新的菜单项,它们没有快捷方式。你还创建了一个Markdown
菜单,其中只有一些项目有快捷键。
通过MenuCommands
的方式,尽可能多的为项目添加键盘快捷键。
挑战2:更多的Markdown
片段¶
Markdown
菜单有一些片段,但如果能有更多就更好了。增加一个Headers
子菜单,插入各种头的类型。页眉1以1#
开头,页眉6以6#
开头。你可以用页眉级别的数字作为快捷方式。
添加分隔线可能很棘手,因为最常见的格式是三个破折号,但macOS
试图做一些聪明的事情,将其转换为em-dash
或en-dash
。因此,添加一个菜单项来输入分隔线,不要忘记在前后添加换行,使用"\n"
。
关键点¶
- 默认的
macOS
文档应用程序得到了一套标准的菜单,但你可以通过很多有用的方式来增加它们。 - 一个枚举可以自动创建一个菜单。这甚至可以扩展到生成键盘快捷方式。
- 在一个菜单项中包含一个
Menu
来创建一个子菜单。 - 使用
NavigationLink
作为一个菜单项,允许你打开一个包含任何SwiftUI
视图的新窗口。 - 追踪最前面的窗口并不是一件容易的事,苹果的机制并不总是有效。
- 一旦你知道哪个窗口是活动的,你就可以直接从菜单项中锁定它。这样你就可以拥有特定窗口的菜单项。
- 当保存文件时,使用
NSSavePanel
向用户请求保存路径。当你要求用户选择一个文件时,你的应用程序可以写到该文件,即使它在应用程序的沙盒容器之外。 - 很多
MacBook
都有一个触摸条,SwiftUI
可以很容易地将其添加到默认的触摸条控件中。
接下来去哪里?¶
干得好! 你已经到了另一节的结尾,而且你已经完成了一个新的应用程序。如果你一直按顺序阅读本书,你现在已经有了三个风格迥异的应用程序。
本节向你介绍了一个基于文档的应用程序,并使用一些新的SwiftUI
功能来创建一个整洁的Markdown
编辑器,随着苹果公司对SwiftUI
组件的改进和扩展,这个编辑器只会变得更好。
在下一节中,你将建立一个应用程序,让你在iOS
应用程序中做你做梦都想不到的事情。你将从你的应用程序中运行终端命令。这将使你能够为一些晦涩但有用的命令提供一个GUI
。