窗口是所有 OS X app 的 UI “容器”。它定义了 app 当前负责的屏幕区域,并且允许用户用通用的模式(多任务)进行交互。OS X app 分为以下类别:
- 单窗口工具例如“计算器”
- 库形式(“shoebox”)的单窗口例如“照片”
- 基于文档的多窗口例如“文本编辑”
无论 app 属于哪个类别,几乎所有 OS X app 都会采用 MVC(模型-视图-控制器),一种核心设计模式。
在 Cocoa 中,一个窗口就是 NSWindow 类的一个实例,相关的控制器对象就是一个 NSWindowController 类的实例。在设计良好的 app 中,会看到窗口和控制器间有一对一的关系。模型层会根据 app 的类型和设计而有所不同。
在这篇 OS X 中的窗口和窗口控制器详解中,我们将会创建 BabyScript,一个模仿“文本编辑”的基于文档的多窗口 app。在这个过程中,你会学到:
- 窗口和窗口控制器
- 文档架构
- NSTextView
- 模态窗口
- 菜单栏和菜单项
前提
本教程面向初学者。话虽如此,但你最好拥有下面的基础知识:
- Swift
- Xcode,特别是 storyboard
- 创建一个简单的 Mac(OS X)app
如果你不了解上述中的某一点,推荐你先了解下我写的其它教程。
开始
启动 Xcode,选择 File / New / Project…。选择 OS X / Application / Cocoa Application,然后点击 Next。

接下来,填写如下所示的字段,但输入你自己的名字(或武侠小说人名),不要用我的。

点击 Next 然后保存项目。
构建运行,会看到:

为了打开多个文档,选择 File / New。由于所有文档都位于同一位置,所以只有在拖动之后才能看到顶部的文档。这不是一个理想的效果,所以在待办清单里记录以后要修复它,但现在先不要研究。

也可以使用 Windows 菜单以把新的窗口挪到前面。

文档:深入了解
现在已经看过了实际的操作,让我们花几分钟了解一下工作原理。
文档架构
文档是内存中数据的容器,可以在窗口中查看。最终,它可以写入磁盘或 iCloud,也可以从中读取。从程序上讲,一个文档是一个 NSDocument 类的实例,它充当数据对象(也称为与文档相关联的模型)的控制器。
文档架构中其它两个主要的类是 NSWindowcontroller 和 NSDocumentController。每个主要类的担任的角色如下:
- NSDocument:创建、呈现和存储文档数据
- NSWindowcontroller:管理显示文档的窗口
- NSDocumentController:管理 app 中所有的文档对象
可视化的效果更好,所以这里有一张展示类之间如何工作的图标:

禁用打开和保存文档
文档架构还提供文档的保存/打开机制。
在 Document.swift 中,可以看到 dataOfType 空的实现,它用于写入以及用于读取的 readFromData。保存和打开文档超出了本教程的范围,因此进行一些更改防止出现混乱。
在 Document.swift 中,移除 autosavesInPlace:
override class func autosavesInPlace() -> Bool {
return true
}
现在就已经禁用了所有与打开和保存相关的菜单项,但在此之间,可以注意到我们想要的所有都已经被实现了。例如,选择 File / Open 然后带有控件、边栏、工具条等等的 finder 对话框就已经存在了:

但在没有定义操作时,菜单项是没有用处的。如果在响应链中没有对象响应与动作相关的操作,就会发生同样的禁用效果。
因此,应该让需要禁用的菜单项和为它们定义的操作断开连接。

在 storyboard 里,选择 Application Scene 中的 Main Menu 中的 File / Open。
选择 Connections Inspector 然后点击 Open。可以看到,它通过 openDocument 选择器连接了 first responder,也就是 responder 链中第一个响应这个选择器的对象。点击 x 删除连接,如下所示:

给 Save、Save as 以及 Revert To Saved 重复同样的操作。
构建运行。切换至 Open 菜单,它看起来就像这样:

窗口位置
在你运行 BabyScript 的时候,窗口在靠近左侧边缘处的位置打开,稍低于屏幕中心。
为什么它会选择这个位置?
转到 storyboard,在 outline view 中选择 Window,然后选择 Size Inspector。运行 BabyScript——或者把它挪到前面来——你就能看到如下屏所示:

在 Initial Position 下面输入 X 和 Y 的数值是一种设置窗口位置的方式。也可以拖动下面的灰色矩形进行设置。

