原文:Windows and WindowController Tutorial for macOS
作者:Warren Burton
译者:kmyhy

更新说明:本教程由 Warren Burton 升级至 Xcode 8 和 Swift 3。原文作者是 Gabriel Miro。

Windows 是有 macOS app 的 UI “容器”。它定义了 app 当前管理着的、允许用户以多任务模式交互的屏幕区域。macOS app 分成这几类:

  • 单窗口应用,比如计算器
  • 单窗口图书馆风格的“鞋盒式”应用,比如照片应用
  • 多窗口基于文档的应用,比如 TextEdit

无论属于哪种 app,几乎每个 mac app 都使用了 MVC 这种主要的设计模式。

在 Cocoa 中,一个窗口是一个 NSWindow 实例,对应地,控制器就是一个 NSWindowController 实例。在一个设计良好的 app 中,你会发现窗口及其控制器是 1 对 1 关系。MVC 模式中的第三个部分是模型层,则依据 app 的类型和设计不同而不同。

在本教程中,我们将创建一个多窗口文档类型的 app,BabyScript,模仿 TextEdit。在此过程中,你将学习:

  • Windows and window controllers
  • 文档结构
  • NSTextView
  • Modal windows
  • 菜单条和菜单项

预备

本教程针对初学者。但是,你需要掌握以下内容:

  • 熟悉 Swift 3.1
  • 熟悉最新版本的 Xcode,尤其是 Storyboard
  • 会创建简单的 Mac(macOS) app
  • 知道什么是 First Responder 和响应链

如果你不熟悉以上内容,你可以阅读 macOS tutorials pageGetting Started 一节。

开始

打开 Xcode,选择 File / New / Project…。选择 macOS / Application / Cocoa Application, 点击 Next。

在下一界面,如下图所示进行填写。确认勾选 Create Document-Based Application ,将 Document Extension 设置为 “babyscript”, 其它选项清空。

点击 Next,保存项目。

运行 app,你会看到这个窗口:

要打开第二个文档窗口,选择 File / New (或者 Command-N)。所有新建文档都放在同一地方,因此你只能看到最上面的文档,除非你将它拖开。这个问题待会再来解决。

多文档 app

实际体验过一把之后,花几分钟来看一下多文档 app 的工作机制是什么。

文档结构

一个文档是一个 NSDocument 类的实例,用于充当内存中的数据或模型的控制器——你可以在一个窗口中查看这个模型。它能够读写磁盘或 iCloud。

NSDocument 是一个抽象类,也就是说你必须创建它的子类,因为它的功能并不完善。

在文档结构中,另外还有两个主要的类是 NSWindowController 和 NSDocumentController。

每个类的职责如下:

  • NSDocument: 创建、呈现和存储文档数据。
  • NSWindowController: 管理要显示的文档窗口。
  • NSDocumentController: 管理 app 中所有文档对象。

下图显示这些类之间的关系:

禁止文档的保存和打开

文档结构内置了文档的保存和打开机制。但是,这些事情你想在自己的子类中去完成。

打开 Document.swift。你会发现空实现的 data(ofType:)方法,用于文件的写入;以及 read(from:ofType:) 方法,用于文件的读取。

文档的保存和打开不属于本文的内容,因此你需要禁止这个行为以混淆概念。

打开 Document.swift 将 autosavesInPlace 方法修改为:

override class func autosavesInPlace() -> Bool {
  return false
}

这样就禁用了自动保存特性。现在,需要将和打开、保存相关的菜单禁用了。在这样做之前,注意所有你想用的功能都已经存在了。例如,运行 app,选择 File / Open。注意 Finder 对话框,包括控件、侧边栏、工具栏等等:

当菜单项没有定义 action 时,这个菜单项就会显示为不可用。因此,你可以删除某个菜单项的 action,就可以禁用它了。

