本文帶大家一起簡單學習如何開發一個基於文檔的 Cocoa 應用(document based Cocoa App),了解一下窗口相關的部分設置,並學習如何使用模態窗口(Modal Windows) 以及 macOS Sierra 支持的標籤窗口(tabbed interface)。

開發平台

  • macOS 10.14.4
  • Swift 5
  • xcode 10.2

概述

所有 macOS 程序要呈現用戶界面都是以 Windows 作為容器的,當然除了純粹的命令行工具和菜單欄小工具。Windows 定義了 App 在屏幕中的活動區域,在這個區域內允許用戶進行易於理解的多任務交互操作。macOS 應用最終可分為以下幾種:

  • 單窗口的工具,比如計算器
  • 單窗口的 library-style 應用,比如照片應用(Photos.app)
  • 基於文檔的多窗口應用,比如文本編輯應用(TextEdit.app)

不管屬於哪一類的應用,幾乎每一個 macOS 應用都是使用 MVC (Model-View-Controller) 構建的,這是 macOS 開發中核心的開發模式。

Cocoa 應用中,一個窗口是 NSWindow 類的一個實例(window 就是 view 的容器),其與控制器緊密配合工作,而控制器是 NSWIndowController 的一個實例。在一個設計良好的應用中會發現通常窗口和控制器是一一對應的,而 MVC 模式中的第三個組成部分——模型(model)的使用會根據應用的類型和設計而不同。

在本文中我們會創建一個有點像 TextEdit 的應用,是 document based 的,我管它叫 5kmEditor,這個名字隨便,只要您喜歡啥都行!在我們開發過程中,將會涉及到以下內容:

  • 窗口 和 窗口控制器
  • document 架構
  • NSTextView
  • 模態窗口
  • 菜單欄和菜單項

搞起

啟動 Xcode,按快捷鍵 Shift + Command + n ,新建工程,選擇 macOS -> Cocoa App,點擊 Next:

新出現的窗口中,勾選 Create Document-Based Application,應用的名稱隨意取,比如 5kmEditor,然後 Document Extention 這一項指定文件的擴展名,其決定以後應用保存文件的擴展名,比如十里指定的是 5km,可以不勾選包含測試,點擊 Next

選擇合適的目錄,點擊 Create 創建即可,創建成功後編譯運行,你會看到類似下面的窗口:

此時,按快捷鍵 Command + n 或者菜單欄點擊 File -> New,可以創建新的窗口如下:

Document-Based 應用如何工作

上面也看到了這類應用的樣子,下面花幾分鐘一起看一下 Document-Based 應用如何工作的。

Document 的結構

一個 Document 其實就是 NSDocument 的一個實例對象,文檔模型 NSDocument 作為模型保存了文檔的數據,同時負責文檔窗口控制器 NSWindowController 的創建管理,它管理文檔數據,負責文檔打開時數據讀取的管理和文檔對象管理的數據保存到文件的處理,而文件有可能是在硬碟也有可能在 iCloud,均支持。

創建關聯的 NSWindowController 負責展示文檔的內容,內容視圖最終相應處理用戶對文檔操作的各種交互事件。

NSDocumentController 是一個單例對象,主要負責文檔模型 NSDocument 的管理工作,維護系統中所有的文檔模型 NSDocument 列表,控制多個文檔窗口的激活切換,同時跟蹤當前活動文檔對象,最終結構圖如下:

禁用 Document 的保存和打開

Document-Based 應用的 Documnent 架構支持文件的保存和打開,但是需要定義 Document 類中自己具體實現,本文暫不涉及這部分內容,所以先禁用 Document 的保存和打開。

打開文件 Document.swift,會發現有兩個函數是空的:

  • data(ofType:): 用於寫文件
  • read(from:ofType:): 用於讀取文件

同時還有一個函數是 autosavesInPlace()->Bool,更改返回值為 false:

override class var autosavesInPlace: Bool {
return false
}

這樣我們首先禁用了自動保存功能,下面我們需要禁用菜單欄 File 中的保存和打開菜單項。在這之前,我們運行程序,點擊 File -> Open ,竟然會彈出打開文件的窗口,很神奇,我們並沒有實現打開呀,為什麼會出現文件打開窗口呢?