注意:Cocoa 中的可视对象(窗口、视图、控件等)的原点是左下角。在坐标系中值向上和向右增加。
相比之下,许多图形环境,特别是 iOS,原点在左上角,值向下和向右增加。
假设我们需要窗口的打开位置位于距离左上角横竖各 200 点的位置。你可以在 Xcode 中的 Size Inspector 窗口里设置它,或者用代码实现。
使用 Interface Builder 设置窗口的位置
要假设用户会从不同尺寸分辨率的屏幕上启动 BabyScript。由于 app 无法预见编译时会在多大尺寸的屏幕上打开,Xcode 使用了虚拟屏幕大小,并使用类似于自动布局的概念来确定运行时的窗口位置。
要设置位置,需使用 Initial Postion 下面的 X 和 Y 值以及那两个下拉菜单。
转到 Initial Position 根据屏幕坐标设置窗口的打开位置。X 和 Y 都输入 200,然后分别在上下下拉菜单中选择 Fixed from Left 和 Fixed from Bottom。这将会在 x 和 y 方向上设置窗口原点 200 的偏移量。

构建、运行然后可以看到:

按照以下步骤将窗口固定到左上角:
- 将预览中的灰色矩形拖动到虚拟屏幕的左上角——这会更改 initial position。
- 在 X 中输入 200,Y 中输入最大值减去 200,在这里是 557。
- 从下面的下拉列表中选择 Fixed from Top。
下面右侧的图片说明了输入的内容和位置:

注意:OS X 会在 app 启动中记录窗口位置。为了看到我们所做的更改,需要彻底关闭 app 窗口——不要只是构建和运行。
关闭窗口,然后编译和运行。

用代码设置窗口的位置
现在会完成与使用 Interface Builder 相同的任务,但这是是用代码实现。
采取“硬编程方式”有两个原因。首先,有助于更好地了解 NSWindowController。其次,它是一种更灵活和直接的方法。
在运行时,app 会在了解屏幕大小后设置窗口的最终位置。
在 Project Navigator 里选择 BabyScript 组,然后选择 File / New / File..。从弹出的对话框里,选择 OS X / Source / Cocoa Class 然后点击 Next。
创建一个名为 WindowController
的新类,让它成为 NSWindowController
的子类。不要勾选 XIB 的勾选框,Language 应是 Swift。

选择某个位置存储这个新文件。完成后,你会看到一个叫做 WindowController.swift 的新文件出现在 BabyScript 组里。
打开 storyboard,在 Outline View 中选择 Window Controller Scene 里的 Window Controller。打开 Identity Inspector,然后从 Class 的下拉菜单中选择 WindowController
。

调用 viewDidLoad
的时候,窗口已经完成了从 storyboard 的加载,所以你做的任何配置都会覆盖 storyboard 中的设置。
打开 WindowController.swift 将 viewDidLoad
替换为如下代码:
override func windowDidLoad() {
super.windowDidLoad()
if let window = window, screen = window.screen {
let offsetFromLeftOfScreen: CGFloat = 20
let offsetFromTopOfScreen: CGFloat = 20
let screenRect = screen.visibleFrame
let newOriginY = CGRectGetMaxY(screenRect) - window.frame.height
- offsetFromTopOfScreen
window.setFrameOrigin(NSPoint(x: offsetFromLeftOfScreen, y: newOriginY))
}
}
这套逻辑将窗口左上角置于距离屏幕左上角 x 和 y 方向各 20 点的位置。
可以看到,NSWindowController 有一个窗口属性,NSWindow 有一个 screen 属性。可以使用这两个属性来访问窗口和屏幕的几何尺寸。
在确定屏幕的高度后,窗口的 frame 就被减去了所需的偏移量。记住,Y 值随着在屏幕上向上移动而增加。
visibleFrame
排除了 dock 和菜单栏所占用的区域。如果你没有考虑到这一点,窗口可能会被 dock 遮挡住。
当设置了系统隐藏 dock 和 菜单时,visibleFrame
仍会小于 frame
,因为系统保留了一个小边界区域以检测何时显示 dock。
构建并运行。窗口应该距离屏幕左上角各 20 点。
层叠窗口
为了进一步提升窗口的位置,我会为你介绍 层叠窗口(Cascading Windows,),表示一种窗口的安排方式,彼此重叠,而每个窗口的标题栏都可见。
在 WindowController.swift 中的 WindowController
定义下面添加如下代码:
required init?(coder: NSCoder) {
super.init(coder: coder)
shouldCascadeWindows = true
}
通过覆写 NSWindowController 所需的 init 方法,将 NSWindowController 的 shouldCascadeWindows 属性设置为 true。
构建并运行 app,然后打开五个窗口。屏幕应该看起来更友好了:

让 BabyScript 成为一台迷你文字处理器
现在到了本教程中最激动人心的部分。只需两行代码和一个添加到窗口的 contentView 中的 NSTextView 控件,就可以添加一个冲击人们心灵的功能!
Content View
在创建时,窗口会自动创建两个视图:带边框、标题栏等等的不透明视图以及通过窗口的 contentView 属性可以访问的透明 content view。
内容视图是窗口视图层级的最下层,可以用自定义视图替换掉默认视图。注意如果要设置 content view 的位置,必须使用 NSWindow 的 setContentView 方法——不能用 NSView 标准的 setFrame 方法来设置它的位置。

注意:如果你是 iOS 开发者,请注意在 Cocoa 中,NSWindow 不是 NSView 的子类。在 iOS 中,UIWindow 是 UIView 一个特殊的子类。UIWindow 本身是视图层级的底层,它只是扮演了 content view 的角色。
添加 Text View
在 storyboard 里从 contentView 中移除写着 “Your document contents here” 的 text field,只要选中并按 delete 即可。
要创建 NSTextField 需要按照以下步骤操作,它将会构成 UI 的主要组成部分:
- 在 storyboard 里,打开 Object Library。
- 搜索 nstextview。
- 拖一个 Text View到 content view 上。
- 调整 text view 的大小,使其在 content view 每侧的 inset 各为 20 点。
- 在 Outline View 中,选择 Bordered Scroll View。注意 text view 嵌套在 Clip View 中,Clip View 嵌套在 scroll view 中。
- 打开 Size Inspector。X 和 Y 输入 20,Width 输入 440,Height 输入 230。

构建运行——可以看到如下界面:

看看,友好的、闪烁着的文本插入光标正在邀请你输入文字!输入你写的小说,或者简单一点就输入 “Hello World”,然后选中文本。用 File/Copy 或 Command - C 拷贝,然后多粘贴几次,只是为了感受一下这个 app。
探索一下 Edit 和 Format 菜单,了解可用的功能。你可能发现 Font / Show Fonts 被禁用了。现在我们要启用它。
启用字体面板
在 storyboard 中,打开 main menu,点击 Format 菜单,然后点击 Font,再点击 Show Fonts。
打开 Connections Inspector 然后可以看到这个菜单项没有定义的操作。这样菜单项为什么被禁用就说通了,但要连接到什么呢?
很明显,该操作已经在由 Xcode 间接引入的代码里定义好了,只需要连接一下就好了。
右击 Show Fonts 然后把它拖到 Application Scene 里的 First Responder,然后松手。一个带有可滑动列表的小窗口会弹出来。找到并选中 orderFrontFontPanel
。也可以输入它的名字以便更快找到。

现在看看选中了 Show Fonts 的 Connections Inspector。你会发现菜单现在连接到了响应该选择器的响应链中的第一个对象的 orderFrontFontPanel。
构建并运行 app,输入一些文字然后选中它们。选择 Format / Font / Show Fonts 以打开字体面板。使用字体面板右侧的竖向滑块,观察文本大小是如何实时变化的。

等等,你好像还没有输入任何关于 text view 的代码,就已经可以改变文字的大小了。你太牛逼了!

用富文本初始化 Text View
为了查看这个 app 的全部功能,从 这里 下载一些格式化文本,并将其用作 tet view 的初始文本。
用文本编辑打开它,全选并拷贝到剪贴板。打开 storyboard,选中 Text View,然后打开 Attributes Inspector 并把文本粘贴到 Text Storage 字段中。

构建并运行,可以看到:

使用自动布局
如果当前窗口装不下文本,的确可以滑动来解决,但还是尝试一下调整窗口的大小。
Oops!text view 没有调整窗口的大小。