打开 Main.storyboard。找到 Application Scene 选择 Main Menu 中的 File / Open 菜单项。打开连接面板。你会看到,它的 action 连接的是 first responder 的 openDocument: 方法。点 x 删除这个连接:

在 Save、Save As 和 Revert to Save 菜单上进行同样操作。

删除整个

打开 Document.swift 添加一个方法,在用户试图保存时显示一个 alert。在本文的后面你会显示这个 alert。

override func save(withDelegate delegate: Any?, didSave didSaveSelector: Selector?, contextInfo: UnsafeMutableRawPointer?) {
  let userInfo = [NSLocalizedDescriptionKey: "Sorry, no saving implemented in this tutorial. Click \"Don't Save\" to quit."]
  let error =  NSError(domain: NSOSStatusErrorDomain, code: unimpErr, userInfo: userInfo)
  let alert = NSAlert(error: error)
  alert.runModal()
}

运行 app。选择 File 菜单,看起来像这个样子:

现在窗子已经被你打碎,你可以装上新的玻璃了!

窗口位置

你要解决的第一个问题就是,创建新文档时每个窗口和其它窗口重叠在了一起。

你需要创建一个 NSWindowController 子类,在它里面编写代码,控制和初始化文档窗口的位置。

创建一个 NSWindowController 子类

在项目导航器中选中 BabyScript 文件夹,然后选择 File / New / File…,在弹出窗口中,选择 macOS / Source / Cocoa Class 然后点 Next。

新建类 WindowController,继承 NSWindowController。反选 create a XIB,Language 设置 Swift。

点击 Next,选择文件保存位置。

然后,将故事板中的 Window contorller 修改为 WindowController 的实例。打开 Main.storyboard,选择 Window Controller 场景中的 Window Controller。在 Identity 面板中,将 Class 设置为 WindowController。

窗口层叠

还记得你的文档窗口全部都时在其它窗口的位置上打开的吗?我们准备在新建窗口时以层叠方式打开。打开WindowController.swift 添加代码:

required init?(coder: NSCoder) {
  super.init(coder: coder)
  shouldCascadeWindows = true
}

shouldCascadeWindows 属性设置为 true,告诉 window controller 以层叠方式放置窗口。

运行 app,你会发现新窗口会在原来窗口的基础上偏移一定的位置,这样所有窗口都能够同时看到了。

添加 Tab

层叠式窗口是可以了,但这种方式有点古旧。那我们来试试最新的 Sierra API: Tab 式窗口如何?

打开 Main.storyboard 选择 Window Controller 场景中的 Window。打开属性面板,将 Tabbing Mode control 设置为 Preferred。

就这个样子。只需要小小的一个改变,你的 app 就变成了最新的 Tab 风格!

运行 app。新开几个文档,你会看到所有的 tab 都放在一个窗口里。

在运行 BabyScript 时,macOS 会计算当前屏幕的大小,询问窗口的大小以决定要将窗口放在哪里以及窗口真正的大小。

有两种方法控制窗口位置和大小。接下来你会学习到这个。

在 IB 中设置窗口位置

首先,我们用 IB 来设置窗口的初始位置。

打开 Main.storyboard,在 Window Controller 场景中,选择 Window。打开 Size 面板。运行 BabyScript,或者将它放到最上层窗口,你会看到下面这个样子:

设置窗口位置的一种方式,就是修改 Initial Position 下面的 X 值和 Y 值。你也可以用拖动 X 和 Y 值下面的预览窗口中的灰色矩形框来设置它。

注意:Cocoa View 的坐标零点是左下角。因此,Y 值从下往上增加。这和 iOS 的视图坐标零点在左上角是不同的。

如果你点击窗口预览中的灰色窗口四周的红色约束线,你可以控制当新窗口放在屏幕中时 macOS 是如何计算窗口位置。注意当你这样做时,预览窗口下方的下拉菜单会做响应改变。

