macOS 窗口和窗口控制器教程

原文:Windows and WindowController Tutorial for macOS

更新:本教程由Warren Burton更新到Xcode8 和 Swift 3。原教程是Gabriel Miro编写的。

Windows and WindowController Tutorial for macOS

窗口是所有macOS程序的用户界面的“容器”。他们定义了当前App负责的屏幕上的一片区域,并且让用户能够和这个容易理解的多任务环境进行交互。macOS的App有如下几种:
单窗口应用,比如计算器
单窗口书库,比如图片Photos
多窗口文稿,比如文本编辑器TextEdit
不管是那种App,每一个macOS都是应用了MVC的设计结构。

在Cocoa框架下,一个窗口就是NSWindow类的实例,对应的控制器就是NSWindowController 类的实例。在设计得比较好的App中,你可以发现窗口和窗口控制器的一一对应关系。MVC模式的第三部分是模型层,样式多样取决于App的类型和设计。

本教程中,你将建立一个叫BabyScript的,多窗口文本类型的App,类似TextEdit。过程中,你会学到如下内容:
窗口和窗口控制器
文本的架构
NSTextView
模式窗口
菜单条和菜单项

预备知识

虽然本教程面向初学者,但也需要如下基础知识支撑:
Swift 3.1
最新版的Xcode,特别是storyboards
创建一个简单的Mac App
首选响应器和响应链
如果对其中的概念不熟悉,请寻找相应的教程学习。

开始

启动Xcode,File / New / Project…. 选择 macOS / Application / Cocoa Application, 点击下一步。
这里写图片描述

接下来的屏幕,填写下面列出的区域。勾选 Create Document-Based Application,Document Extension 设置成 “babyscript”,其他的选项不选。

这里写图片描述

点击下一步并保存项目。
运行,看到如下窗口:

First window state

选择 File / New 或着Command-N 打开更多的文稿。所有的文本都被放置在相同的位置,所以你只能看到最上面的文稿,除非你拖动它。不过你很快就会完善它。

Open many windows

文稿:在后台

现在你已经看到它的响应链,现在花些时间看看文稿类App是怎样运行的。

文本架构

一个文稿就是一个 NSDocument 类的实例,它是内存中数据或者模型的控制器,你可以在窗口中观察它。它可从硬盘或云中读取或写入。
NSDocument 是一个抽象类,意味着你必须建立它的子类,因为抽象类不提供具体的功能方法。
另外两个主要的类是 NSWindowController 和 NSDocumentController。
关键类的角色:
NSDocument:创建,展示,存储文稿数据
NSWindowController:管理显示文稿的窗口
NSDocumentController:管理App中所有的文稿对象
这张图展示了这些类的关系:

document architechture

禁止文稿的保存和打开

文稿架构提供了文稿 保存/打开的内建的机构。事实上,这是你的子类必须完成的任务。
打开 Document.swift。你会发现用于保存的 data(ofType:)是空实现,同样,用于打开文档的 read(from:ofType:) 也是。
保存和打开文档不是本教程关注的内容,所以我们要禁止这一功能,避免功能上的混乱。
打开 Document.swift 用下面的代码替代 autosavesInPlace 的实现:

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

上面的代码禁止了自动保存功能。现在你需要禁止所有菜单项中之前就存在的和文件打开、保存相关的项。例如:运行一下,点击 File / Open。Finder对话框就会展现:
stock open window

菜单没有动作定义时,就无效了。所以我们要断开被禁止功能的菜单和动作的关联。

no-saving-for-you

打开 Main.storyboard。转到 Application Scene 选择Main Menu中的 File / Open 菜单项。然后切换到 Connections Inspector。现在菜单的动作是通过 openDocument: 选择器连接到首选响应器。点击小的 x 图标删除这个连接。

break open action

对菜单项 Save、Save As 和 Revert to Saved。重复上述操作。
然后,删除整个 Open Recent 菜单。
现在,打开 Document.swift 在下面的方法中添加下列代码,使你在保存时能弹出警告框。

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