其實是因為 Open 的菜單項綁定了具體的 Action,Action中實現了這些,所以我們只需要斷開菜單項與 Action 的鏈接就可以禁用掉菜單項,視覺上的表現就是菜單欄相應菜單項會變灰色不可用。

打開 Main.storyboard ,找到 Application Scene,點擊菜單欄中 File,選擇其中的 Open,右擊會看到所有的連接,點擊 Sent Action 中連接右側的 x 號,即可斷開連接:

同樣的操作,分別將 Save...Save As...Reverrt to Saved 與相應的 Action 斷開連接。

然後刪除 Open Recent 菜單項,最後我們重寫一下 save(withDelegate:didSave:contexInfo:) 方法,後面我們會用到,主要是添加一個錯誤???提醒窗口,打開 Document.swift 文件,在 Document 類中添加重寫方法如下:

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

運行程序,此時會看到菜單欄的相應菜單項已經禁用:

窗口顯示

上面新建文件的時候,我們發現新建文檔的窗口完全覆蓋了之前的文檔窗口,然而這不是我們想要的結果,本節就聊一下怎麼合理布局窗口的位置。進行改造前,我們需要新建一個 NSWindowController 的子類,然後添加相應的代碼實現我們預期的功能。

新建 NSWindowController 的子類

工程導航欄選擇 5kmEditor,按下快捷鍵 Command + n,就會彈出新建文件的導航窗口,選擇 macOS -> Source -> Cocoa Class,點擊 Next

取名為 WindowController,選擇繼承自 NSWindowController,不要勾選 Also Create XIB file for user interface,語言選擇 Swift ,點擊 Next,之後默認然後點擊 Create 即可創建。

下一步需要確保 storyboard 中的 window 的控制器是我們剛定義的 WindowController 的實例,打開 Main.storyboard 點擊 Window Controller Scene 中的 Window Controller,按快捷鍵 Option + Command + 3 ,在右側顯示的 Identity Inspector 中配置 Custom Class 為 WindowController,也就是剛剛創建的類:

層疊窗口

現在我們可以使新建的窗口層疊顯示而不是覆蓋顯示,打開新建的 WindowController.swift 文件,添加以下代碼:

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

只需要設置 shouldCascadeWindows 為 true 就可以實現層疊效果,運行程序測試一下:

以標籤頁顯示

層疊效果很不錯,但我們可以嘗試一下其它的方式,比如從 macOS Sierra 開始新增加的 tabbed Windows,簡單說就是新建的窗口以標籤頁顯示。

打開 Main.storyboard,選中 Window Controller scene 下的 Window ,然後打開 Inspector 欄的 Attributes Inspector (可以按快捷鍵 Option + Command + 4),找到 Tabbing Mode,更改它的值為 Preferred:

運行程序,然後按快捷鍵 Command + n 新建窗口,可以看到新的窗口以標籤頁的方式顯示了:

當我們運行程序的時候,macOS 會根據當前屏幕大小和應用請求窗口的大來決定應用窗口的顯示位置和實際的大小,下面我們將學習兩種方式控制應用窗口的顯示位置和實際大小。

使用 Interface Builder 設置窗口顯示位置

首先我們需要先使用 Interface Builder 設置窗口的初始位置。

打開 Main.storyboard,選中 Window Controller scene 下的 Window ,然後打開 Inspector 欄的 Size Inspector (可以按快捷鍵 Option + Command + 5),找到 Initial Position,運行程序後的窗口就是按照這個設置初始位置的:

其中 x 代表窗口到屏幕左邊緣的距離,y 代表窗口到屏幕底邊的距離,單位是 px,在 macOS 中應用的坐標原點在左下角,這與 iOS 中是不同的(iOS 使用的是 flipped 坐標系,其原點在左上角)。

我們可以點擊上面 Size Inspector 中的窗口位置預覽圖中的紅色 constrains,這會決定 macOS 顯示應用窗口位置的設定,點擊紅色 Constrains 可以打開或關閉相關限制,同時會看到下面兩個下拉框得值會改變,比如這裡:

  • 取消上邊和右邊的紅色限制,此時會看到下面兩個下拉框的值分別變成 Fixed From LeftFixed From Bottom
  • 設置初始的位置:x -> 200, y -> 200