它们的默认值是 Propertional Horizontal 和 Propertional Vertical。意思是窗口的初始位置将取决于屏幕的尺寸。现在做如下修改:

  1. 将两个下拉框中的值设置为 Fixed From Left 和 Fixed From Bottom。
  2. 设置初始位置的 x 为 200,y 为 200。

Build & Run。你会发现第一个新窗口无论屏幕尺寸是多大它的位置都是同一个位置。

注意:macOS 在 app 重启后会记住窗口的位置。为了看到你修改后的效果,你需要关闭窗口,然后 build & run。

以编码方式设置窗口位置。

在这一节,我们将实现和之前用 IB 来实现的相同效果,但这次我们是将通过编写代码的方式实现。这样你可以在运行时控制窗口的初始位置。在某些情况下这种方式会更加灵活。

我们将修改 window controller 的 windowDidLoad 方法。当 windowDidLoad 方法被调用时,窗口已经将所有 storyboard 中的 view 都加载完成了,因此你可以在这里覆盖故事版中的所有设置。

打开 WindowController.swift 修改 windowDidLoad 方法为:

override func windowDidLoad() {
  super.windowDidLoad()
  //1.
  if let window = window, let screen = window.screen {
    let offsetFromLeftOfScreen: CGFloat = 100
    let offsetFromTopOfScreen: CGFloat = 100
    //2.
    let screenRect = screen.visibleFrame
    //3.
    let newOriginY = screenRect.maxY - window.frame.height - offsetFromTopOfScreen
    //4.
    window.setFrameOrigin(NSPoint(x: offsetFromLeftOfScreen, y: newOriginY))
  }
}

上述代码将窗口的左上角设置为 屏幕左上角偏移 x:100,y:100 处:

  1. 获得 NSWindow 和 NSScreen 对象,以便计算坐标。
  2. 获取屏幕的 visibleFrame 属性。
  3. 从屏幕高度减去窗口高度和偏移 Y 值。
  4. 将窗口左上角设置为计算后的坐标。

NSScreen 的 visibleFrame 属性会排除 Dock 和菜单栏的面积。如果将这两者考虑进去,则你的窗口可能会被 Dock 遮掉一部分。

Build& run。现在窗口会偏离屏幕左上角 100 像素的位置。

实现基本的字处理

有些神奇的 Cocoa UI 组件就等着你拖到 app 窗口中了。在这一节,你将学习功能强大和灵活多变的 NSTextView。但在此之前,你需要了解 NSWindow 的 content view。

content view

contentView 是窗口视图树中的根视图。它位于窗口的外框(标题栏和控制按钮)之内,它是所有 UI 元素的容器视图。你可以修改 contentView 属性,将它替换成你自己的。虽然本教程中不会这样做,但你还是要知道这一点。

添加 text view

打开 Main.storyboard 删除写有 “Your document contents here” 的 text field。然后添加一个 text view:

  1. 在故事板中,打开 Object Library。
  2. 搜索 NSTextView。
  3. 将 Text View 拖进 content view。
  4. 修改 text view 的 Inset 为距离 content view 四周 20 像素。蓝色的对齐线也许能帮到你。
  5. 在 Outline 窗口中,选择 Bordered Scroll View,注意,text view 是嵌套在 Clip View 中的,而 Clip View 又是套在 scroll view 中的。
  6. 选择 Resolve Auto Layout Issues 按钮,然后选择 Reset To Suggested Constraints。

Build & run——你会看到:

看起来很好,闪烁的文字输入提示符正等待你输入一些文字!输入一个长篇,或者简单的文本 Hello world,然后用 Edit / Copy 或者 Command+C,复制粘贴多次,以便测试。

查看一下 Edit 菜单和 Format 菜单,了解一下有些什么功能。你可能会发现 Formt / Font / Show Fonts 是禁用的。我们现在就来解决它们。

使用字体面板

在 Main.storyboard 中,找到主菜单,点击 Format \ Font \ Show Fonts 菜单。

在连接面板中,你会发现这个菜单项没有连接 action。所以这个菜单是灰的,但我们将它连接到哪里呢?

