跳转至

11.添加菜单控件

在上一章中,你创建了一个基于文档的应用程序。你导入了一个将Markdown转换为HTML的包,并添加了控件以允许用户选择预览样式。你甚至在SwiftUI应用中包含了一个AppKit视图。

在本章中,你将深入研究菜单。在第1节中,你为你的应用程序添加了菜单,所以其中一些内容你是熟悉的。但这些菜单只适用于整个应用程序的设置。现在你将学习一些不同的菜单技巧,以及如何跟踪活动窗口,以便你可以只对该窗口应用菜单操作。

添加样式文件

网页视图使用默认的样式来渲染HTML,但如果能够通过使用层叠样式表(CSS)来选择自己的样式,那就更好了。

打开你上一章的项目,或者打开本章下载的资料中的starter项目。

接下来,打开本章下载资料中的assets文件夹,找到StyleSheets文件夹。

把这个文件夹拖到你的项目导航器中,选择Copy items if neededCreate groupsMarkDowner目标:

img

这个新文件夹包含一组CSS文件,有一些不同的样式,还有一个.swift文件,为这些样式设置了一个枚举。你将使用这个枚举中的数据来生成菜单项。

创建一个新的菜单

你将会有很多的菜单代码,所以为了容纳这些代码,创建一个新的Swift文件,叫做MenuCommands.swift。将其内容替换为:

// 1
import SwiftUI

// 2
struct MenuCommands: Commands {
  // 3
  var body: some Commands {
    // 4
    EmptyCommands()
  }
}

这有什么作用?

  1. 导入SwiftUI,因为菜单是SwiftUI框架的一部分。
  2. 创建一个符合CommandsMenuCommands结构,以便SwiftUI将其识别为可以出现在菜单中的东西。
  3. 添加一个同样符合Commandsbody
  4. 返回一个空菜单以避免错误。

这为你的菜单设置了基本的结构。现在,你可以开始创建它们。但是在你改变样式表之前,你需要一个方法来存储用户的选择。

首先,把这个添加到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

踏过这些线路,你:

  1. 创建一个新菜单,将其标题设为Display
  2. 循环浏览StyleSheet枚举中的案例。因为这个枚举不符合Identifiable,所以使用每个案例作为它自己的标识符。这些案例总是唯一的,而且顺序也不会改变,所以这不会造成问题。
  3. 为每个样式创建一个Button
  4. 给每个按钮应用一个动作,设置styleSheet属性。
  5. 将按钮的内容设置为Text视图,显示每个样式的rawValue

你已经创建了一个菜单,但你还没有告诉你的应用程序要显示它。打开MarkDownerApp.swift,在DocumentGroup上添加这个修改器:

.commands {
  MenuCommands()
}

这就把MenuCommands附加到DocumentGroup上。你添加到该结构中的任何菜单或菜单项现在都会出现在主菜单栏中。

构建并运行以检查你的新菜单:

img

它看起来不错,而且整洁的地方在于,它使用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的多行字符串语法,将htmlstyleSheet属性包装成一个完整的HTML文档。因为你把web视图的baseURL设置为应用程序的Bundle.main.resourceURL,文件名,没有任何文件夹路径,就足以定位CSS文件。

接下来,你必须告诉WebView使用这个版本的HTML,所以用这个版本替换updateNSView

func updateNSView(_ nsView: WKWebView, context: Context) {
  nsView.loadHTMLString(
    formattedHtml,
    baseURL: Bundle.main.resourceURL)
}

现在你可以测试它了。构建并运行该应用程序,确保你有一些Markdown样本,并测试所有的样式:

img

添加键盘快捷键

在第一节中,你添加了一个使用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键是任何快捷方式的默认修改键,所以严格来说没有必要在调用中包括它。

现在构建并运行,在菜单中看到你的快捷方式。对它们进行全部测试。

img

即使你已经决定优先使用键盘快捷键而不是复选标记,你仍然可以使用造型来表示当前的选择。你不想在文本中手动添加一个复选标记。它出现在错误的地方,看起来不对。但是你可以修改菜单项Button中的文本。

菜单项中的文本忽略了很多样式修改器,但它可以使用不同的颜色。

Text(style.rawValue)中添加这个修改器:

.foregroundColor(style == styleSheet ? .accentColor : .primary)

现在建立并运行,可以看到活动的样式表被清楚地显示出来:

img

因为这使用了语义学的颜色名称,所以它在浅色和深色模式下都能工作。有趣的是,它没有使用实际的重点颜色,在这个系统上是蓝色的,而是使用一种对比色,即使你把鼠标放在菜单项上,也会显示出它的背景颜色。

插入一个子菜单

另一个有用的功能是能够改变编辑器的字体大小。你可以把它作为一个子菜单添加到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("+")
}

那么这段代码在做什么呢?

  1. 在菜单上添加一个分隔线,以表明下一个条目不属于样式表组。
  2. 创建一个名为Font Size的子菜单。
  3. 添加一个Button来减少字体大小的下限。
  4. 给它一个键盘快捷方式。Command-Minus, Command-PlusCommand-Zero通常用于改变和重设尺寸。
  5. 再插入两个按钮,用于将字体大小重设为默认值和增加大小。

