20分钟手把手教你写 macOS 文本编辑器
相较于 iOS 上火热的开发势头,macOS 开发简直就是一片蓝海。让人不禁有些好奇,本是同根生的 macOS 开发究竟是一番怎样的光景?在略微接触之后发现,除了 UIKit 被 AppKit 替换之外,最明显的是 macOS 对待 Window 的态度转变,想想也是,毕竟桌面端应用的效率优势很大一部分就是体现在窗口多开上。然而找了找现有的资料,关于 macOS 开发的实在不多,于是就在学习的过程中翻译一篇国外教程,为社区做点贡献。
本文是 RayWenderlich 上的一篇翻译文,里面带着读者从无到有地构建了一个简单的文本编辑器,内容会涉及到 macOS 上 Window 相关的一些使用基础。翻译里加入了一点个人理解,但是技术部分是忠于原文的,不放心的可以直接到官网上看:
原文链接:Windows and WindowController Tutorial for macOS
博客链接:macOS 下的 Window 和 WindowController
说太多了,来看正文啦!
Window 是一切 macOS 应用的界面载体,它定义了一个专属于某个应用的区域,并作为多任务处理的标识展现给用户。
一切 macOS Apps 都不外乎是下面三种类型之一:
- 单一窗口工具型应用(一个界面就完成所有功能),比如计算器
- 单一窗口图书馆式应用(一个窗口完成所有功能,但这个窗口里的界面可能有许多个),比如照片
- 多窗口的基于文档的应用,比如文本编辑
这篇教程将会涵盖下列知识:
- Windows 和 windowControllers
- 文档(Document)架构
- NSTextView
- 模态窗口
- 菜单栏和菜单项
而看这篇文章的读者们可能需要提前掌握这些知识:
- Swift 3 或更高版本的 Swift 语法
- Xcode 和 Storyboards 的基本操作
- 在 Mac App 上实现一个 Hello world
- 控件的响应链
那么就开始吧
同创建一个计算器 App 不同的是,我们将要创建的是一个基于文档的应用,在创建工程项目的时候,Xcode 会给出提示让你选择应用的类型:
![](https://img.haomeiwen.com/i1311292/aa4eb5311cb35a79.png)
上面的内容可以自由发挥,唯独红框的部分要注意一下:
- Create Document-Based Application 要勾上,此时 Xcode 会为你生成基于文档型应用的示例代码,能省去我们不少工作量
- Document Extension 是告诉 Xcode 我们这个应用要操作的文档的后缀,我这个 Demo 的名字叫 “MyTextEditor”,所以我就取首字母 “mte” 作为后缀了
- 下面是关于数据库和测试的部分,同样是勾了就会有示例代码,但我们这里不需要,所以不勾选它们以排除一些干扰
项目创建好后马上就可以运行了,原始的 MyTextEditor 应该是这个样子的:
![](https://img.haomeiwen.com/i1311292/29460940230660f8.png)
而且它已经具有一些基本功能了,比如你已经可以新建很多个窗口(不过这些窗口是重叠在一起的,你可能要拖动一下才能看到后来的窗口):
![](https://img.haomeiwen.com/i1311292/300503c229f98fd8.png)
文档(Documents)
在继续之前,我们要先来了解一下文档类型应用是怎么工作的。
文档(Document)架构
一个文档对应的是一个 NSDocument
类型的对象,它相当于这个文档的控制器。通过它,我们可以读取文件的内容或往里面写东西,而且它既可以是本地硬盘上的文件,也可以是存在 iCloud 上的。
NSDocument 是一个抽象类,也就是说你需要用一个子类去实现具体功能。在文档架构中还有两个很主要的类:NSWindowController
和 NSDocumentController
,它们作用分别是:
-
NSDocument
:创建和保管文档数据 -
NSWindowController
:管理用来展示文档的窗口 -
NSDocumentController
:管理一个应用中的所有文档对象
![](https://img.haomeiwen.com/i1311292/567398610f6b2c9e.png)
文档操作
还记得创建工程的时候,我们告诉了 Xcode 这个 App 是一个文档型应用吗?聪明的 Xcode 知道了这一点之后,会给我们的应用內建许多文档操作,但一些具体的逻辑还是需要我们继承 NSDocument
去实现。(Xcode 其实已经给了我们一个叫 Document
的子类作为例子了)
打开 Document.swift,可以看到已经有用于文件读写的空方法了(data(ofType:)
和 read(from:ofType:)
)。运行这个 App 时,你可能已经发现顶部菜单栏里很多功能都是已经实现了的,比如新建、打开、保存等等,不过我们这个 Demo 里面不涉及“保存”,所以我们需要删除相关的逻辑,也借此看看 Xcode 帮我们做了些什么。
打开项目里唯一的 Storyboard,然后像下图这样取消菜单项和实际逻辑之间的关联:
![](https://img.haomeiwen.com/i1311292/c11777a48231d19f.png)
把 Open、Save、Save As、Revert to Saved 的关联都干掉,因为这些我们都不会用到。
接下来打开 Document.swift,添加以下代码,我们要在用户尝试保存的时候弹一个提示:
override func save(withDelegate delegate: Any?, didSave didSaveSelector: Selector?, contextInfo: UnsafeMutableRawPointer?) {
let userInfo = [NSLocalizedDescriptionKey: "Sorry, no saving for you, sir! Click \"Don't save\" to quit."]
let error = NSError(domain: NSOSStatusErrorDomain, code: unimpErr, userInfo: userInfo)
NSAlert(error: error).runModal()
}
![](https://img.haomeiwen.com/i1311292/015b1ddb09743395.jpg)
现在重新运行项目,你会发现菜单栏里我们刚才取消关联的选项已经无法选中了:
![](https://img.haomeiwen.com/i1311292/68be2888c4bb4ac8.png)
好了,清除掉了障碍,现在要开始真正的开工了!
窗口位置
首先我们要修复的一个问题是:新建的窗口都是死死盖在原来的窗口上面的。我们会通过继承一个窗口控制器来实现这部分逻辑。
继承一个 NSWindowController
新建一个 NSWindowController
的子类,确保语言选了 Swift,并且不要勾选创建 xib 的那个选项:
![](https://img.haomeiwen.com/i1311292/7f03cc6e100c1f95.png)
然后打开 Storyboard ,将里面的 Window Controller 的 Custom Class 配置为我们刚刚新建的 WindowController
:
![](https://img.haomeiwen.com/i1311292/9fa8a0906b861ce7.png)
然后开始解 Bug!在我们的 WindowController.swift 里面,重写父类的一个初始化方法:
required init?(coder: NSCoder) {
super.init(coder: coder)
shouldCascadeWindows = true
}
运行!
![](https://img.haomeiwen.com/i1311292/12265351ba7aae89.png)
除了第二个窗口不是很听话,后续的窗口都已经会排好队了。
用 Tabs 绕过这个问题
这第二个窗口是怎么回事呢?我们后面将初始化窗口位置的时候再回答,现在我们先用另一种新建窗口的方式去绕过这个问题~(正式开发中千万不能绕开问题啊!)
其实将新建窗口变为新建 Tabs 超级简单,只需要在 Storyboard 里面配置一下就好了:
![](https://img.haomeiwen.com/i1311292/f926c2607b9cc3bf.png)
重新运行,这次当你新建窗口时,这些窗口就会以一个个 Tab 的形式出现了:
![](https://img.haomeiwen.com/i1311292/a7ae0afebc0836b7.png)
在 IB 中设置窗口的位置
回到我们绕过的问题本身:窗口的位置。
在 Storyboard 里,当 Window Controller 里的 Window 被选中时,可以在右侧的 Size Inspector 中看到对窗口位置和大小的配置,其中 “Initial Position” 里设置的就是窗口的初始化位置:
![](https://img.haomeiwen.com/i1311292/fe4fd31325601cd7.png)
在 macOS 中,坐标轴的原点在左下角,横轴是 X 轴,纵轴是 Y 轴,跟 iPhone 上的坐标系要区别开来。
你也可以直接拖动那个小界面里的灰色窗口来设置初始位置。注意小界面下面的两个下拉框的内容变化:
- Proportional Horizontal/Vertical:初始化位置会根据屏幕的大小按比例来设置
- Fixed From Left/Right/Top/Bottom:写死一个固定的初始化位置
在这个 Demo 里,我们会让窗口固定在左下角 (200,200) 的位置出现:
- 设置下拉框内容为 Fixed From Left 和 Fixed From Bottom
- 将初始值设置为 X:200 和 Y:200
macOS 会记录每次应用启动后的窗口位置,所以为了看到这里的设置引起的变化,你要先把所有的窗口的关掉,再编译运行项目。
用代码设置窗口的位置
这一小节其实就是把上一节做的事情用代码重新做一遍,以防你们以为 Swift 程序员不会写代码。
用代码设置还是有它的好处的,比如你可以在应用运行的过程中决定窗口要在哪里出现。
打开 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))
}
}
- 取到窗口和屏幕对象(还记得 Swift 是怎么安全的获取 Optional 对象的值吗?就是这样~)
- 取得屏幕的可视范围
- 计算 Y 坐标的值(别忘了坐标轴原点是左下角,这里计算的是底边的高度)
- 更新窗口的坐标
visibleFrame
这个属性不包含 Dock 和菜单栏的范围,如果不用这个参数来计算的话,可能会出现窗口被这两个控件挡住的情况。
重新编译运行,窗口应该就会出现在距离屏幕(不算菜单栏高度)左上角 (100,100) 的位置了。
打造一个迷你文字编辑器
Cocoa 自带了一些很神奇的 UI 和功能,就等着你把它们用起来了。接下来我们会接触到多才多艺的 NSTextView
,但首先我们要先了解一下 NSWindow
自带的 content view。
The Content View
contentView
是一个窗口中所有视图层级中的根视图,NSWindow
里的 content view 由它带着的 ViewController
来体现。
![](https://img.haomeiwen.com/i1311292/6d547237dd957a55.png)
喏,那个蓝的发亮的就是 contentView
添加 Text View
打开 Main.storyboard,从右边栏拖一个 NSTextView
到上面说到的 contentView
里面去,把它调节到一个舒服的大小,然后点击右下角的小三角形,选择 Reset to Suggested Constraints。
![](https://img.haomeiwen.com/i1311292/bc3a15e069b0da5a.png)
这里我们让系统自动帮我们布局这个视图,省点事儿。
编译运行,你应该能看到我们简陋的文字编辑器了。尝试拉伸一下窗口,Text View 会跟着窗口一起变大变小,这是 AutoLayout 的功劳,这个 Demo 里面不会讲咯。
![](https://img.haomeiwen.com/i1311292/efda174caed57877.png)
好好探索一下我们的第一个文字编辑器吧,你会发现 Format - Font - Show Font 功能并不可用,我们接下来就解决这个问题。
打开字体设置框
在 Main.storyboard 里的 Main Menu 上找到 Show Font 这个菜单项,按住 Ctrl 把 Show Font 拖到 First Responder 上,然后在随之出现的弹框中找到 orderFrontFontPanel:
并选择它:
![](https://img.haomeiwen.com/i1311292/38f06859098a9163.png)
然后重新编译运行项目,Show Font 功能就被打开啦!
在不写一行代码的前提下,你实现了改变字体的功能,这是怎么做到的呢?其实是 NSFontManager
和 NSTextView
把所有的脏活累活都给干掉了。
-
NSFontManager
是一个管理字体变化系统的类,它就是刚刚弹框中orderFrontFontPanel:
方法的实际实现的地方,我们刚才的操作是把响应链上的信息发送(forward)给了它,然后它负责展示系统默认的字体设置框 - 当我们在字体设置框中对字体进行操作,
NSFontManager
会发送一个changeFont
消息给当前的第一响应对象(First Responder) -
NSTextView
实现了changeFont
方法,当我们操作它里面的文字时(比如选中某个单词),它就自动成为了第一响应对象,然后一切就联系起来了
富文本
要看到 NSTextView
的真正实力,你可以先从这里下载一段富文本,然后把它设置为 NSTextView
的默认文字,编译运行!
![](https://img.haomeiwen.com/i1311292/c96d5571e1e10667.png)
嗯?文本里的图片哪里去了呢?
因为 IB 里面的设置默认文字的地方不能保存图片,所以图片就被丢弃掉了。不过我们还是可以通过复制粘贴或者拖拽的方式,把图片添加到 Text View 里面去。
玩耍过后,在你想要关闭这个窗口的时候,你会发现我们在文章开头设置的弹窗生效了!(我们在前面禁用了保存功能)
把帅气的刻度尺显示出来
打开 ViewController.swift,把 viewDidLoad
附近的代码替换成下面这段:
@IBOutlet var text: NSTextView!
override func viewDidLoad() {
super.viewDidLoad()
text.toggleRuler(nil)
}
然后回到 Main.storyboard,把我们手写的 text
和 IB 里的 Text View 关联起来:按住 Ctrl,把代表 ViewController 的蓝色小圆圈拖向 Text View,选择 text。
![](https://img.haomeiwen.com/i1311292/e2749b7c4aa9e851.png)
再跑一遍,看起来是不是高大上了一些:
![](https://img.haomeiwen.com/i1311292/bab4ad5f58d3806f.png)
模态窗口
模态窗口是 Window 世界中最霸道的存在,一旦出现,它会吃掉所有的事件,知道它们被主动 dismiss 掉。保存和打开文件的弹窗就是模态窗口的范例,总的来说,有三种方式展示模态窗口:
- 当做一个常规窗口使用,通过调用
NSApplication.runModal(for:)
显示 - 当做一个表单(Sheet)用,通过调用
NSWindow.beginSheet(_:completionHandler:)
显示 - 通过一个模态会话来展示,这是一个高级用法,这里不讲
前面尝试关闭窗口时弹出的保存提醒框就是一个表单型的模态窗口:
![](https://img.haomeiwen.com/i1311292/a09a322878100c66.png)
嘛,这玩意儿就这样,我们在这里也不会接着深入了。但是我们会看看一个分离式的模态窗口怎么出现的。
添加一个新 Window
打开 Main.storyboard,从右边栏拖一个 Window Controller 到画面上,这会生成两个东西,一个 Window Controller Scene 和一个 View Controller Scene:
![](https://img.haomeiwen.com/i1311292/048d7907b4745f9d.png)
选中 Window Controller Scene 下面的 Window,把它的 Content Size 改为宽300高150,顺带也把 View Controller Scene 下面的 view 也做这样的修改:
![](https://img.haomeiwen.com/i1311292/b53bbe38c228defb.png)
![](https://img.haomeiwen.com/i1311292/c2462dd29f4fe189.png)
然后我们要禁用这个窗口左上角的那些控制按钮,让用户必须沿着我们设定的交互走:
![](https://img.haomeiwen.com/i1311292/7f1a69cf17bbda50.png)
新窗口里的界面布局
就像上文对 Text View 的布局那样,把我们的新窗口也进行一番鼓捣,留给大家自由发挥啦。Demo 里使用了4个 Label 和一个 Button,最终长这个样子:
![](https://img.haomeiwen.com/i1311292/9e3a13b60820a609.png)
创建对应的 View Controller 类
为了控制这个窗口的内容,我们要从 NSViewController
继承一个子类:
![](https://img.haomeiwen.com/i1311292/497fef275cefb0de.png)
接着在 IB 里关联一下界面和这个子类:
![](https://img.haomeiwen.com/i1311292/a0f72559596ba21e.png)
界面与数据绑定
接下来,我们用 macOS 上的一个神奇功能,实现界面与数据的直接绑定。
打开 WordCountViewController.swift,给这个类添加两个属性:
dynamic var wordCount = 0
dynamic var paragraphCount = 0
dynamic
关键字使得这两个属性可以被用作 Cocoa Bindings 的绑定对象。
回到我们的 Main.storyboard,选中上面添加的 “Word Count” 后面的那个 “0”,然后在右边栏进行设置,将这个 Text View 的内容跟 wordCount
属性的值进行绑定:
![](https://img.haomeiwen.com/i1311292/f11310d91f7433b3.png)
对 Paragraph Count 后面跟着的 Text View 也进行相同的操作,但是这次要绑定到 paragrahCount
属性上去。
Cocoa Bindings 是一个很有用的 UI 技巧,在文章《Cocoa Bindings on macOS》中会有更详细的介绍。(如果有机会,这里也会尝试翻译一下这篇文章)
最后,给我们的 WordCountViewController 所属的 Window 加上一个 Storyboard ID,方便我们后续在代码里面找到它:
![](https://img.haomeiwen.com/i1311292/85ea71a5008cda79.png)
这个 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()
}
}
分解动作:
- 通过刚刚设置的 Storyboard ID 初始化一个 WordCountWindowController(注意是 WindowController,不是我们自己创建的 ViewController)
- 从我们的内容里取得字数和段落数,设置到 WordCountViewController 的属性里
- 显示一个模态窗口
- 关闭一个模态窗口
你可能会觉得奇怪,我们在显示之后立马就把它给关闭了,那还看什么?
事实上,当一个窗口以 runModel(for:)
的方式显示出来之后,应用会进入一个模态过程。这个动作相当于启动了一个阻塞的线程,启动方法调用之后的所有代码都会被阻塞住,只有在调用了 stopModel
停止 模态过程 后,代码才会继续执行。
隐藏
关闭模态窗口的代码需要由模态窗口本身去调用,因为其他地方都被阻塞住了呀。在 WordCountViewController.swift 里加上这段代码:
@IBAction func dismissWordCountWindow(_ sender: NSButton) {
let application = NSApplication.shared()
application.stopModal()
}
这里只是让应用退出了 模态过程,真正关闭窗口的代码我们已经在上面写好了(就是 close
那个方法)
好咯,这个按钮事件怎么关联到那个 “OK” 按钮呢?留给你们自己去发现~
触发窗口的显示
显示的代码写好了,但是在哪里地方调用它好呢?不如在菜单里加一个选项吧。
增加菜单项跟之前添加视图没有扫描区别,菜单项的名字叫 “Menu Item”,直接拖到菜单里面去就好了,然后在右边栏里进行一下设置:
![](https://img.haomeiwen.com/i1311292/c48dc95d16213dd5.png)
Key Equivalent 设置的是快捷键
然后 Ctrl + 拖动,把这个菜单项跟我们写的方法联系起来:
![](https://img.haomeiwen.com/i1311292/cd3758a1d9df3b4f.png)
完成了!编译运行!我们的统计功能就上线了!
接下来呢?
不知不觉,你其实已经学到蛮多东西了,比方说:
- MVC 设计模式的一点应用
- 创建一个多窗口 app
- macOS app 的常见结构
- 通过 IB 和代码改变窗口的布局
- 将 UI 中的事件传递到响应链上
- 用模态窗口来展示附加信息
- …
不过这都只是 macOS app 开发的冰山一角。
如果你对窗口感兴趣,可以看看苹果的官方文章 Window Programming Guide。如果想要继续深入研究 Mac 应用开发,则推荐 Mac App Programming Guide。
这个 Demo 完整的代码在这里。这是原文里的链接,连保存的功能也实现好了,虽然没什么注释,但是代码很好懂。