此時重新編譯運行應用,你會發現不管屏幕多大,只要尺寸允許範圍內,應用的窗口會顯示在離屏幕左邊和底邊均為 200 px 的位置。

??: macOS 會記住 app 的窗口顯示位置,所以需要先把應用完全退出,然後再編譯運行就能看到修改的效果!

代碼實現對窗口顯示位置的設置

代碼實現的話,需要在 window 載入之後進行設置,在 WindowsController 中重寫的 windowDidLoad 方法中添加相關代碼。

這次我們來點特別的,我們設置窗口顯示在離屏幕頂邊和左邊均為 150px 的位置,打開 WindowController.swift 文件,在 WindowController 類中修改 windowDidLoad 方法內容如下:

override func windowDidLoad() {
super.windowDidLoad()

if let window = window, let screen = window.screen {
let offsetFromLeftOfScreen = CGFloat(150)
let offsetFromTopOFScreen = CGFloat(150)
let screenFrame = screen.visibleFrame
let offsetFromBottomOfScreen = screenFrame.maxY - window.frame.height - offsetFromTopOFScreen
window.setFrameOrigin(CGPoint(x: offsetFromLeftOfScreen, y: offsetFromBottomOfScreen))
}
}

上面代碼主要完成以下工作:

  • 獲取需要用到的 NSWindowNSScreen 的實例
  • 得到 screen 的 visibleFrame
  • 通過離頂邊的距離計算得到離底邊距離
  • 設置 window 的遠點坐標為 (offsetFromLeftOfScreen, offsetFromBottomOfScreen)

選中之前顯示的應用窗口,Command + q 完全退出應用,然後回到 Xcode 編譯運行應用,會看到應用的窗口如期顯示在指定的位置:

變身超 mini 富文本處理工具

Cocoa 有很多可以添加到 window 中的牛??的功能性 UI 控制項,在本節我們將會用到 NSTextView,在這之前,我們需要了解 NSWindow 的 content view。

content view

contentView 位於 window 中視圖層次的根級,在這個視圖中我們可以放置所有界面元素。另外,我們還能替換默認的 contentView 為我們自定義的視圖,在這裡我們就不做相關操作了,以後我們可能會用到!

添加 Text View

打開 Main.storyboard 文件,找到 View Contorller Scene 下的 View Controller,其下的 View 中有個控價 Your document contents here ,將其刪除,然後我們添加 Text View:

  • 按快捷鍵 Shift + Command + l 打開 Object Library
  • 搜索 Text View
  • Rich Document Content Text View 拖入 Content View 中
  • 調整 Rich Document Content Text View 的大小和位置,最終使其四邊分別與 contentView 的邊緣貼齊
  • 選中剛添加的 Text View 控制項,然後點擊底邊的 Resolve Auto Layout Issues 按鈕,選中 Reset To Suggested Constrains
  • 添加限制之後的樣子如下:

編譯運行,可以看到剛添加的 Text View 了:

在窗口的 Text View 中可以添加進行文本編輯了,也支持常用的快捷鍵,比如複製、粘貼、剪切、撤銷、重做等。窗口中也出現了一組工具欄,支持字體設置、簡單的段落設置等,同時菜單欄的 Format 的菜單項功能也是可用的,還支持查找替換。這一小節我們沒有添加任何代碼,就完成了一個簡單的富文本編輯工具了,是不是炒??煎??!

撤銷和重做

在窗口中添加部分文本,已經可以完成基本的富文本編輯功能了,但是此時還不支持撤銷和重做,我們需要添加支持。

  • 打開 Main.storyboard 文件,依次找到 View Controller -> View -> Scroll View - Text View -> Clip View -> Text View,選中 Text View
  • 按下快捷鍵 Option + Command + 4 打開 Attribute Inspector,勾選 Undo 複選框

此時運行程序,就支持 Undo 和 Redo 了!

在文本框添加文本以後,我們點擊窗口關閉按鈕,此時會提醒要不要保存文檔:

點擊 save 按鈕,會彈出一個警告窗口:

是不是對裡面的內容很熟悉,這就是前面添加的 save 方法中的錯誤信息。

模態窗口(Modal Window)

模態窗口是一種特殊的窗口,一旦顯示就會獨佔用戶的所有操作事件,一直到它被關閉,其它窗口才能響應用戶的操作。

顯示模態窗口有三種方法:

  • 以一個普通窗口的形式顯示,使用 NSApplication.runModal(for:) 觸發顯示
  • 以 Modal sheet 的形式顯示, 調用 NSWindow.beginSheet(_:completionHandler:) 顯示窗口
  • 通過模態會話的形式,本文暫不涉及這種高級的方法

其實,文檔的保存和打開窗口就是模態窗帘的好例子,就像上面關閉窗口時彈出的提示保存的窗口,它出現在窗口的頂部,這就是 Modal Sheet,在本文也不講這種模態窗口,下面我們一起實現一個顯示字數和段落統計的模態窗口,它是以一個正常窗口形式顯示的。

添加一個新的窗口

打開 Main.storyboard 文件,按快捷鍵 Shift + Command + l 打開 Object Library,搜索 Window Controller,拖拽 Window Controller 進入畫布,這會生成兩個場景:Window Controller Scene 和 View Controller Scene。

選中剛添加的 WIndow Controller Scene 中的 Window,按快捷鍵 Option + Command + 5,打開 Size Inspector,調整其寬為 300,高為 150。

繼續選中 Window,按快捷鍵 Option + Command + 4 打開 Atrribute Inspector,取消 Close、Resize 和 Minimise 控制項複選框的勾選,設置標題為 Word Count

窗口 Close 按鈕會造成一個 bug:當點擊這個按鈕後雖然窗口已經關閉,但是應用因為沒有調用 stopModal 方法而一直保留在模態狀態,這就很尷尬了!

另外,不保留 Minimise 和 Resize 按鈕是為了遵循 Apple 的 Human Interface Guidelines (HIG)。

選中新添加的 View Controller Scene 中的 View,按下快捷鍵 Option + Command + 5 打開 Size Inspector,設置寬為 300 高為 150。

配置 Word Count 窗口

Shift + Command + l 打開 Object Library 拖拽 4 個 label 到 View 中。

改變四個 label 的標題分別為:Word CountParagraph Count00,同時設置它們都是右對齊,調整它們的寬為 120,這裡我們不涉及自動布局,可能會出現幾個警告,先不管它們。

從 Object Library 推拽一個 Push Button 到 View 中,更改其標題為 OK,手動調整所有控制項布局到合適的位置。

創建 Word Count 的 View Controller 的類

Command + n 會打開一個文件新建的導航窗口,我們選擇 macOS -> Source -> Cocoa Class,新出現的窗口中輸入類的名稱為 WordCountViewControllerSubclass of 設置為 NSViewController,取消勾選 Also create XIB for user interface

點擊 Next 創建新的文件。

打開 Main.storyboard,選中新添加的 View Controller,按快捷鍵 Option + Command + 3 打開 Identity Inspector,選擇 class 為剛添加的 WordCountViewController 類。

綁定計數 label 與 View Controller

打開新建的 WordCountViewController.swift 文件,在 WordCountViewCOntroller 中添加屬性如下:

@objc dynamic var wordCount: Int = 0
@objc dynamic var paragraphCount: Int = 0

??:兩個屬性添加了 @objc dynamic 修飾符是為了有效實現 Cocoa Bindings^[Cocoa Bindings 是 UI 開發中一個強大的技術,主要用於數據與 UI 的綁定,可以閱讀 Cocoa Bindings on macOS 了解更多相關內容,後面有時間十里會專門寫一篇相關的文章與大家一起學習!],否則綁定無效運行時會報錯。

打開 Main.storyboard 選中與 Word Count 的 label 相對應的數字 label,按下快捷鍵 Option + Command + 7 打開 Bindings Inspector:

  • 點擊 Value 左邊的小三角,展開 Value
  • Bind to 的下拉框選擇 Word Count View Controller
  • 勾選 Bind to
  • Model Key Path 輸入 wordCount

