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 會給出提示讓你選擇應用的類型:

Create Project

上面的內容可以自由發揮,唯獨紅框的部分要注意一下:

  • Create Document-Based Application 要勾上,此時 Xcode 會為你生成基于文檔型應用的示例代碼,能省去我們不少工作量
  • Document Extension 是告訴 Xcode 我們這個應用要操作的文檔的后綴,我這個 Demo 的名字叫 “MyTextEditor”,所以我就取首字母 “mte” 作為后綴了
  • 下面是關于數據庫和測試的部分,同樣是勾了就會有示例代碼,但我們這里不需要,所以不勾選它們以排除一些干擾

項目創建好后馬上就可以運行了,原始的 MyTextEditor 應該是這個樣子的:

Empty Window

而且它已經具有一些基本功能了,比如你已經可以新建很多個窗口(不過這些窗口是重疊在一起的,你可能要拖動一下才能看到后來的窗口):

New Windows

文檔(Documents)

在繼續之前,我們要先來了解一下文檔類型應用是怎么工作的。

文檔(Document)架構

一個文檔對應的是一個 NSDocument 類型的對象,它相當于這個文檔的控制器。通過它,我們可以讀取文件的內容或往里面寫東西,而且它既可以是本地硬盤上的文件,也可以是存在 iCloud 上的。

NSDocument 是一個抽象類,也就是說你需要用一個子類去實現具體功能。在文檔架構中還有兩個很主要的類:NSWindowControllerNSDocumentController,它們作用分別是:

  • NSDocument:創建和保管文檔數據
  • NSWindowController:管理用來展示文檔的窗口
  • NSDocumentController:管理一個應用中的所有文檔對象
Document Architecture

文檔操作

還記得創建工程的時候,我們告訴了 Xcode 這個 App 是一個文檔型應用嗎?聰明的 Xcode 知道了這一點之后,會給我們的應用內建許多文檔操作,但一些具體的邏輯還是需要我們繼承 NSDocument 去實現。(Xcode 其實已經給了我們一個叫 Document 的子類作為例子了)

打開 Document.swift,可以看到已經有用于文件讀寫的空方法了(data(ofType:)read(from:ofType:))。運行這個 App 時,你可能已經發現頂部菜單欄里很多功能都是已經實現了的,比如新建、打開、保存等等,不過我們這個 Demo 里面不涉及“保存”,所以我們需要刪除相關的邏輯,也借此看看 Xcode 幫我們做了些什么。

打開項目里唯一的 Storyboard,然后像下圖這樣取消菜單項和實際邏輯之間的關聯:

Target-Action

OpenSaveSave AsRevert 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()
    }
No saving for you

現在重新運行項目,你會發現菜單欄里我們剛才取消關聯的選項已經無法選中了:

Menu Disabled

好了,清除掉了障礙,現在要開始真正的開工了!

窗口位置

首先我們要修復的一個問題是:新建的窗口都是死死蓋在原來的窗口上面的。我們會通過繼承一個窗口控制器來實現這部分邏輯。

繼承一個 NSWindowController

新建一個 NSWindowController 的子類,確保語言選了 Swift,并且不要勾選創建 xib 的那個選項:

New WindowController

然后打開 Storyboard ,將里面的 Window Controller 的 Custom Class 配置為我們剛剛新建的 WindowController

Set Custom Class

然后開始解 Bug!在我們的 WindowController.swift 里面,重寫父類的一個初始化方法:

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

運行!

New Windows

除了第二個窗口不是很聽話,后續的窗口都已經會排好隊了。

用 Tabs 繞過這個問題

這第二個窗口是怎么回事呢?我們后面將初始化窗口位置的時候再回答,現在我們先用另一種新建窗口的方式去繞過這個問題~(正式開發中千萬不能繞開問題啊!)

其實將新建窗口變為新建 Tabs 超級簡單,只需要在 Storyboard 里面配置一下就好了:

Tabbing Mode

重新運行,這次當你新建窗口時,這些窗口就會以一個個 Tab 的形式出現了:

New Windows in Tabbing Mode

在 IB 中設置窗口的位置

回到我們繞過的問題本身:窗口的位置。

在 Storyboard 里,當 Window Controller 里的 Window 被選中時,可以在右側的 Size Inspector 中看到對窗口位置和大小的配置,其中 “Initial Position” 里設置的就是窗口的初始化位置:

Initial Position

在 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))
  }
}
  1. 取到窗口和屏幕對象(還記得 Swift 是怎么安全的獲取 Optional 對象的值嗎?就是這樣~)
  2. 取得屏幕的可視范圍
  3. 計算 Y 坐標的值(別忘了坐標軸原點是左下角,這里計算的是底邊的高度)
  4. 更新窗口的坐標

visibleFrame 這個屬性不包含 Dock 和菜單欄的范圍,如果不用這個參數來計算的話,可能會出現窗口被這兩個控件擋住的情況。

重新編譯運行,窗口應該就會出現在距離屏幕(不算菜單欄高度)左上角 (100,100) 的位置了。

打造一個迷你文字編輯器

Cocoa 自帶了一些很神奇的 UI 和功能,就等著你把它們用起來了。接下來我們會接觸到多才多藝的 NSTextView,但首先我們要先了解一下 NSWindow 自帶的 content view。

The Content View

contentView 是一個窗口中所有視圖層級中的根視圖,NSWindow 里的 content view 由它帶著的 ViewController 來體現。

Content View

喏,那個藍的發亮的就是 contentView

添加 Text View

打開 Main.storyboard,從右邊欄拖一個 NSTextView 到上面說到的 contentView 里面去,把它調節到一個舒服的大小,然后點擊右下角的小三角形,選擇 Reset to Suggested Constraints

AutoLayout

這里我們讓系統自動幫我們布局這個視圖,省點事兒。

編譯運行,你應該能看到我們簡陋的文字編輯器了。嘗試拉伸一下窗口,Text View 會跟著窗口一起變大變小,這是 AutoLayout 的功勞,這個 Demo 里面不會講咯。

Mini Text Editor

好好探索一下我們的第一個文字編輯器吧,你會發現 Format - Font - Show Font 功能并不可用,我們接下來就解決這個問題。

打開字體設置框

Main.storyboard 里的 Main Menu 上找到 Show Font 這個菜單項,按住 Ctrl 把 Show Font 拖到 First Responder 上,然后在隨之出現的彈框中找到 orderFrontFontPanel: 并選擇它:

Show Fonts

然后重新編譯運行項目,Show Font 功能就被打開啦!

在不寫一行代碼的前提下,你實現了改變字體的功能,這是怎么做到的呢?其實是 NSFontManagerNSTextView 把所有的臟活累活都給干掉了。

  • NSFontManager 是一個管理字體變化系統的類,它就是剛剛彈框中 orderFrontFontPanel: 方法的實際實現的地方,我們剛才的操作是把響應鏈上的信息發送(forward)給了它,然后它負責展示系統默認的字體設置框
  • 當我們在字體設置框中對字體進行操作,NSFontManager 會發送一個 changeFont 消息給當前的第一響應對象(First Responder)
  • NSTextView 實現了 changeFont 方法,當我們操作它里面的文字時(比如選中某個單詞),它就自動成為了第一響應對象,然后一切就聯系起來了

富文本

要看到 NSTextView 的真正實力,你可以先從這里下載一段富文本,然后把它設置為 NSTextView 的默認文字,編譯運行!

Rich Text

嗯?文本里的圖片哪里去了呢?

因為 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。

Connect Outlets

再跑一遍,看起來是不是高大上了一些:

Show Ruler

模態窗口

模態窗口是 Window 世界中最霸道的存在,一旦出現,它會吃掉所有的事件,知道它們被主動 dismiss 掉。保存和打開文件的彈窗就是模態窗口的范例,總的來說,有三種方式展示模態窗口:

  1. 當做一個常規窗口使用,通過調用 NSApplication.runModal(for:) 顯示
  2. 當做一個表單(Sheet)用,通過調用 NSWindow.beginSheet(_:completionHandler:) 顯示
  3. 通過一個模態會話來展示,這是一個高級用法,這里不講

前面嘗試關閉窗口時彈出的保存提醒框就是一個表單型的模態窗口:

Save Window

嘛,這玩意兒就這樣,我們在這里也不會接著深入了。但是我們會看看一個分離式的模態窗口怎么出現的。

添加一個新 Window

打開 Main.storyboard,從右邊欄拖一個 Window Controller 到畫面上,這會生成兩個東西,一個 Window Controller Scene 和一個 View Controller Scene:

New Window with IB