运行,检查一下 File 菜单应该入下图所示:

现在要增加一些新内容了。

窗口位置

首先要修复的是新建文稿的窗口,显示时的位置总是覆盖老文稿的窗口。

创建 NSWindowController 的子类

选择项目导航器里面的BabyScript组,选择 File / New / File…. ,在对话框中选择 macOS / Source / Cocoa Class 并点击下一步。
建立了一个新类叫 WindowController 并让它继承 NSWindowController。XIB不要勾选,语言选择Swift。

create window controller subclass

点击下一步并且选择文档的保存位置。
下一步,你需要保证故事板中的窗口控制器是WindowController的实例。打开 Main.storyboard,选择 Window Controller Scene 下面的 Window Controller 。
打开 Identity Inspector 在下拉菜单中选择 WindowController。

这里写图片描述

层叠的窗口

通过在 WindowController.swift 中添加下列代码,就能实现创建窗口时自动的层叠窗口。

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

把 shouldCascadeWindows 属性设置成true就可通知窗口控制器层叠他的窗口了。
运行,就看到新窗口会和老窗口之间有一点偏置,这样就可以同时看到所有的窗口了。

这里写图片描述

把窗口放到 Tab

层叠式窗口看上去不错,但有点过时了。所以我们怎样才能使用最新的 Sierra API 来实现标签式的窗口呢?
打开 Main.storyboard,选择 Window Controller scene 中的Window,然后打开 Attributes Inspector 并且把 Tabbing Mode 选成 Preferred。

configure for tabbing

就这样更改一点点,你的App就开始使用新的标签风格了。
运行一下, 打开一下新文本,看到他们都放到标签页里面了。

这里写图片描述

当你运行 BabyScript 时,macOS 自动计算了屏幕的大小,窗口的大小,以及窗口的位置,和窗口的实际尺寸。
有两种方法来控制窗口的尺寸和位置,稍后可以学到。

在IB中设置窗口的位置

首先用IB来进行位置的初始设定。

打开 Main.storyboard,选择 Window Controller Scene 下的 Window。然后选 Size Inspector。运行 BabyScript - 或者把他移动到前面 - 就可以看到如下的屏幕:

这里写图片描述

你可以在Initial Position 输入X,Y的数值来设定窗口的初始位置。也可以直接拖动窗口图标里面的灰色块来改变X,Y值。

注:Cocoa视图中,原点时在试图的左下角,所以Y值是正直。iOS中原点在视图左上角。与之相反。

如果你点击灰色窗口边上的红色约束线,你就可以改变macOS设置窗口的方式,注意下拉窗口的内容随着点击在变化。
初始设置为 Proportional Horizontal 和 Proportional Vertical。这表明窗口的位置会根据窗口打开时的尺寸进行设定。现在我们改成:
设定两个下拉框的设置为 Fixed From Left 和 Fixed From Bottom。
设定初始位置 X:200 和 Y:200
运行,注意不管窗口的尺寸如何,新窗口都是在同一个位置出现。

BL aligned window

注:macOS 会记住上一次App启动时窗口的位置。你必须关闭App的窗口,重新编译并运行,才能看到变化。

用程序设定窗口位置

在这个章节你将运用代码来完成和前面用IB完成的同样的工作。这样你就可以在程序运行时来设置窗口的初始位置了,在某些情况下这样做柔性会更大些。
在窗体控制器中修改 windowDidLoad 方法。当 windowDidLoad被调用时,窗口的所有视图都已经从storyboard中加载了,在这里进行的所有的设置都会把storyboard中的设置覆盖掉。
打开 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、y方向都是100的距离:

  1. 获取 NSWindow 和 NSScreen 的实例,可以得到计算用的几何信息。
  2. 获取屏幕可视区域的外框尺寸。
  3. 由于原点是在底边,所以要根据屏幕最大Y值和你设想的窗口高度,以及窗体的上边距计算出来。
  4. 把窗口的原点设置为新计算出的点。