这个 action 已经隐式地被定义在 Xcode 导入的代码里面,作为 Cocoa 的一部分——你只需要连接它们就可以了。你需要这样做:

  1. Ctrl+左键,点击 Show Fonts 拖一条线到 Application Scene 的 Responder 上,松开鼠标。
  2. 在弹出窗口中,找到并选择 orderFrontFontPanel:。为了节省时间,你可以输入 orderFrontFontPanel 直接定位到它。
  3. 保持 Show Fonts 选中,在连接面板中,你会发现它已经连接到 orderFrontFontPanel:方法,这个方法属于响应链中第一个对象。

Build & run,输入几个字,然后选中它。选择 Format / Font / Show Fonts,打开字体面板(或者按 Command+T)。拖到字体面板右边的垂直滚动条,你会发现字体大小会随之而变。

我们没有编写哪怕一行代码,就实现了修改字体大小的功能。这是怎么做到的?这是因为 NSFontManager 和 NSTextView 类为你完成了大部分脏活累活。

NSFontManager 是负责管理字体转换系统的类。它实现了 orderFrontFontPanel 方法,因此当响应链将消息发送到它时,它弹出了默认的系统字体面板。

当你修改了面板中的字体属性,NSFontManager 发送一个 changeFont 消息给 First Responder。

NSTextView 实现了 changeFont,同时它是响应链中的第一个对象,因为你刚刚选中了一段文本。因此,当字体属性改变,它自动修改选中文本的字体。

让 Text View 支持富文本

真正想领略 NSTextView 的威力,请从这里下载格式化好的文本,以便用于作为 Text View 的初始文本。

用文本编辑器打开下载的文件,选中所有文本然后拷贝到剪贴板。然后,打开 Main.storyboard ,选中Text View。在属性编辑器中,将复制的内容粘贴到 Text 属性。

然后,勾选 Graphics 和 Image Editing 选项框,允许在 Text View 中显示图片。

Build & run,你会看到:

图片来自于你刚刚拷贝的内容!怎么会这样?

你无法向 IB 的 Text 字段中添加图片——因为图片并没有保存在故事板中。但你可以在 BabyScript 运行的时候通过拖拽,或者粘贴的方式放到 text view 中。你可以尝试一下。

当你修改文字,或者粘贴一张图片之后,关闭窗口。这时会弹出一个 alert,选择 save the document。你会发现我们在教程一开始编写的 alert 错误消息弹出了。

显示标尺

要在一个 BabyScript 窗口中显示标尺,你需要为 text view 创建一个 IBOutlet 连接。打开ViewController.swift,删除默认的 viewDidLoad 方法。添加如下代码:

@IBOutlet var text: NSTextView!

override func viewDidLoad() {
  super.viewDidLoad()
  text.toggleRuler(nil)
}

这里为 text view 定义了一个出口,然后在 viewDidLoad 方法中调用 text view 的 toggleRuler 方法以显示标尺——默认,标尺是被隐藏的。

然后在 IB 中将 text View 连接到该出口。

打开 Main.storyboard 点击 ViewController 代理。Ctrl+左键,将它拖到 text view 上直到 text view 高亮再释放鼠标。弹出一个窗口,列出所有出口,选择 text 出口:

Build & run,现在每个编辑器窗口都会显示标尺了:

使用两句代码,以及 Cocoa 的默认功能,你就实现了一个简单的字处理程序!

站起来伸个懒腰,喝上一小杯休息一会,准备进入下一节!

模式窗口

在窗口的世界中,模式窗口就像是那些爱出风头的人。一旦呈现,它会消耗所有的事件,直到它消失。你在需要吸引用户所有注意力时使用它来做某些事情。macOS app 中的保存和打开对话框就是模式窗口的极佳例子。

要呈现模式窗口,有 3 种方式:

  1. 对于正常的窗口,用 NSApplication.runModal(for:)。
  2. 对于模式表单,用 NSWindow.beginSheet(:completionHandler:)。
  3. modal session。这属于高级主题,不属于本文内容。