選中 Window Controller Scene 下面的 Window,把它的 Content Size 改為寬300高150,順帶也把 View Controller Scene 下面的 view 也做這樣的修改:

Set Content Size
Set View Size

然后我們要禁用這個窗口左上角的那些控制按鈕,讓用戶必須沿著我們設定的交互走:

Window Controls

新窗口里的界面布局

就像上文對 Text View 的布局那樣,把我們的新窗口也進行一番鼓搗,留給大家自由發揮啦。Demo 里使用了4個 Label 和一個 Button,最終長這個樣子:

WordCountWindow UI

創建對應的 View Controller 類

為了控制這個窗口的內容,我們要從 NSViewController 繼承一個子類:

New WordCountViewController

接著在 IB 里關聯一下界面和這個子類:

Set Custom Class for WordCountViewController

界面與數據綁定

接下來,我們用 macOS 上的一個神奇功能,實現界面與數據的直接綁定。

打開 WordCountViewController.swift,給這個類添加兩個屬性:

dynamic var wordCount = 0
dynamic var paragraphCount = 0

dynamic 關鍵字使得這兩個屬性可以被用作 Cocoa Bindings 的綁定對象。

回到我們的 Main.storyboard,選中上面添加的 “Word Count” 后面的那個 “0”,然后在右邊欄進行設置,將這個 Text View 的內容跟 wordCount 屬性的值進行綁定:

Cocoa Bindings

Paragraph Count 后面跟著的 Text View 也進行相同的操作,但是這次要綁定到 paragrahCount 屬性上去。

Cocoa Bindings 是一個很有用的 UI 技巧,在文章《Cocoa Bindings on macOS》中會有更詳細的介紹。(如果有機會,這里也會嘗試翻譯一下這篇文章)

最后,給我們的 WordCountViewController 所屬的 Window 加上一個 Storyboard ID,方便我們后續在代碼里面找到它:

Setting Storyboard ID

這個 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 初始化一個 WordCountWindowController(注意是 WindowController,不是我們自己創建的 ViewController
  2. 從我們的內容里取得字數和段落數,設置到 WordCountViewController 的屬性里
  3. 顯示一個模態窗口
  4. 關閉一個模態窗口

你可能會覺得奇怪,我們在顯示之后立馬就把它給關閉了,那還看什么?

事實上,當一個窗口以 runModel(for:) 的方式顯示出來之后,應用會進入一個模態過程。這個動作相當于啟動了一個阻塞的線程,啟動方法調用之后的所有代碼都會被阻塞住,只有在調用了 stopModel 停止 模態過程 后,代碼才會繼續執行。

隱藏

關閉模態窗口的代碼需要由模態窗口本身去調用,因為其他地方都被阻塞住了呀。在 WordCountViewController.swift 里加上這段代碼:

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

這里只是讓應用退出了 模態過程,真正關閉窗口的代碼我們已經在上面寫好了(就是 close 那個方法)

好咯,這個按鈕事件怎么關聯到那個 “OK” 按鈕呢?留給你們自己去發現~

觸發窗口的顯示

顯示的代碼寫好了,但是在哪里地方調用它好呢?不如在菜單里加一個選項吧。

增加菜單項跟之前添加視圖沒有掃描區別,菜單項的名字叫 “Menu Item”,直接拖到菜單里面去就好了,然后在右邊欄里進行一下設置:

Add Menu Item

Key Equivalent 設置的是快捷鍵

然后 Ctrl + 拖動,把這個菜單項跟我們寫的方法聯系起來:

Menu Action

完成了!編譯運行!我們的統計功能就上線了!

接下來呢?

不知不覺,你其實已經學到蠻多東西了,比方說:

  • MVC 設計模式的一點應用
  • 創建一個多窗口 app
  • macOS app 的常見結構
  • 通過 IB 和代碼改變窗口的布局
  • 將 UI 中的事件傳遞到響應鏈上
  • 用模態窗口來展示附加信息

不過這都只是 macOS app 開發的冰山一角。
如果你對窗口感興趣,可以看看蘋果的官方文章 Window Programming Guide。如果想要繼續深入研究 Mac 應用開發,則推薦 Mac App Programming Guide

這個 Demo 完整的代碼在這里。這是原文里的鏈接,連保存的功能也實現好了,雖然沒什么注釋,但是代碼很好懂。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容