这给了你设置所需字体大小的接口,所以现在你必须应用它。

打开ContentView.swift,在结构的顶部添加属性声明:

@AppStorage("editorFontSize") var editorFontSize: Double = 14

将此修改器应用于HSplitView,就在toolbar修改器之前:

.font(.system(size: editorFontSize))

建立和运行,并检查你的新子菜单。选择项目来改变字体大小,也可以使用键盘快捷键。将预览切换到HTML代码视图,你会发现字体大小的设置也适用于此:

img

使用帮助菜单

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

这里发生了什么事?

  1. 使用CommandGroup在一个标准菜单中插入一个菜单项。这里,它取代了help菜单项。
  2. 插入一个NavigationLink作为菜单项。
  3. NavigationLink导航到一个WebView,它的URL指向一个Markdown小抄。
  4. 设置新窗口的最小框架。
  5. 为菜单项的标题使用一个Text视图。

建立并运行应用程序,并测试你的新帮助菜单项:

img

你已经看到菜单项可以是许多不同类型的SwiftUI视图。最常见的是使用Button,但TogglePicker也很有用,你可以使用Menu来添加一个子菜单。

现在,你已经看到一个菜单项也可以是一个NavigationLink。这就是你如何让一个菜单项打开一个新的窗口。新窗口中的视图可以是任何SwiftUI视图。

聚焦于一个窗口

到目前为止,你所添加的所有菜单项都将其动作应用于整个应用程序。但这是一个基于文档的应用程序,所以你想把一些菜单项只指向活动窗口。你怎么能知道哪个是活动窗口呢?

苹果的文档建议使用@FocusedBinding。这很有效--有时候。问题似乎是,它只在焦点改变到一个全新的窗口时才会检测到。在已经打开窗口的情况下打开应用程序,无法检测到一个活动的窗口,而将应用程序发送到后面,然后再将其带到前面,也会失败。

幸运的是,有一个名为KeyWindowSwift包,它一直在发挥作用。它使用AppKit的观察者来跟踪一个窗口何时来到前面,并通过一个自定义的EnvironmentKey将其公开。这个包的作者在在SwiftUI生命周期应用中从窗口读取这篇文章中解释了这个过程。

所以现在,你要导入这个包并将其投入使用。

就像你在导入MarkdownKit包时做的那样,在项目导航器的顶部选择项目,并点击项目,而不是目标。

选择顶部的Package Dependencies,点击+按钮,向你的项目添加第二个包。

在搜索框中输入这个URL

https://github.com/LostMoa/KeyWindow

你已经加载了一个软件包,所以这一次,你会看到两个软件包被列出。请确保在点击Add Package之前选择KeyWindow软件包:

img

库已经在下一个对话框中被选中,所以再次点击Add Package,将其添加到你的项目中。现在你有两个包的依赖关系被列出:

img

接下来,在开始跟踪活动窗口之前,还需要做一些设置工作。

配置库

首先打开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结构越来越长,但你可以使用代码折叠使其更容易浏览。如果你没有看到代码折叠功能区,去XcodePreferences ▸ Text Editing ▸ Display并勾选Code folding ribbon。现在,点击行号和代码之间的那一栏,就可以折叠部分了:

img

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

以此为例,你:

  1. 创建一个名为Markdown的新菜单。
  2. 添加一个Bold菜单项。
  3. 将一些文本添加到焦点文件中,如果它存在的话。
  4. 应用一个标准的键盘快捷方式。
  5. 用同样的方法再制作一些按钮。图片的Markdown语法与链接的语法非常相似,所以这些特别有用。

构建并运行该应用程序。打开一个新的窗口,这样你至少有两个窗口打开。然后,尝试一下新的菜单:

img

伟大的工作。那里发生了很多事情,但你成功了,现在你有一个只针对最前面的窗口的菜单。

导出HTML

你可以输入Markdown并将其作为HTML预览,但你可能想导出HTML代码以便在网站上使用。

打开MenuCommands.swift并折叠新的CommandMenu。在它后面插入一些空行,然后添加这个:

// 1
CommandGroup(after: .importExport) {
  // 2
  Button("Export HTML…") {
    // exportHTML()
  }
  // 3
  .disabled(document == nil)
}

这有什么作用?

  1. importExport组之后创建一个新的CommandGroup。这将它放在File菜单的最后。
  2. 添加一个Button来导出HTML。这将打开一个保存对话框,按照惯例,如果一个菜单项或按钮要询问进一步的信息,它的标题要以省略号结束。
  3. 如果没有重点关注的文件,就禁用这个菜单项。

建立并运行,检查你在File菜单中的新菜单项:

img

关闭所有的窗口,你会看到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)
    }
  }
}

那么,这里发生了什么?

  1. 检查是否有一个活动文件。应该总是有的,因为如果没有的话,这个菜单项就会被禁用,但最好还是确定一下。
  2. 创建一个NSSavePanel,它是标准的系统保存对话框。给它一个标题并设置文件的默认名称。
  3. 显示保存面板并等待响应。
  4. 检查响应是否为.OK,这是NSApplication.ModalResponse.OK的缩写,并且用户选择了一个URL
  5. 尝试将文档的HTML代码写到URL上。