同樣的步驟,與 Paragraph Count 的 label 相對應的數字 label 綁定到 paragraphCount:

下一步設置 Window Controller 的 Storyboard ID

選擇 Word Count WindowWindow Controller,然後按快捷鍵 Option + Command + 3 打開 Identity Inspector,更改 Storyboard ID 的值為 Word Count Window Controller

顯示和關閉模態窗口

前面的準備工作做足了,那本節講講如何召喚和轟走模態窗口。

出來吧,模態窗口

打開 ViewController.swift 文件,在類中添加以下屬性:

@IBOutlet var text: NSTextView!

同時添加以下方法:

@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
NSApplication.shared.runModal(for: wordCountWindow)
// 4
wordCountWindow.close()
}
}

打開 Main.storyboard 文件,選中添加 Text View 的 View Controller,按住 Ctrl 鍵,點擊 View Controller 按鈕,不鬆手拖動至 Text View 上鬆手,此時會彈出一個綁定選擇框,裡面就包含了我們剛添加的 text 屬性,點擊它,這就完成了 Text View 控制項與 text 屬性的綁定

上面添加的方法,這裡一步一步的說明一下:

  1. 使用之前配置的 Storyboard ID 實例化一個 Word Count Window Controller 對象
  2. 從 text view 的 storage 對象中獲取字數統計和段落統計,將值設置到 wordCountViewController 的兩個屬性 wordCountparagraphCount
  3. 模態方式顯示 word count 窗口
  4. 一旦模態狀態結束就關閉模態窗口,這裡需要注意,只要模態不結束這一句就不會執行

消失吧,模態窗口

這裡我們需要添加結束模態的實現,打開文件 WordCountViewController.swift,添加以下方法:

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

下面我們將此方法與上面添加的 OK 按鈕進行綁定。

打開 Main.storyboard,選中 OK 按鈕,點擊它,同時按住 Ctrl 鍵,拖動滑鼠至 Word Count View Controller 的按鈕上,在彈出的綁定窗口上選擇剛添加的方法 dismissWordCountWindow 即可完成綁定。

添加召喚模態的符咒

這裡我們以菜單欄的菜單項的方式觸發模態窗口。

打開 Main.storyboard 文件,找到 Main Menu,點擊展開 Edit,然後進行以下操作:

  1. Shift + Command + l 打開 Object Library,搜索 Menu Item,拖動到最下面的位置,添加一個新的菜單項,選中它
  2. Option + Command + 4 打開它的 Attribute Inspector,更改標題為 Word Count,同時配置快捷鍵為 ?K

下面我們需要為其綁定上面定義的方法 showWordCountWindow,點擊菜單項 Word Count,同時按住 Ctrl 鍵,拖動至 Application Scene 下的 First Responder 上鬆手,在彈出的列表中找到方法 showWordCountWindow,選擇它,這就完成了觸發模態的綁定:

召喚模態

編譯運行程序,在窗口中輸入一些內容,比如:

望岳

唐代:杜甫 岱宗夫如何?齊魯青未了。 造化鍾神秀,陰陽割昏曉。 盪胸生曾雲,決眥入歸鳥。 會當凌絕頂,一覽眾山小。

菜單欄 Edit -> Word Count (或者按快捷鍵 Command + k) 就能打開統計字數的模態窗口。

點擊 OK 就可以「轟走」模態窗口了。

總結

點我?

pichome-1254392422.cos.ap-chengdu.myqcloud.com

可以下載本文中的工程。本文涉及到了以下內容:

  • MVC 的設計模式
  • 多窗口應用的實現
  • Interface Builder 和 代碼 兩種方式控制窗口位置
  • 控制項與類屬性的綁定,控制項 Action 與類方法的綁定
  • 窗口形式的 macOS 的常規開發姿勢
  • 如何代碼控制顯示模態窗口
  • 富文本編輯的簡單實現

希望對大家學習 macOS 開發有所幫助!感謝您的閱讀!

參考

Windows and WindowController Tutorial for macOS?

www.raywenderlich.com圖標


博客原文:

macOS 開發之 Windows 和 WindowController?

www.smslit.top
圖標

推薦閱讀:
相关文章