NSScreen 对象的 visibleFrame 属性不包括Dock和菜单条的区域,如果你计算的时候不考虑这些,窗口有可能会被Dock覆盖。
运行一下,窗口的位置和屏幕左上角的距离都是100像素点。

TL aligned window

把 BabyScript 做成一个最轻量级的文字处理器

Cocoa 有一些功能非常优秀的UI控件等着你在App中应用。这里我们会探索一下功能强大、多用途的 NSTextView。但首先你先要了解一下 NSWindow 的内容视图。

内容视图

contentView 是窗口视图树状结构的根节点。他在一个窗口的内部(标题和控件),他的视图用来放置所有的用户界面。他的属性都可以进行调整。了解这一点非常有用,但在本教程中我们不涉及。

Content View

添加文本视图

打开 Main.storyboard ,把内容视图中内容为“Your document contents here”的 textField 控件移除。现在,添加一个text View。

  1. 在storyboard中,打开控件库。
  2. 找到 NSTextView 控件。
  3. 拖动到content view上。
  4. 改变 text view 控件的大小,使他距content view 的边界有20点的距离,利用好蓝色的辅助线。
  5. 在控件清单中,选择 Bordered Scroll View 。你可以看到,text view 是嵌入在其中的。这表明text view 是可以有滚动条的。
  6. 选择 Reset To Suggested Constraints 解决 Auto Layout 的问题。

add and resize text view
set textview constraints

运行,应该看到如下画面:

empty text window

现在窗口上有了一个非常熟悉的文本插入标记在闪烁了。现在可以开始你的宣言了,在里面输入“Hello World”,然后可以用Edit/Copy菜单或Command-C 拷贝,然后在文本框里粘贴几次。
再尝试一下 Edit 和 Format 菜单,可以了解哪些功能还没有开放。这里 Format / Font / Show Fonts 是被禁止的,我们马上来解决。

激活字体面板

在 Main.storyboard 中,选择主菜单中选择 Format 菜单,然后选 Font -> Show Fonts。
在 Connections Inspector 中可以看到这个菜单的响应没有定义,这就是为什么该功能不能实现的原因。看看我们如何建立连接。

这个响应的代码作为 Cocoa 的一部分,Xcode 已经把他导入进来了。我们仅仅需要连接他们就可以了。下面是具体的步骤:

  1. 按下 Ctrl 键 点击 Show Fonts 菜单并拖动到 Application Scene 中的 First Responder。然后释放鼠标。
  2. 一个包涵所有可用 action 清单的带滚动条列表会出现。寻找到 orderFrontFontPanel:。你也可以在键盘输入 orderFrontFontPanel 可以更快的找到这一条。
  3. 回到 Connections Inspector 中,保持 Show Fonts 选中。可以看到现在已经连接到 First Responder 中的 orderFrontFontPanel: 。

    connect font menu to first responder

运行App, 然后输入文字并选中他们。选择 Format / Font / Show Fonts 来打开字体面板(或者Cmd - T)。拖动字体面板右侧的纵向滑动条,可以看到文本的字体大小在实时的变化。

font panel in action

一行代码都没写,我们已经可以调整字体的大小了。为什么会这样?这主要是 NSFontManager 和 NSTextView 类为你完成了大量的工作。
NSFontManger 是负责管理字体系统的类。他实现了orderFrontPanel 方法,所有当响应链把消息传递给他时,他就会展示系统缺省的字体面板。
当你在面板中改变字体属性时,NSFontManger 就把 changeFont 的消息传递给 First Responder。
由于你选择了文本,NSTextView 成为了响应链中的第一个对象,他会执行 changeFont。所以当字体属性更改时,会自动的修改选中的文本。

用富文本来格式化 Text View