模式表单会显示在弹出它们的窗口之上,例如 BabyScript 中的保存对话框。

除此之外,本教程中不会再用刀模式表单。相反,在下一节,你将学习如何显示分离的模式窗口,用于显示当前文档的字数统计和段落统计。

向场景中添加新窗口

打开 Main.storyboard。拖一个新的 Window Controller 到画布中。这会创建两个新的场景:一个 Window Controller Scene 和它的内容 View Controller Scene:

选中 Window Controller Scenen 的 Window 对象,在 Size 面板中将 width 设为 300,height 设为 150。

保持 Window 选中,在属性面板中反选 Close、 Resize 和 Minimize 选项。然后将 title 设置为 Word Count。

Close 按钮有一个严重的 Bug,当点击这个按钮时会关闭窗口,但没有调用 stopModal,因此 app 会停留在“modal”状态。

标题栏中的最小化和修改窗口大小按钮有点奇怪。它也违反了苹果的人机交互指南(HIG)。

现在,选择 View Controller 场景中的 View,在 Size 模板中设置 width 为 300,高度 150。

创建字数统计窗口

从 Object Library 中拖 4 个 Label 到 Word Count 窗口的 contentView 上。将它们排列为如下图所示。因为这个窗口是不能修改大小的,你不需要考虑自动布局的问题。

选择属性面板,将 label 的标题修改为 Word Count、Paragraph Count、123456 和 123456 ,如下图所示。(因为你没有用自动布局来动态调整 label 的宽度,你可以用一段长的 placeholder 文本比如123456,确保 label 宽度在运行时是足够的,数字不会被截断)。现在,将所有 label 的对齐方式设置为右对齐。

然后,拖一个 Push Button 到 content view。

将按钮的标题修改为 OK。

创建 Word Count View Controller 类

你需要创建一个 NSViewController 子类,用于作为字数统计的 View Controller:

  1. 选择 File / New / File…, 选择 macOS / Source / Cocoa Class。
  2. 在 Choose Options 对话框, Class 一栏输入 WordCountViewController。
  3. SubClass 输入 NSViewController。
  4. 反选 “Also create XIB for user interface”。

点击 Next,创建新文件。

打开 Main.storyboard。在 word count view controller 中点击 proxy 图标,然后切换到 Identity 面板。从 Class 下拉列表中选择 WordCountViewController。

绑定统计标签到 view controller

接下来,需要通过 Cocoa 绑定将统计值显示到 view controller 中。打开 WordCountViewController.swift, 添加代码:

dynamic var wordCount = 0
dynamic var paragraphCount = 0

dynamic 关键字允许属性支持 Cocoa 绑定。

打开 Main.storyboard 选中用于显示字数统计数字的 Label。打开 Bindings 面板:

  1. 点击 Value 旁边的小三角,展开 Value 绑定。
  2. 在 Bind To 下拉列表中,选择 Word Count View Controller。
  3. 勾选 Bind To
  4. 在 Model Key Path 中输入 wordCount。

在显示段落统计数的 label 上重复同样步骤,当 Model Key Path 选择 paragraphCount。

注意:Cocoa 绑定在 UI 开发中非常有用。如果你想了解更多它的内容,请阅读我们的 macOS Cocoa 绑定教程

最后,需要为 controller 指定一个 Storyboard ID。

选择 Word Count 窗口的 Window Controller。然后在 Identity 门面板中的 Storyboard ID 一栏输入 Word Count Window Controller。

呈现/解散模式窗口

字数统计窗口的 storyboard 组件都已就绪。接下来应该显示这个窗口了!

在后面几节中,我们将编写显示窗口的代码,让它最终完成。基本上已经快完成了,再坚持一下吧!

显示模式窗口

打开 ViewController.swift 添加方法:

