跳转至

10.创建一个基于文档的应用程序

本书到目前为止,你已经建立了两个非常不同的Mac应用程序。首先,你使用SwiftUI制作了一个传统的基于窗口的应用程序。接下来,你使用AppKit创建了一个菜单栏应用程序。

现在,你将了解另一类应用程序:基于文档的应用程序。在本节中,你将回到SwiftUI,但与上一节的做法相反,你将在SwiftUI应用中嵌入一个AppKit视图。

本节的应用程序是一个Markdown编辑器。Markdown是一种标记语言,它允许你快速而轻松地编写格式化的文本。它可以转换为HTML进行显示,但在编写和编辑时比HTML要方便得多。

您将从Xcode模板中创建一个基于文档的应用程序,看看它能免费提供多少功能。然后,你将继续定制用于保存和打开的文件类型,并添加一个HTML预览。

如果你读过SwiftUI by Tutorials,这个应用程序对你来说会很熟悉,尽管这个版本有一个不同的名字,以避免混淆设置。会有一些不同之处,特别是在下一章中,它详细地处理了菜单。如果你对这个应用程序已经很熟悉了,可以跳过这一章,继续学习下一章中提供的启动项目。

设置一个基于文档的应用程序

许多Mac应用程序是基于文档的。想想像TextEditPagesNumbersPhotoshop这样的应用程序。你一次只处理一个文件,每个文件都在自己的窗口中,而且你可以同时打开多个文件,每个文件都显示自己的内容。这类应用程序允许你编辑、保存和打开不同的文件,所有这些都基于文件的类型。

现在,你将制作你自己的基于文件的应用程序,它可以处理任何Markdown文件,即使是由不同的编辑器创建的。

启动Xcode并创建一个新项目。选择macOS并选择Document App

img

确保界面是SwiftUI,语言是Swift。将该应用程序称为MarkDowner

一旦你保存了这个项目,建立并运行这个应用程序。点击New Document,如果出现文件选择器,或者从File菜单中选择New。这将给你一个单一的窗口,显示一些默认文本。你可以编辑这个文本,并使用标准的Edit菜单命令进行选择、剪切、复制和粘贴,以及撤销和重做。

File菜单中可以看到所有你希望在任何编辑器类型的应用程序中看到的菜单项。从File菜单中选择Save或按Command-S

img

Note

如果你在保存对话框中没有看到文件扩展名,进入Finder ▸ Preferences ▸ Advanced并打开Show all filename extensions。这将使你更容易理解本章的下一部分内容。

img

默认的应用程序使用的文件扩展名是.exampletext,所以给它起个名字,用建议的扩展名保存你的文件。关闭窗口,用Command-N创建一个新窗口。现在从File ▸ Open中选择你保存的文件,打开它。

所以你已经有了一个可以编辑、保存和打开文档的应用程序。而你甚至还没有看过代码呢!

关闭所有的文档窗口,退出应用程序,回到Xcode看看那里发生了什么。

默认的文档应用程序

在你的项目中,有三个.swift文件

MarkDownerApp.swift与你在其他SwiftUI项目中看到的App.swift文件类似,但它的body不是包含一个WindowGroup,而是包含一个DocumentGroup

你用一个文档类型的实例来初始化DocumentGroup,在这里是MarkDownerDocument。在闭包中,你提供了显示该文件数据的视图,并将其传递给文件的文档绑定,因此对文档的修改可以流回。

如果你的应用程序支持一个以上的文档类型,你可以在这里添加一个以上的DocumentGroup

视图像往常一样被设置为ContentView,但有document参数。

ContentView.swift接收该文档并使用其text属性来填充TextEditor。这种类型的视图允许编辑长块的文本。

真正的魔法发生在MarkDownerDocument.swift中。这是你配置文档类型的地方,也是保存和打开文档的地方。

首先看一下UTType扩展名。UT代表Uniform Type,是macOS处理文件类型、文件扩展名和确定哪些应用程序可以打开哪些文件的方式。当你定制应用程序来处理Markdown文件时,你会很快了解到更多关于这个问题。