最后一步是使用这个方法。取消对按钮动作中的// exportHTML()一行的注释,这样它就可以调用这个新方法。

是时候测试一下了。构建并运行该应用程序。打开一个文档或用一些Markdown创建一个新的文档。然后从File菜单中选择Export HTML...

img

一旦你保存了该文件,双击它在你的默认浏览器中打开它:

img

你可能想知道为什么沙盒不限制你把这个文件保存在某些位置。默认情况下,沙盒允许Read/Write访问User Selected Files。这意味着,只要你要求用户选择一个文件位置,你就可以保存在他们想要的任何地方。

Note

如果你在你的Markdown中包含任何本地图片,由于苹果的安全设置,它们不会在网页预览中显示出来。但它们会在HTML输出中如期出现。

为触摸条编码

很多MacBook都有一个触摸条,它经常被用来提供自动完成建议或格式化选项。因此,如果你的一些Markdown菜单项也能在触摸条上使用,那将是一种很好的触摸。

你可能想知道,如果你没有一台带触摸条的Mac,你怎么能测试这个,但Xcode已经涵盖了这个问题。打开Window菜单,进入Touch Bar ▸ Show Touch Bar,或者按Shift-Command-8来打开一个完整的触摸条模拟器,它可以与你的Mac上的所有应用程序一起使用。

img

向触摸条添加命令与向菜单添加命令非常相似。但是你在菜单中使用的是文本标签,你可以在更多的图形化触摸条中使用风格化的字符和图标。

打开本章下载的资料中的assets文件夹,将TouchbarCommands.swift拖入你的项目。

看一下这个文件,你会发现与MenuCommands不同,它不需要符合任何特殊的协议。你可以在触摸条上显示任何SwiftUI的视图,这带来了很多可能性。

TouchbarCommands结构可以访问关键窗口的文档,就像MenuCommands一样,它有和Markdown菜单一样的四种能力,但有不同的按钮标签。

现在你有了这个结构,打开ContentView.swift并在HSplitView上添加这个修改器:

.touchBar {
  TouchbarCommands()
}

这就是你需要做的,为这个视图应用触摸条。这将它设置为使用你刚刚添加的命令,以及默认的触摸条命令。

构建并运行该应用程序以检查它们:

img

现在有很多MacBook都有触摸条,SwiftUI可以很容易地支持它们,所以为什么不这样做呢。

挑战

挑战1:键盘快捷键

当你添加Display菜单时,你为每个菜单项创建了键盘快捷方式。从那时起,你为FileHelp菜单添加了新的菜单项,它们没有快捷方式。你还创建了一个Markdown菜单,其中只有一些项目有快捷键。

通过MenuCommands的方式,尽可能多的为项目添加键盘快捷键。

挑战2:更多的Markdown片段

Markdown菜单有一些片段,但如果能有更多就更好了。增加一个Headers子菜单,插入各种头的类型。页眉1以1#开头,页眉6以6#开头。你可以用页眉级别的数字作为快捷方式。

添加分隔线可能很棘手,因为最常见的格式是三个破折号,但macOS试图做一些聪明的事情,将其转换为em-dashen-dash。因此,添加一个菜单项来输入分隔线,不要忘记在前后添加换行,使用"\n"

关键点

  • 默认的macOS文档应用程序得到了一套标准的菜单,但你可以通过很多有用的方式来增加它们。
  • 一个枚举可以自动创建一个菜单。这甚至可以扩展到生成键盘快捷方式。
  • 在一个菜单项中包含一个Menu来创建一个子菜单。
  • 使用NavigationLink作为一个菜单项,允许你打开一个包含任何SwiftUI视图的新窗口。
  • 追踪最前面的窗口并不是一件容易的事,苹果的机制并不总是有效。
  • 一旦你知道哪个窗口是活动的,你就可以直接从菜单项中锁定它。这样你就可以拥有特定窗口的菜单项。
  • 当保存文件时,使用NSSavePanel向用户请求保存路径。当你要求用户选择一个文件时,你的应用程序可以写到该文件,即使它在应用程序的沙盒容器之外。
  • 很多MacBook都有一个触摸条,SwiftUI可以很容易地将其添加到默认的触摸条控件中。

接下来去哪里?

干得好! 你已经到了另一节的结尾,而且你已经完成了一个新的应用程序。如果你一直按顺序阅读本书,你现在已经有了三个风格迥异的应用程序。

本节向你介绍了一个基于文档的应用程序,并使用一些新的SwiftUI功能来创建一个整洁的Markdown编辑器,随着苹果公司对SwiftUI组件的改进和扩展,这个编辑器只会变得更好。

在下一节中,你将建立一个应用程序,让你在iOS应用程序中做你做梦都想不到的事情。你将从你的应用程序中运行终端命令。这将使你能够为一些晦涩但有用的命令提供一个GUI