为了观察 NSTextView 的 强大功能,从这里下载一些富文本的文字,作为文本窗口中的初始文本。
用TextEdit打开他,选择所有的文字并把他拷贝到剪贴板。然后打开 Main.storyboard 并选择 Text View。打开 Attributes Inspector 并且把拷贝的文字粘贴到文本输入框中。
现在,勾选 Graphics 和 Image Editing 选择框,允许text View中显示图片。

populate and configure text field

运行,可看到如下界面:

window with rich text

你从原文中拷贝的图片消失了!怎么回事?因为 Interface Builder 中用的是文本输入框 - 他不会在storyboard中存储图片。但是你可以在程序运行的时候在文本视图中粘贴或者拖入图片。

编辑图片

当你修改了文本,或者粘贴一幅图片后,试着关闭窗口。警告框会弹出来,选择保存文本。可以看到我们在教程一开始设置的警告弹窗:

save alert

显示缺省的标尺

为了在App一运行时,就在文本框显示标尺。需要 IBOutlet 来连接 text view。打开 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-drag 到text view。一个Outlets列表的窗口弹出,选择text引用。

connect text outlet

运行,所有的文本窗口都显示标尺了。

ruler is automatic

就用了两行代码,我们就借助Cocoa的缺省功能建立了一个迷你版的文本处理程序。
现在休息一下,继续后面的章节。

模式(Modal)窗口

模式窗口是属于需要用户关注的一种窗口。一旦出现,他就屏蔽了所有的events。你用他们来获得用户的关注,保存和打开面板就是一个最好的例子。
共有3种方法来展现一个模式窗口:

  1. 对于一个普通窗口用 NSApplication.runModal(for:)。
  2. 对于一个单页面模式用 NSWindow.beginSheet(_:completionHandler:)。
  3. 通过一个modal的过程。这是一个高级的话题,本教程不涉及。

本程序中的保存报警,就是在窗口最上面显示一个单页模式的窗口。

Sheet Modal Example

对于单页模式窗口就讲这么多,后面我们会学习如何增加一个展示当前文档的单词数和章节数量的窗口。

在场景中增加一个新窗口

打开 Main.storyboard。纵控件库中拖一个Window Controller 到故事板上。这个操作建立了两个场景:Window Controller 和 View Controller。

这里写图片描述

选择新建的Window Controller 中的窗口。用 Size Inspector 把它的高度设置为150,宽度300。

set window size这里写图片描述

保持窗口在选中状态,用 Attributes Inspector 把 Close, Resize 和 Minimize 都设置为未选中状态。然后把窗口标题改为 Word Count。

这里写图片描述

窗口上的关闭按钮会导致一些问题,因为点击了它就会关闭窗口,但我们并没有设置退出模式状态,所以App会保持在一个奇怪的状态。
在标题上有最小化和尺寸按钮比较奇怪,虽然这和Apple的人机界面指导相违背。
现在,选择新建的 View Controller 中的视图,用 Size Inspector 把高设置为150,宽为300。

set view content size

设置文字计数窗口

打开控件库,把4个Label控件拖到contentView中。按图中的样子对齐它们。由于窗口被设置成不能改变大小,因此不必考虑自动排布的事情。

add 4 textfields

选择 Attributes Inspector,改变标签的标题为 Word Count, Paragraph Count, 123456 and 123456,和图示一样。(由于不采用autolayout来调整标签的宽度,因此我们用一个长的数字来保证标签栏在运行时能够有足够的宽度)。把文本的对齐方式都改成右对齐。

这里写图片描述
下一步,拖进来一个Push Button。

drag button to scene

把按钮的标题改成OK。

configure button

创建 Word Count View Controller 类

你将为 Word Count View Controller 创建一个NSViewController的子类。如下:

  1. 选择 File / New / File…, 选择 macOS / Source / Cocoa Class。
  2. 在选项对话框中给类起名叫 WordCountViewController。
  3. 在Subclass下拉框中输入 NSViewController。
  4. 确认不勾选“Also create XIB for user interface”。