使用自动布局就可以轻松修复它。
注意:自动布局在 Cocoa 和 iOS 中都可以帮助你构建 app 的 UI。它创建了一组规则,定义了元素之间的几何关系,并根据约束来定义这些关系。使用自动布局可以创建动态界面,以适当地响应屏幕大小、窗口大小、设备朝向和本地化的更改。还有更多的东西,但是由于本教程篇幅所限,你只需要跟着做完下面几步就可以——以后可以多花点时间了解自动布局。这里是两篇很好的教程,可以看看:在 iOS 7 中使用 Auto Layout 教程, 第一部分 和 第二部分 。
在 storyboard 的 Outline View 中,选中 Bordered Scroll View,然后点击画布右下角的 Pin 按钮。
依次点击四个小红条约束;浅红色虚线会变成红色实线。点击底部写着 Add 4 Constraints 的按钮。

构建并运行,观察窗口和 text view 是如何一起调整大小的:

默认显示标尺
要在窗口打开时自动显示标尺,你需要在代码中使用 IBOutlet。从菜单中选择 Format / Text / Show Ruler。在 ViewController.swift 中,给 viewDidLoad 新写一行 toggleRuler 方法,然后在该方法上方新加一个 IBOutlet,如下所示:
@IBOutlet var text: NSTextView!
override func viewDidLoad() {
super.viewDidLoad()
text.toggleRuler(nil)
}
现在要将 text view 连接到 storyboard 中的 view controller。
在 storyboard 中,右击 ViewController,按住并拖动到文本视图,知道它高亮显示再松手。显示了一个带有 Outlets 列表的小窗口。选中 text outlet:

构建并运行,现在窗口默认显示标尺了:

所以就像我承诺的,只用了两行代码和 storyboard,你就创建了一个迷你文字处理器——爱你,苹果!
模态窗口
可以用模态方式运行窗口。窗口仍然使用 app 的正常事件循环,但只能在模态窗口中输入。
有两种方式实现模态窗口。可以调用 NSApplication 的 runModalForWindow 方法。这种方法会让这个窗口独占所有事件,直到通过调用 stopModal、abortModal 和 stopModalWithCode 来请求停止。
在这个 app 中,我们会使用 stopModal。另一种叫做 modal session 的方式本教程并不会涉及。
添加一个字数统计窗口
我们会添加一个模态窗口,对活动窗口中的文字和段落进行计数。它必须是模态的,因为它与特定窗口和特定状态相关。
打开 Object Library,从中拖一个新的 window controller 到画布上。这会创建两个新场景:一个 window controller scene 以及一个 view controller scene:

从新的 window controller scene 中选中 Window,然后使用 Size Inspector 将它的宽度设置为 300,高度设置为 150。从新的 view controller scene 中选中 View,把它的大小调成和窗口相同:

由于“字数统计”是模态的,标题栏里有关闭、最小化和调整大小按钮会很奇怪,违背了 HIG(苹果的人机界面指南)。
对于关闭按钮,也会导致一个严重的 bug,因为点击该按钮会关闭窗口,但没有调用 stopModal
。所以,app 会被永远困在“模态”中。
移除模态的按钮
在 storyboard 中,选中 Word Count 窗口然后打开 Attributes Inspector。取消 Close、Minimize 和 Resize 的勾选。顺便把 Title 改为 Word Count。

现在要从 Object Library 中添加四个 label 控件和一个 push button 到 Word Count 窗口的 contentView 上。
打开 Attributes Inspector。把 label 的 title 依次改为 Word Count、Paragraph Count、0和0。也把两个 0 label 的 alignment 改为右对齐。把 push button 的 title 改为 OK。

下一步是创建一个子类 Window Count ViewController。点击 File / New / File..,选择 OS X / Source / Cocoa Class。在 Choose Options 对话框中,在 Class 字段中输入 WordCountViewController,在 Subclass of 字段中输入 NSViewController
。

点击 Next 并创建新文件。确认 WordCountWindowControll.swift 现在在项目导航器中。
打开 storyboard。在 view controller 场景中选中代表字数统计的图标。打开 Identity Inspector,然后从 Class 下拉列表中选中 WordCountViewController。注意画布和 Outline View上的名字是如何从通用名称变成 Word Count View Controller 的。

创建统计 Label
现在要为两个显示计数值的 label 创建 outlets——两个 0 label。在 WordCountViewController.swift 的类定义下面,添加如下代码:
@IBOutlet weak var wordCount: NSTextField!
@IBOutlet weak var paragraphCount: NSTextField!
在 storyboard 中,右击代表 word count view controller 的图标,拖动最上面的 0 label,并在它高亮显示的时候松手。从弹出的 Outlets 中,选择 wordCount。
在下面的 0 label 上重复同样的操作,但这次要选择 paragraphCount。在 Connections Inspector 中检查是否每个 label 的 Outlets 都像这样连接好了:

稍后,我们会添加代码,通过代码加载字数统计 window controller。这需要它有一个 storyboard ID。从 storyboard 选择 word count window 的 window controller。打开 Identity Inspector,然后在 Storyboard ID 中输入 Word Count Window Controller:

显示模态窗口
现在写显示模态窗口的基本逻辑。在 document window 的 view controller 中,找到并选中 ViewController.swift 然后在 viewDidLoad 的下面输入如下代码:
@IBAction func showWordCountWindow(sender: AnyObject) {
// 1
let storyboard = NSStoryboard(name: "Main", bundle: nil)
let wordCountWindowController = storyboard.instantiateControllerWithIdentifier("Word Count Window Controller") as! NSWindowController
if let wordCountWindow = wordCountWindowController.window, textStorage = text.textStorage {
// 2
let wordCountViewController = wordCountWindow.contentViewController as! WordCountViewController
wordCountViewController.wordCount.stringValue = "\(textStorage.words.count)"
wordCountViewController.paragraphCount.stringValue = "\(textStorage.paragraphs.count)"
// 3
let application = NSApplication.sharedApplication()
application.runModalForWindow(wordCountWindow)
}
}
分步解释:
- 使用上面指定的 storyboard ID 实例化 word count window controller。
- 设置从 word count window count outlet 里面的 text view 中得到的值
- 模态显示字数统计窗口
注意:在第二步中,你在两个 view controller 间传递数据。就像过渡时调用了 segue 然后在 prepareForSegue 方法中通常会做的。因为用一个 runModalForWindow 的调用就直接完成了模态窗口的显示,所以不需要调用 segue,只要在调用方法前传递数据即可。
拜拜,模态窗口
现在需要添加代码以关闭字数统计窗口。在 WordCountViewController.swift 中,在 paragraphCount outlet 下面添加如下方法:
@IBAction func dismissWordCountWindow(sender: NSButton) {
let application = NSApplication.sharedApplication()
application.stopModal()
}
这是一个应该在用户点击字数统计窗口上的 OK 时调用的 IBAction。
打开 storyboard,右击 OK,然后按住并拖动到代表 word count view controller 的图标上。松手并从显示的列表中选中 dismissWordCountWindow::

添加 UI 以调用它
显示窗口要做的最后一件事是添加 UI 以调用它。打开 storyboard,点击 Main Menu 中的 Edit。从 Object Library 中,拖一个 Menu Item 到 Edit 菜单的底部。打开 Attributes Inspector 然后将标题设置为 Word Count。

再花点时间创建一个快捷键,通过输入命令——K 作为等效键。
现在要链接新的菜单项到 ViewController.swift 中的 showWordCountWindow 方法。
打开 storyboard,右击 Word Count 菜单项,按住并拖到 application scene 上的 First Responder。从列表中选择 showWordCountWindow。

注意:你可能想知道为什么要将菜单项连接到 first responder,而不是直接到 showWordCountWindow。这是因为文档视图的主菜单和视图控制器在不同的故事板场景中,因此,不能直接连接。
构建并运行 app,点击 Edit / Word Count,瞧,字数统计窗口自己弹出来了。

点击 OK 以关闭该窗口。
下一步?
这是 BabyScript 的最 终版本 。
你已经看完了这篇 OS X 中的窗口和窗口控制器详解中的绝大部分!但这对于窗口和窗口控制器来说只是冰山一角。
你学到了:
- 实际应用的 MVC 设计模式
- 如何创建多窗口 app
- OS X app 的典型架构
- 如何用 Interface Builder 以及通过代码放置和组织窗口
- 使用自动布局以调整窗口和视图的大小
- 使用模态窗口以显示额外信息
还有其它的!
我强烈建议你看看苹果在 El Capitan 的 Mac 开发者库 中提供的大量文档。特别是,参考一下 Window Programming Guide。
为了更好地了解 Cocoa app 的设计,以及如何处理开篇提到的 app 类型,可以看看 Mac App Programming Guide 。这篇文档还扩展了基于多窗口文档 app 的概念,所以你会找到创意来完善 BabyScript。
期待听到你的想法、经验和疑问,请在下方评论!