MarkDownerDocument中,你有一个text属性,用来保存文件的内容。它的初始化器设置了你在运行应用程序时在每个新窗口看到的默认文本。readableContentTypes属性决定了这个应用程序可以打开哪些文档类型,使用前面定义的UTType

initfileWrapper方法处理所有打开和保存文档文件的工作。现在,他们使用的是.exampletext文件扩展名,但现在是时候解决如何处理Markdown文件了。

Markdown配置

当你在Mac上双击一个文档文件时,Finder会使用默认的应用程序打开它。TextEdit用于.txt文件,Preview用于.png文件,等等。右键点击任何一个文档文件,看看Open With菜单。你会看到Mac上能够打开该类型文件的应用程序的列表。Finder知道哪些应用程序可以打开该文件,因为应用程序开发人员已经指定了他们的应用程序可以打开哪些Uniform Types

要设置一个基于文档的应用程序来打开一个特定的文件类型,你需要关于该文件的三条信息:

  • 统一类型标识符或`UTI'。
  • 这符合什么标准的文件类型。
  • 文件扩展名或扩展名。

苹果公司提供了一个系统声明的统一类型列表。在为一个应用程序确定文件类型时,你总是应该先检查这里。但在这种情况下,它没有帮助,因为Markdown不在那里。

然而,在互联网上搜索markdown uniform type可以找到John GruberMarkdown的发明者。他说统一类型标识符应该是net.daringfireball.markdown,而这符合public.plain-text

FileInfo.com上搜索Markdown告诉你,Markdown最流行的文件扩展名是.markdown.md

这给了你所有的数据,你需要把你的应用程序从使用纯文本切换到使用Markdown文本。

设置一个文件类型

在项目导航器列表的顶部选择项目。点击MarkDowner目标,从上方的选择中选择Info标签。

展开Document Types部分,将Identifier改为net.daringfireball.markdown

img

接下来,展开Imported Type Identifiers部分,做如下修改:

Description: Markdown文本

Identifier: net.daringfireball.markdown

Extensions: markdown, md

Note

如果你对你的Markdown文件使用不同的扩展名,请参阅下面的Challenge 1

这里的所有其他设置可以保持不变,因为Conforms To字段已经包含public.plain-text

img

这就是你的应用程序的配置;现在你必须改变MarkDownerDocument以使用这些新设置。

回到MarkDownerDocument.swift中,将UTType一行替换为以下内容:

UTType(importedAs: "net.daringfireball.markdown")

接下来,右击exampleText,选择Refactor ▸ Rename...,将其重命名为markdownText

而且,为了让你知道它已经工作了,把init中的默认文本改为# Hello, MarkDowner!,这是1级标题的Markdown格式。

测试文件设置

建立并运行应用程序,并创建一个新的文件。现在的默认文本是# Hello MarkDowner!。保存文档,并确认建议的文件名是使用.md.markdown作为文件扩展名。

Note

它选择哪一个似乎是随机的,也许取决于你过去在其他应用程序中使用过什么。

保存并关闭你的新文件,然后在Finder中找到该文件。鼠标右键单击它,显示其Open With菜单。

img

你看到MarkDowner列在那里,是因为你的设置告诉Finder,你的应用程序可以打开Markdown文件。如果你有其他应用程序创建的任何Markdown文件,右键点击其中一个并在MarkDowner中打开它。

你的应用程序还没有对Markdown文本做任何处理,但它现在可以编辑、保存和打开任何Markdown文件。伟大的工作! 现在来学习更多关于Markdown的知识。

MarkdownHTML

Markdown是一种标记语言,它使用快捷键将纯文本的格式化,以便于转换为HTML。作为一个例子,请看下面这个HTML

<h1>Important Header</h1>
<h2>Less Important Header</h2>

<a href="https://www.raywenderlich.com">Ray Wenderlich</a>

<ul>
  <li>List Item 1</li>
  <li>List Item 2</li>
  <li>List Item 3</li>
</ul>

如果要用Markdown写同样的内容,你会用:

# Important Header
## Less Important Header

[Ray Wenderlich](https://www.raywenderlich.com)

- List Item 1
- List Item 2
- List Item 3

我相信你会同意,Markdown版本更容易写,更容易读,而且更可能是准确的。

你可以从这个非常有用的小抄中了解到更多关于Markdown的信息。

MarkDowner中,你用Markdown写文本。这个应用会把它转换为HTML,并在网页视图中显示在一边。

Swift有能力将某些Markdown元素转换为AttributedString。这对于格式化你的用户界面的部分内容非常有用。这不是你在这里想要的,因为它不会创建HTML。但有几个Swift包可以。你在这个应用中要使用的是[Swift MarkdownKit](https://github.com/objecthub/swift-markdownkit)。

Markdown转换为HTML

如果你通过上一节的工作,或者你在iOS应用程序中使用过Swift包管理器,那么你会熟悉这个过程。

Xcode中,在项目导航器中选择项目,这一次,点击MarkDowner项目而不是目标。进入Package Dependencies标签,点击加号按钮,添加一个新的依赖性。

img

将此网址复制到右上方的搜索栏中,以搜索该软件包。

https://github.com/objecthub/swift-markdownkit

Xcode找到软件包后,确保它被选中并点击Add Package来下载它。

img

一旦下载完成,你会看到一个新的对话框,询问你想使用软件包的哪些部分。选择MarkdownKit Library并点击Add Package将其添加到你的项目中。

img

下一步是编辑MarkDownerDocument.swift,使其能够创建HTML版本的文档。为了使用你刚刚添加的包,你需要导入它。把它添加到文件顶部的其他导入文件中:

import MarkdownKit

MarkDownerDocument中,在text属性下,定义一个html属性:

var html: String {
  let markdown = MarkdownParser.standard.parse(text)
  return HtmlGenerator.standard.generate(doc: markdown)
}

这段代码创建了一个计算属性,它使用MarkdownKitMarkdownParser来解析文本,并使用其HtmlGenerator将其转换为HTML

你的文档现在有两个属性。一个是Markdown文本,这是每一个文档文件保存的内容。另一个是这个文本的HTML版本,它是使用MarkdownKit包从文本中导出的。

嵌入一个AppKit视图

现在你已经为MarkDownerDocument设置了一个html属性,你需要一个方法来显示它。显示HTML的明显方法是在某种网络视图中。问题是,SwiftUI没有网络视图--至少现在没有。但这提供了一个学习在SwiftUI应用程序中嵌入WebKit视图的绝佳机会。

如果你在iOS应用中做过这个,你会使用UIViewRespresentable来嵌入一个UIKit视图。对于嵌入AppKit视图,你使用NSViewRepresentable,但它的工作方式完全相同,如果你用NS替换每个UI

创建一个新的Swift文件,名为WebView.swift,并将其内容替换为以下代码:

// 1
import SwiftUI
import WebKit

// 2
struct WebView: NSViewRepresentable {
  // 3
  var html: String

  init(html: String) {
    self.html = html
  }

  // 4
  func makeNSView(context: Context) -> WKWebView {
    WKWebView()
  }

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

踏踏实实地做这件事:

  1. 你需要SwiftUI库来使用NSViewRepresentable,而你要嵌入的WKWebView是在WebKit框架中。
  2. 这个结构定义了一个名为WebViewSwiftUI视图。它符合NSViewRepresentable
  3. 该结构有一个单一的属性来保存HTML文本。
  4. NSViewRepresentable有两个必要的方法:makeNSView(context:)创建并返回NSView,在这里是WKWebView
  5. 第二个方法是updateNSView(_:context:)。每当属性发生变化,需要更新视图时,系统就会调用这个方法。在这种情况下,每当HTML发生变化时,网络视图就会重新加载WKWebView中的HTML

现在,你可以访问一个新的SwiftUI视图,叫做WebView,它包含一个WKWebView

显示HTML

打开ContentView.swift,用这个替换body的内容:

HSplitView {
  TextEditor(text: $document.text)
  WebView(html: document.html)
}

你想在可调整大小的窗格中并排显示MarkdownHTMLMacOSSwiftUI有一个专门为此设计的视图,叫做HSplitView。如果你想垂直堆叠视图,也有一个VSplitView,但水平分割更适合这个应用程序。

HSplitView中,TextEditor和以前一样。新的部分是你刚刚创建的WebView。你要把文档的HTML版本的文本传递给这个视图。

先不要建立和运行。它可能看起来一切都设置好了,但它不会工作。

再谈Mac沙盒

在第1节中,你发现你必须打开Outgoing Connections (Client)以允许从互联网下载。你可能认为这个应用程序不需要任何这样的权限,因为它只处理本地数据,但是Mac沙盒并不是这样工作的。

要把任何东西载入WKWebView,甚至是本地的HTML字符串,你需要以完全相同的方式打开沙盒。

点击项目导航器顶部的项目,选择MarkDowner目标,然后选择Signing & Capabilities标签。

勾选Outgoing Connections (Client),允许你的WebView加载HTML

img

现在,构建并运行。测试一些Markdown。如果你想做个入门者,可以复制上面的样本。试着调整窗口的大小,拖动分隔线来调整每个窗格的大小。

img

你可以让每个子视图变得很小,甚至让它消失。这并不理想,所以你需要解决这个问题。

限制框架

正如你在第二章"使用窗口"中所发现的,为你的窗口设置框架以限制其大小是很重要的。

在本例中,你希望TextEditor填充在窗口的左边,WebView填充在右边。当用户调整窗口大小和用户拖动它们之间的分隔线时,它们都应该调整大小。但是分隔线不应该让任何一个视图消失,而且窗口应该有一个最小尺寸。

回到ContentView.swift中,将body的内容替换成这样:

HSplitView {
  TextEditor(text: $document.text)
    // 1
    .frame(minWidth: 200)
  WebView(html: document.html)
    // 2
    .frame(minWidth: 200)
}
// 3
.frame(
  minWidth: 400,
  idealWidth: 600,
  maxWidth: .infinity,
  minHeight: 300,
  idealHeight: 400,
  maxHeight: .infinity)

增加的内容都是frame修改器,但这里是它们的作用:

  1. HSplitView中,将TextEditor的最小宽度设置为200
  2. WebView应用同样的宽度限制。
  3. HSplitView一个更完整的frame,设置其最小、理想和最大尺寸。最小宽度足以容纳两个子视图的最小宽度。最大尺寸是infinity,所以窗口可以达到用户想要的大小。

建立并再次运行,尝试调整每个窗格和窗口的大小。这样效果更好。:]

添加一个工具条

现在,该应用程序允许你编辑Markdown文本,并在网页视图中呈现出相应的HTML。但有时看到实际生成的HTML代码是很有用的。而且,如果在一个较小的屏幕上空间紧张,能够完全关闭预览是很方便的。

所以现在,你要添加一个工具栏。在工具栏中,你将有控件在三种可能的预览模式之间切换:网页、HTML代码和关闭。

首先,为预览模式定义一个枚举。把它添加到ContentView.swift的末尾,在任何结构之外:

enum PreviewState {
  case web
  case code
  case off
}

接下来,将这个属性添加到ContentView中:

@State private var previewState = PreviewState.web

这就定义了一个@State属性来保存所选状态,并默认设置为web

最后,在frame修改器之后,将其添加到HSplitView中:

// 1
.toolbar {
  // 2
  ToolbarItem {
    // 3
    Picker("", selection: $previewState) {
      // 4
      Image(systemName: "network")
        .tag(PreviewState.web)
      Image(systemName: "chevron.left.forwardslash.chevron.right")
        .tag(PreviewState.code)
      Image(systemName: "nosign")
        .tag(PreviewState.off)
    }
    // 5
    .pickerStyle(.segmented)
    // 6
    .help("Hide preview, show HTML or web view")
  }
}

这一切有什么作用?

  1. HSplitView应用一个工具条修改器。
  2. 在工具栏中插入一个ToolbarItem
  3. ToolbarItem包含一个Picker,其选择与previewState属性绑定。
  4. 为每个PreviewState显示苹果公司SF符号字体中的一个图像,并将标签设置为相应的情况。
  5. 将选择器设置为使用segmented风格。
  6. 使用help修改器应用可访问性文本和工具提示。

当你在第1节中制作一个工具条时,你把工具条的代码放在自己的文件中。如果你的视图很复杂或者工具栏包含很多按钮,这是个好主意。在这种情况下,直接应用它仍然使ContentView.swift相当简短和可读。

构建并运行应用程序,可以看到工具栏的最右边有这三个选项。点击每一个,可以看到显示当前所选选项的视觉差异:

img

配置预览

你已经有了支配预览的控件,但是你的应用程序并没有对它们做出反应。现在,在HSplitView中你有TextEditorWebView。但是当你允许预览选项时,有三种可能的组合。

  • 单独的文本编辑器
  • TextEditorWebView.
  • TextEditor加上新的东西来显示原始HTML

首先,为了让用户关闭WebViewCommand-click HSplitView里面的WebView,选择Make Conditional

Note

如果你的Xcode偏好设置Command-clickJumps to Definition,使用Command-Control-click来显示菜单。

true占位符替换为:

previewState == .web

建立并运行该应用程序。点击工具条上的三个选项。只有当你在选取器中选择网络按钮时,WebView才是可见的,当你点击其他任何一个时,它就会消失:

img

WebView有条件地出现,为它不应该出现的时候增加了一个EmptyView。但这是你想检查previewState是否被设置为code的地方。

替换:

} else {
  EmptyView()
}

为:

// 1
} else if previewState == .code {
  // 2
  ScrollView {
    // 3
    Text(document.html)
      // 4
      .frame(minWidth: 200)
      .frame(
          maxWidth: .infinity,
          maxHeight: .infinity,
          alignment: .topLeading)
      .padding()
      // 5
      .textSelection(.enabled)
  }
}

下面是这段代码的作用:

  1. 检查previewState是否被设置为code
  2. 显示一个ScrollView,这样你就可以看到所有的文本,即使有超过适合的窗口。
  3. 在一个Text视图中显示HTML版本的文档文本。
  4. Text视图设置framepadding,使其具有与其他视图相同的最小宽度,但要扩展到填补ScrollView
  5. 使文本可选择,所以你可以复制它。

现在建立并运行,并尝试三种预览状态。

img

挑战

挑战1:添加文件扩展名

当你设置文件类型时,你允许应用程序使用.markdown.md作为文件扩展名。但有些人使用.mdown来表示Markdown文件。编辑项目,使之成为一个有效的扩展名。为了测试它,重命名你的一个文件,使用这个新的扩展名,看看你是否能在MarkDowner中打开它。

挑战2:应用一个应用程序图标

打开下载材料中本章的assets文件夹,你会发现一个叫markdown.png的图像文件。回头看看第5章"设置偏好和图标",提醒自己如何创建一个应用程序图标集并将其添加到项目中。

你可以自己去实现这些,但如果你需要一些帮助,请查看challenge文件夹。

关键点

  • 苹果为基于文件的Mac应用程序提供了一个起始模板。这可以让你很快上手,但现在你知道如何定制这个模板以适应你自己的文件类型。
  • 你使用Uniform Types来指定你的应用程序可以处理哪些文件类型。这些可以是苹果公司在系统中定义的类型,或者你可以创建你自己的自定义类型。
  • SwiftUIAppKit协作良好。你可以使用NSViewRepresentableSwiftUI应用程序中嵌入任何AppKit视图。

何去何从?

你已经创建了一个编辑器应用程序,可以处理Markdown文件,将其转换为HTML,并以各种方式预览它们。

在下一章中,你将为这个应用程序添加菜单命令。它们会让你对HTML进行造型,调整字体大小,显示一些Markdown的帮助,并将Markdown片段添加到你的文档中。