@IBAction func showWordCountWindow(_ sender: AnyObject) {

  // 1
  let storyboard = NSStoryboard(name: "Main", bundle: nil)
  let wordCountWindowController = storyboard.instantiateController(withIdentifier: "Word Count Window Controller") as! NSWindowController

  if let wordCountWindow = wordCountWindowController.window, let textStorage = text.textStorage {

    // 2
    let wordCountViewController = wordCountWindow.contentViewController as! WordCountViewController
    wordCountViewController.wordCount = textStorage.words.count
    wordCountViewController.paragraphCount = textStorage.paragraphs.count

    // 3
    let application = NSApplication.shared()
    application.runModal(for: wordCountWindow)
    // 4
    wordCountWindow.close()
  }
}
  1. 用我们之前指定的 Storyboard ID,我们可以创建一个 Word Count Window Contorller。
  2. 从 text view 的 storage 对象中获取相应的值(字数和段落数),赋给 word count view controller 的对应属性。由于 Cocoa 绑定的存在,texst field 会自动显示这些值。
  3. 以模式化方式显示字数统计窗口。
  4. 当模式化状态结束,关闭 Word Count 窗口。注意,这句代码一直到模式化状态结束才会被执行。

走开,模式化窗口

接下来,我们添加解散字数统计窗口的代码。在 WordCountViewController.swift 中添加代码:

@IBAction func dismissWordCountWindow(_ sender: NSButton) {
  let application = NSApplication.shared()
  application.stopModal()
}

这是一个 IBAction,当用户点击字数统计窗口中的 OK 按钮时调用。

在这个方法中,简单地终止了之前启动的模式会话。模式会话必须在返回 app 的正常模式时终止。

打开 Main.storyboard,点击 OK 按钮,ctrl+左键,拖一条线到 Word Count View Controller 的 proxy 图标上。松开鼠标,选择列表中的 dismissWordCountWindow: f

添加调用模式窗口的 UI

仍然在 Main.storyboard,找到主菜单,展开 Edit 菜单项,进行如下操作:

  1. 从 Object Library 中拖一个 Menu Item 到 Edit 菜单底部。
  2. 打开属性面板,设置 title 为 Word Count。
  3. 在 Key Equivalent 中输入快捷键 Command+K。

接下来,将新的菜单项和 ViewController.swift 文件中的 showWordCountWindow 方法连接上。

点击 Word Count 菜单项,右键拖一条线到 Application scene 中的 First Responder。在弹出列表中选择 showWordCountWindow: 。

这里,将该菜单项连接到了 first responder,而不是直接连接到 ViewController.swift 文件中的 showWordCountWindow 。这是因为 app 主菜单和 View controller 在故事板中不是同一个 scene,因此不能直接连接。

Build & run,选择 Edit / Word Count (或者 Command+K), 字数统计窗口将显示。

点击 OK 解散窗口。

结束

这里下载 BabyScript 的完成版本。

在本教程中,你学习了:

  1. MVC 设计模式实战。
  2. 如何创建多窗口 app。
  3. macOS app 的典型 app 结构。
  4. 如何通过 IB 和代码设置窗口的位置和布局。
  5. 将 action 从 UI 传递到响应链。
  6. 用模式窗口显示额外的信息。

等等。

但这仅仅是 window 和 Window controller 的冰山一角。如果你想进一步学习这方面的内容,我强烈建议你阅读苹果的 Window 编程指南

要更好地理解 Cocoa 和它在前面提到的 app 类型中的工作原理,请阅读 Mac App 编程指南。这个文档对多窗口文档类 app 的概念进行了扩展,你可以从中寻求改进 BabyScript 的思路。

如果你想看到带保存和打开文档概念的完整版本,请在这里下载更完整的版本。它对于如何实现一个完整的基于文档的 app 提供了一种思路。

希望能下面的留言中看你的想法、经验和问题。

Logo

权威|前沿|技术|干货|国内首个API全生命周期开发者社区

更多推荐