add view controller subclass

点击下一步,创建类文件。

打开 Main.storyboard。选择 word count view controller 的代理图标,打开 Identity Inspector。在Class下拉框中选择WordCountViewController。

set custom class

绑定计数标签到ViewController

下一步,我们要使用Cocoa Bindings把计数的值展现到视图控制器中。打开WordCountViewController.swift, 在类的代码中加入:

dynamic var wordCount = 0
dynamic var paragraphCount = 0

dynamic修饰是为了让这两个属性能够适配 Cocoa Bindings。

打开 Main.storyboard 并选择文本计数的数字控件,打开Bindings inspector 并进行如下操作:
1. 点击Value旁的小三角箭头,展开Value项。
2. 下拉框中选择 Word Count View Controller。
3. 勾选Bind To。
4. 在 Model Key Path 输入框中输入 wordCount

这里写图片描述

重复上面的步骤,把段落计数标签也绑定到 paragraphCount 中。

这里写图片描述

注:Cocoa Bindings是超级有用的UI开发技术,如果想学习更多的内容请参考 macOS Cocoa 绑定教程.

最后,给控制器添加一个Storyboard ID。
选择Word Count window中的Window Controller。然后打开 Identity Inspector,并且在Storyboard ID输入框中输入 Word Count Window Controller。

set storyboard id

打开和关闭模式窗口

现在我们已经准备好了故事板中的 文本计数窗口了。现在可以打开它透透气了 :]。
后面的部分我们会添加一些代码来展示和关闭它来。

展示模式窗口

打开 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 控制器的实例。
  2. 获取相关联的控制器的属性中,文本视图控件的storage对象(文字和段落计数) 。由于Cocoa Bindings 的作用这些文本框就能自动展示这些数值了。
  3. 用模式方式展示文本计数窗口。
  4. 当模式状态结束时,关闭文本计数框。注意这行代码在模式状态结束之前不会马上执行。

关闭模式

下一步,添加文本计数窗口关闭的代码,在WordCountViewController.swift中添加:

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

这是一个 IBAction,当用户点击OK按钮时被调用。
在这个方法中,简单关闭了之前启动的模式状态。模式过程必须被显示的关闭使程序返回到正常的操作中。

打开 Main.storyboard。点击OK按钮控件。然后Ctrl-Drag到 Word Count View Controller的代理图标上,释放鼠标,在清单中选择dismissWordCountWindow:

connect OK button to action

添加UI控件来调用模式窗口

仍然在 Main.storyboard 中,选择 Main Menu -> Edit menu,进行如下操作:

  1. 在控件库中拖入一个Menu Item,到Edit menu的底下。
  2. 选择 Attributes Inspector 并设置它的标题为 Word Count。
  3. 在 key equivalent 中输入Command - K,来创建快捷键。

add word count menu

现在,我们在ViewController.swift 中把新建的菜单项和 showWordCountWindow 方法连接起来。

点击 Word Count 菜单项,然后 Ctrl-Drag 到窗口上面的 First Responder 上。选择 showWordCountWindow: 。

connect word count menu to first responder

这里,我们把菜单项连接到 first responder,但不是直接连到ViewController 的 showWordCountWindow。这是因为程序的菜单和窗体控制器在不同的故事板场景中,我们不能直接连接它们。

运行程序,选择 Edit / Word Count (或者按 Cmd-K),文本计数窗口就可以展示出来了。

使用一下word count window。点击OK可以关闭窗口。

这里是最终版的BabyScript.
本教程覆盖了许多其他的话题:
MVC设计模式
怎样创建多窗口的App
macOS App的典型架构
如何用代码和IB设置窗口的位置和尺寸
如何把动作响应从界面传递到到响应链
用模式窗口展现内容

更多的内容可参考 Apple 的 Window Programming GuideMac App Programming Guide

Logo

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

更多推荐