NSWindow知多少?

119 篇文章 0 订阅
65 篇文章 0 订阅

如果你有其他的平台的經驗,來寫 Cocoa 應用程式,可能會發現 NSWindow 的行為跟你想得似乎不太一樣。

※ NSWindow 屬於 View

雖然現在的 GUI 應用程式的架構大都遵循 MVC 的設計典範,但是不同的 Framework 之間,那個部分屬於 View,那個部分又屬於 Contoller,規劃卻又不盡相同。在許多 Framework 的設計中,Window 被當成是 Controller 使用,但是在 Cocoa 的架構中,一個 NSWindow 物件,卻是單純扮演 View 的角色。

當你打開 Visual Studio,打算寫個 WinForm 程式,你大概會先用視覺化工具拉出一個 Window(這個 Window 的物件型別其實叫做 Form 就是了),在上面再拉一個按鈕,在這個按鈕上面點兩下,就可以開始撰寫使用者點到這個按鈕時會觸發的程式。-這裡,在 Window 裡頭產生的按鈕,被當成是這個 Window 物件的成員變數,點擊這個按鈕所觸發的行為,也是這個 Window 物件的 method。總之,在這個 Window 中發生的種種,都是由 Window 物件處理,Window 物件是其他放在 Window 裡頭其他 UI 元件的 Controller。

但是在 Cocoa 中卻不一樣。在 Cocoa 中產生了一個 Window 之後,這個 Window 並不扮演 Controller 的角色,而是另外有一個 Controller 物件,這個物件有一個成員變數(通常是個 IBOutlet)連結到這個 Window 上。你的 Window 物件與上面的其他 UI 元件的關係,就只是 NSWindow 有個 content view,content view 裡頭擺放這些元件,在 MVC 的界定上,Window 本身與其他 UI 元件,都是另外一個 Controller 的成員變數。

所以,如果你想要做的事情是:讓使用者在某個 Window 中點了某個按鈕後,跳出另外一個 Window,就很容易發現兩者之間的差別。在 WinForm 裡頭,要產生另外一個 Window,你會先另外製作一個繼承 System.Windows.Forms.Form 的型別,然後在原本的 Form 裡頭產生這個 Form 的 instance,最後呼叫 Show() 顯示出來。

在 Cocoa 裡頭,因為 NSWindow 是被當成 View,所以要產生一個有兩個以上 Window 的應用程式,就不是從某個主要 Window 去產生另外一個 Window,而是你可能會有一個繼承自 NSObject 的 Controller 物件,這個物件可以同時管理兩個 Window,只要設定兩個 NSWindow 成員變數,再去 Interface Builder 拉出兩個 Window,並且連結起來,就可以了。

※ NSWindowController

現在有些人可能是因為 iPhone 接觸 Objective-C 語言與蘋果的開發工具,再來接觸 Mac 上面的應用程式開發。-在 iPhone 應用程式中,我們會產生許多的 UIViewController 物件,然後把 UIViewController 放進 UINavigationController 或 UITabBarController 的瀏覽路徑中。既然 iPhone 的 UI 實作是以 UIViewController 為主,因為 iPhone 的 UI 的重點是 View,而在 Mac 上的 UI 的重點應該就是 Window,所以在寫 Mac 應用程式的時候,是不是可以比照 iPhone,產生許多的 NSWindowCongtroller 呢?

這樣想好像很直觀,不過,狀況並不是這樣。NSWindowController,主要是給 NSDocument 用的,而使用 NSDocument 的應用程式,叫做 document-based application,甚至在 NSWindowController 的文件中,都可以看到參閱文件是 Document-Based Applications Overview

打開 Xcode,可以看到 project template 中,Cocoa Application 分類中有一個 checkbox,讓你選擇要不要產生 document-based application。什麼是 document-based application?

我們可以這樣簡單區分:有一種類型的應用程式,在程式中的 Window 數量是固定的,所有的工作都可以在這些有限的 Window 中完成,例如計算機-計算機程式只有一個 Window,上面是數字與計算用的按鈕,各種計算工作都只在這個 Window 中完成,拉下 File 選單只會看到 Close,而沒有像是 New 或 Save 這些指令;這是一般的 Cocoa Application。

在這種應用程式中,可能還有其他的 Window,例如在計算機中,我們可能想要多一個Window 幫我們做單位轉換,像攝氏轉華氏之類。在這種狀況下,其實只要像之前提到的,用一個物件產生兩個 IBOutlet,分別連到這兩個 Window 就好。

另外有一類應用程式,則是可以用 New 產生新的文件,分別會有一個 Window 代表這個文件,比方說文字編輯器-你可以產生一份新檔案,或是從 Finder 裡頭打開一份檔案,每開一個新檔案,就會多一個 Window,裡頭是檔案的文字內容,你可以繼續開啟更多的檔案,就會產生更多的 Window,所以應用程式中的 Window 數量會隨著開啟文件的數量而變動,沒辦法用有限的 Window 完成所有的工作。這種是 document-based application。

在這兩種類型的應用程式中,Window 會有不同的行為:

1. 關閉 Window 的行為。在一般的 Cocoa 應用程式中,使用者按下計算機的關閉按鈕,只是想要把計算機 Window 隱藏起來;但是在文書編輯器中,關閉 Window 就代表我已經不想要編輯這個檔案了,不只是隱藏起來而已,而是除非要求應用程式重新開啟檔案,這個 Window 就不應該存在,在關閉的同時,如果檔案被改過而沒有儲存,應用程式也應該要提醒使用者要不要存檔。

2. Window 是否要有一些代表檔案的元件。在一般 Cocoa 應用程式中,Window 的標題列就只是單純顯示一個標題,計算機的 Window 標題就是「計算機」三個字;而文書編輯軟體的標題呢,則會是目前正在編輯的檔案的檔名,同時顯示一個檔案圖示的小 icon,在檔名上面點選右鍵,會有一個選單告訴你這個檔案的完整路徑,拖拉這個 icon 到 Finder 裡頭,還可以搬移或是複製檔案。

Screen shot 2010-08-12 at 2.53.14 AM

一個一般的 Cocoa 應用程式,可以直接用 IBOutlet 建立 Controller 與 Window 之間的簡單關係;在 document-based application 中,Cocoa Framework 則是用 NSDocument、NSWindowController 與 NSWindow 實作。

※ NSDocument、NSWindowController 與 NSWindow

NSDocument 所處理的是與檔案之間的關係,最主要的是開啟、修改與儲存檔案。以文書編輯軟體來說,應用程式打開檔案的時候,就會有一個 NSDocument 物件負責讀入檔案,把文字內容放在某個成員變數中,同時記得這個檔案的路徑(不過,最近幾版的 Mac OS X,慢慢地都用 URL 取代本機路徑)。另外,NSDocument 也負責檔案列印與相關設定。

在應用程式中,會有一個 Singleton 地 NSDocumentController,產生了一個新的 NSDocument 時,我們就要把這個 NSDocument 物件加到 NSDocumentController 中,這樣,如果我們打開一個已經開過的檔案,就可以透過比對 NSDocumentController 中是否有路徑相同的檔案,確認是否開過,如果已經開過,就不要產生新的 NSDocument 物件,直接用現有的物件,頂多把隱藏起來的文件 Window 顯示出來。

NSWindow 就是在螢幕上面看到的 UI,而 NSWindowController 的功能,就是介於 NSDocument 與 NSWindow 之間,與兩者互動。

從選單按下 New,產生 NSDocument,到一個 Window 出現在螢幕上,如果你直接用 project template 產生一個 document-based application,會發現 Cocoa 已經幫你做好很多預設實作,而光讀程式碼似乎看不懂 Cocoa 到底做了什麼。官方文件裡頭 Message Flow in the Document Architecture 就在講這幾個 Class 是怎麼串起來的,流程大抵是-

1. NSDocument 首先免不了的要 alloc、init,設定檔案 URL(如果是新文件,就是 nil),把 NSDocument 加入 NSDocumentController 的管理中。

2. 接著,呼叫 NSDocument 的 makeWindowControllers。NSDocument 基本的實作是產生一個 NSWindowController,這個 NSWindowController 透過 NSDocument 的 windowNibName 決定要載入哪一個 nib。產生了 NSWindowController 後,NSDocument 會用 addWindowController:,把這個 NSWIndowController 物件加到自己的 windowControllers 中。每個 WindowController 負責管理一個 Window。

3. 對 NSWindowController 呼叫 showWindow:,把從 nib 載入的 Window 顯示出來。如果 NSWindowController 還沒有載入 window,就會自動用 loadWindow 載入。

NSWindowController 在 NSDocument 與 NSWindow 之間,最主要會用到的就是 setShouldCascadeWindows: 與 setShouldCloseDocument:,這部份大概都會在 makeWindowControllers 的時候設定。

雖然 makeWindowControllers 的時候預設的實作是產生一個單一的 NSWindowController,但是一個 NSDocument 可能會用到很多不同的 Window,每個 Window 都可能有不同的行為。如果我們會用到很多 Window,就可以在 makeWindowControllers 時產生對應的 NSWindowController,用 addWindowController: 加入。

以文字編輯器來說,一個檔案的內容可能出現在一個主 Window 中,但是有可能有許多工具 Window,比方說主 Window 旁邊有一個可以展開或收起來的 Drawer,裡頭有字數統計等相關資訊,在關閉主 Window 時,行為應該是關閉文件,這個 NSWindowController 就應該在 setShouldCloseDocument: 設成 YES,至於抽屜呢,關閉時就只是收起來而已,就反之,應該設成 NO。

甚至,在同一個應用程式中,關閉不同 Window 的行為也都不一樣。在 Safari 裡頭,關閉一個瀏覽器 Window,與關閉檔案下載列表的 Window,前者代表的是我不要看這些網頁了,但後者只是把檔案下載列表隱藏起來而已。

如果 setShouldCloseDocument: 設成 YES,在關閉 Window 的時候,Window 首先會用 delegate 的方式通知 NSWindowController,NSWindowController 再通知 NSDocument,確認檔案是不是已經被改過,是不是應該要先存檔才關閉;從 NSDocument 透過 NSWindowController 產生 Window 的過程中,NSDocument 也同時把自己的 Undo Manager 指派到 NSWindow 上,讓 Window 上的 UI 元件-像是 NSTextField 或 NSTextView 等做 Undo 的時候,讓 NSDocument 知道對檔案的修改已經被 Undo 了。

setShouldCascadeWindows: 則是決定我們要不要在新視窗產生的時候,讓視窗位置稍微出現在前一個產生出來的文件的視窗的附近,但是視窗位置的上緣與左側比前一個視窗多一點(打開「文字編輯」然後一直開新文件就看到這種效果)。同樣的,只有編輯檔案的主視窗需要這種行為,但 Drawer 則不用。

Screen shot 2010-08-12 at 2.52.29 AM

※ 幾個可以想到的問題

問:我們要在應用程式中開一個視窗,一定要用 NSDocument 嗎?假如我們要寫一個聊天軟體,開啟新的聊天視窗的時候,這個聊天視窗根本與檔案路徑無關,我們是不是可以直接產生一個新的 NSWindowController 物件,讓他來從 nib 載入 Window?

答:這麼做,在記憶體管理上,可能會有一些麻煩的地方。在關閉 Window 的時候,如果我們也要把管理 Window 的 NSWindowController 放掉,那麼可能的作法就會是讓 NSWindowController 成為 Window 的 delegate,在 Window 關閉時,NSWindowController 對自己呼叫 [self release],光想就覺得頂危險。而對 NSDocument 呼叫 close 的話,Cocoa 則已經有一套對 NSDocument 的記憶體管理機制。

這邊需要特別注意:NSDocument 的 close 代表的是要關閉文件,NSWindowController 如果沒有一個關連的 NSDocument 的時候,單純呼叫 close,只代表把 Window 隱藏起來而已,如果有關連的 NSDocument,就會去呼叫 NSDocument 的 close。

而且,就算是聊天軟體,我們大概也不會想點選相同的聯絡人,結果卻是開不同的 Window 聊天。所以,每個聊天 Window 要知道是屬於跟誰聊天的,大概也是透過一套 URL 管理,這樣還是用 NSDocument 比較好,可能某個 NSDocument 代表 msn://zonble 之類,只是這類的 URL,通常就需要改寫一下 Window 設定,讓 Window 的標題列不要出現與檔名相關的東西。

問:那麼,為什麼 NSDocument 與 NSWindow 之間,需要經過 NSWindowController 這一層?而不是 NSDocument 直接連結到 NSWindow 上?一定要用 makeWindowControllers 產生 Window 嗎?

而且,事實上,NSDocument 自己也有 window 這個 method,我們也經常把 NSDocument 的 IBOutlet 直接連到 NSWindowController 負責載入的 Window 上,對吧?

答:呃,說起來,如果你的 NSDocument 物件只有一個視窗的話,只要實作 windowNibName,回傳一個要載入的 nib 的檔名字串就好,其他事情預設實作都幫你做好了,就當做 NSWindowController 不存在就好。這個 API 也不是我規劃的,有些事情你還真不知道為什麼。

 

現在來講些一直以來我覺得 NSWindow 討厭的地方。

首先,在 Cocoa 對 Window 的設計中,有個你光看名字實在看不懂什麼意思,不看文件不可能猜得到的屬性,叫做 Key Window-你可以決定一個 Window 是不是 Key Window,也可以詢問目前的 Window 是不是 Key Window。

※ Key Window 與 Main Window

Key Window 是什麼意思呢?從字面來看,好像是什麼關鍵 Window,一個 Window 作為關鍵是什麼意思?或是拿來當做鑰匙的 Window 是什麼意思?

Key Window 當中的 Key 完全不是指這個,而是正在負責處理鍵盤事件的 Window,比方說,你有一個應用程式有一個 Window,裡頭有的文字框,你正在這個文字框裡頭打字,這個 Window 就是 Key Window。

跟 Key Window 相關的另外一個屬性叫做 Main Window,Main Window 通常是 Key Window,但是 Key Window 不見得是 Main Window。

Screen shot 2010-08-14 at 12.26.33 AM

我們來用 Safari 瀏覽器解釋這件事情-在 Safari 裡頭,我們可以產生很多個瀏覽器視窗瀏覽網頁,但是在瀏覽器裡頭想要打一篇文章的時候,突然發現不知道怎麼打,於是我們叫出了字元面板,然後把輸入焦點移動到了字元面板下方的搜尋框裡頭。這時候,字元面板就是我們的 Key Window,後面其中一個瀏覽器視窗則是 Main Window。

這兩個屬性決定了事件的傳遞:當使用者在鍵盤上面按了一個鍵,目的自然是想要在字元面板的搜尋框裡頭打字,所以我們要把這個事件送到字元面板上,我們就該把鍵盤事件分派到 Key Window;但如果是其他的操作行為,像是我們從書籤選單上面選了一個書籤,自然不會是由字元面板處理,而是當前的瀏覽器視窗開啟書籤,那就要送到 Main Window 上。

要讓一個 Window 變成 Main Window 或 Key Window,就是透過 NSWindow 的 makeMainWindow 與 makeKeyWindow 這兩個 method。於是你就可以看到官方文件裡頭讓人想要罵人的地方-打開 Xcode 裡頭附的開發者文件,可以看到 NSWindow 部分中, 關於這兩個 method 的解釋:

makeKeyWindow
Makes the window the key window..

makeMainWindow
Makes the window the main window.

沒什麼問題。好,在 iOS 裡頭,有一個可以對應到 NSWindow 的物件,叫做 UIWindow,UIWindow 沒有 makeMainWindow,只有 makeKeyWindow 以及 makeKeyAndVisible(就是你開始學寫 iPhone 應用程式的第一課中,在 applicationDidFinishLaunching: 裡頭呼叫,讓應用程式 Window 顯示出來的 method),>文件裡頭則是這麼說:

makeKeyWindow
Makes the receiver the main window.

喂喂喂…。

※ Cocoa 的 Events

這後面牽涉到 Cocoa 怎麼傳遞事件,作業系統收到鍵盤或滑鼠事件之後,首先決定要由哪個應用程式負責,可能是交由目前使用者正在使用的應用程式,也可能是某個常駐應用程式,應用程式(NSApplication)收到事件後,就會透過 sendEvent: 把事件傳遞給應該處理的 Window,這就是 Key Window 與 Main Window 派上用場的時候了,接著,NSWindow 繼續透過 sendEvent:,把事件傳遞給應該負責處理的 UI 元件。

同一個 Window 上面,可能有很多的 UI 元件,可能有一堆按鈕、一堆文字框,NSWindow 應該把事件傳遞給哪一個呢?當然是使用者正在用的那個。

所謂的「正在用」,在 Cocoa Framework 裡,稱之為 First Responder。如果你寫過 iPhone 可能就知道,想要讓某個文字框可以不用等到使用者點選,就取得輸入焦點、浮出螢幕鍵盤,就是讓這個文字框變成 First Responder,像是呼叫:

[textfield becomeFirstResponder];

在 Mac OS X 裡頭,我們則是要讓 NSWindow 決定他上面的哪個元件應該變成 First Responder:

[[textfield window] makeFirstResponder:textfield];

岔個題-在設定 target/action 的時候,如果把 target 設定成 nil 的話,就相當於呼叫 First Responder。在你的第一堂 iPhone 或 Mac 開發課程的講義中,你學到在 Interface Builder 裡頭拉一個按鈕出來,然後按著 Ctrl 按鍵,用滑鼠拉出一條連接線連到你的 controller 物件的某個 IBAction 上,對你的按鈕來說,這個 controller 就是 target,指定的 IBAction 就是 action。

在 iPhone 上如果要用程式完成這樣的連接,我們會呼叫 UIButton 的 addTarget:action:forControlEvents:,在 Mac 上面則是呼叫 NSButton 的 setTarget: 與 setAction:。你可以在這邊把傳入的 target 設成 nil,效果也就相當於,在 Interface Builder 裡頭,把連接線連到一個叫做 First Responder 的圖示上,圖示是一個紅色的箱子。

在 iPhone 上可能不常這麼做,但是在 Mac 上則會大量用到,比方說,我們在任何的文字框中,都可以用 Edit 選單裡頭的 Copy 指令複製文字內容。代表 Copy 指令的 NSMenuItem 自然不可能連接到應用程式裡頭所有的文字框上,而是連到 nil、連到 First Responder 上,看到使用者在用哪個文字框,才去複製那個文字框的文字。

※ sendEvent:

NSApplication 與 NSWindow 其實已經幫你完成大部分的事件傳遞,那,到底什麼時候會有必要自己處理 sendEvent:,還要搞清楚事件是被傳到了 Key Window 還是 Main Window?

在漫長的人生中,你可能會遇到這樣的狀況-你在寫一個媒體播放程式,可能是放影片或是放音樂,這個程式裡頭有好幾個 Window,有的 Window 播放影片、有的播放音樂,你可以把某個影片檔案從 Finder 拖到這個應用程式的 Dock Icon 上,就會開出一個新視窗,用新視窗播放影片。印象中 QuickTime 的 Tutorial 就是教你怎麼寫一份這樣的東西。

前一篇我們提到,這樣的應用程式就是 document-based application,所以你產生了一個繼承自 NSDocument 的新文件,這個 NSDocument 所管理的 Window 中有一個 QTMovieView,你直接把 NSDocument 讀到的 fileURL 丟給 movie view 產生 QTMovie,可以播放影片了。你現在想要讓軟體操控變得更容易一點。

首先你想到的是在螢幕畫面最上方的 menu bar 裡頭,增加一個選單,裡頭有要求影片播放或暫停的選項,還可以有一些透過改變 Window 大小來縮放影片的功能,你甚至在 Interface Builder 裡頭,把選單都加上了鍵盤快速鍵,像是用 cmd + 2 就可以變成兩倍大小。這時候我們都用不到 sendEvent:,但是,因為我們有很多個 Window、很多個影片,播放或暫停等命令不會送到固定的地方,所以要讓 First Responder 處理,我們把 target 都設成 nil。

我們打算用 play: 實作播放,用 pause: 實作暫停,這些 method 我們都放在我們的 NSDocument 子類別裡。如此一來,在 Main Window 裡頭沒有任何一個佔據輸入焦點的 UI 物件可以處理 play: 與 pause: 時,就會把事件送到 NSDocument 上。Xcode 提供的 template 裡頭,很多預設的 NSMenuItem 其實都把 action 送到 NSDocument 上,像,用 cmd + s 儲存檔案呼叫 NSDocument 的 saveDocument: ,還有列印等。

然後就遇到了問題-在 Mac 的鍵盤上,有一組按鍵叫做 Media Keys,在我的 MBP 與 MB 上是放在 F7 – F9 的位置,平常可以用來控制 iTunes 的播放、下一首上一首以及音量,你覺得如果是在你的應用程式中,這些按鈕應該是用來控制你的影片用的。可是,NSApplication 根本就不幫你處理這些按鍵,收到之後直接無視。

在 iOS 上面其實也有相同的狀況。iPad 可以透過藍芽連接無線鍵盤,iOS 4.0 之後,iPhone 也可以,如果使用者買了一組蘋果的藍芽鍵盤連接這些裝置,這些裝置也都會收到這些按鍵,甚至耳機線控也都是送出一樣的事件。

iOS 4.0 公開了這部份 API,找個地方實作 remoteControlReceivedWithEvent: 就好,至於 Mac 呢,則要看 Rogue Amoeba 前幾年的文章:Apple Keyboard Media Key Event Handling

這篇文章首先就告訴你,要攔截這些事件,就是要透過 subclass NSApplication,改寫 sendEvent:,遇到 NX_KEYTYPE_PLAY (代表按下 play 鍵)等事件,就另外處理,不然就用 super 的實作。知道了是這些特別事件,在我們這個個案中,有幾個方向可以選擇:

一,我們可以把 NSEvent 再送給 Main Window,叫 Main Window 繼續用 sendEvent: 處理。在這個例子裡頭這麼做頂麻煩就是了,因為按下播放鍵的行為就是要播放影片,所以直接找到哪個是我們要的 NSDocument 物件,呼叫我們實作的 play:,會輕鬆取多。於是-

二,去找到底哪個 NSDocument 應該做事,也就是 Main Window 所屬的 NSDocument 物件。有兩種方法,1. 呼叫 [[[NSApp mainWindow] windowController] document]、2. 呼叫 [[NSDocumentController sharedDocumentController] documentForWindow:[NSApp mainWindow]]。

 

 

所有跟 NSWindow 相關的設計中,最讓人覺得麻煩的,莫過於 Field Editor。Field Editor 之所以麻煩,在於一開始遇到這個東西造成麻煩的時候,完全讓人想不到原來跟 NSWindow 相關。

※ NSTextView、NSTextField 與 Field Editor

要知道什麼是 Field Editor,就要先搞懂兩個 Class-NSTextView 與 NSTextField,兩者都是在 Cocoa 中用來輸入文字用的 UI 元件,但是兩者的實作完全不一樣,NSTextView 繼承自 NSText,NSTextField 則是繼承自 NSControl。

打開系統內建的「文字編輯」軟體(TextEdit.app),就可以看到每個文件 Window 中都有一個 NSTextView,不管是在 RTF 或是純文字編輯模式,都是用 NSTextView 提供文字輸入功能,至於要修改裡頭的文字內容,我們可以把 NSTextView 的 text storage,當成是 NSMutableAttributedString 修改。我們想要設計一個文字編輯器軟體,或是讓使用者可以輸入多行、大篇的文字,就會使用 NSTextView。

至於想要在某個 Windows 裡頭,讓使用者可以輸入帳號密碼等資料,通常會在 Interface Builder 中,拉出幾個只有一行的文字輸入框,這種文字框就是 NSTextField。

我們可能以為 NSTextField 就是一個只有一行的 NSTextView 而已,但並非如此:NSTextField 其實並不負責處理文字輸入,只負責在畫面中,把一段文字與文字框的背景與外框畫出來而已(更精確地說,其實是 NSTextView 裡頭的 NSTextCell 把這些東西畫出來),我們之所以可以在 NSTextField 裡頭打字,是在我們選擇到這個 NSTextField 的時候(或這麼說-這個 NSTextField 變成 First Responder 的時候),NSTextField 要求 NSWindow 提供一個整個 Window 上面所有 UI 元件共用的 NSTextView,把這個 NSTextView 疊到 NSTextField 上面,讓這個 NSTextView 處理文字輸入工作,在視覺上,就產生我們在 NSTextField 裡頭打字,NSTextField 可以處理文字輸入的假象。這一個整個 Window 共用的 NSTextView,就叫做 Field Editor。

我們可以理解 Cocoa 為什麼會這樣設計,這整套機制都是從 NextSTEP 時代就在發展了,而就算 NextSTEP 在當年算是相當高檔的工作站,但是仍然是一個記憶體有限的環境,一個 Window 中可能會有許多的 NSTextField 物件,如果這些物件平常只負責處理一些介面繪圖,而在需要的時候才處理文字輸入-事實上你也不可能同時在兩個 Text Field 裡頭打字-於是這樣設計,的確可以減少記憶體的使用。但如果你不知道 Cocoa 是這麼設計,開始寫程式,有些地方你根本就不知道應該從何下手。

※ NSTableView 與 Field Editor

先來說一個普通麻煩的。

如果我們打算在應用程式中使用表格介面,那麼,就會用上個 NSTableView 或是 NSOutlineView,如果我們想要讓使用者可以修改表格裡頭的文字內容,我們可以讓某一欄、某一列的內容,設定成 editable,那麼,當使用者在某一欄、某一列的某一格中,點選兩下滑鼠,這一格就會變成一個可以修改內容的文字框。雖然這個文字框出現在表格中,但是並不屬於這個 NSTableView 或是 NSOutlineView 物件,而是 Field Editor。

在 NSTableView 中,如果有一欄的內容用的是 NSTextCell,用來顯示文字內容,預設使用系統字體的大小,也就是 13pt 的字,我們想要做的事情是改變文字的字體或顏色,例如把字體改小一點。我們知道,要讓表格中某一格顯示我們要的文字,就是在 NSTableView 的 dataSource 中,用 tableView:objectValueForTableColumn:row:,回傳一個 NSString 物件,既然要改字體,那麼在同一個 method 中, 不要回傳 NSString,而是回傳 NSAttributedString,並且套用指定的樣式屬性,就可以達到改變字體的效果。所以我們這樣寫:

- (id)tableView:(NSTableView *)aTableView objectValueForTableColumn:(NSTableColumn *)aTableColumn row:(NSInteger)rowIndex
{
	NSDictionary *attr = [NSDictionary dictionaryWithObjectsAndKeys:[NSFont systemFontOfSize:11.0], NSFontAttributeName, nil];
	return [[[NSAttributedString alloc] initWithString:@"Test" attributes:attr] autorelease];
}

跑起來看,乍看之下效果不錯,但這樣寫,如果開始編輯其中的某一格,就會發現編輯框裡頭的文字還是一樣的大小,改用 NSAttributedString 並不影響 Field Editor 的顯示方式。要同時改變一般模式與 Field Editor 的字體大小,方法應該是修改 NSTextCell,所以我們要實作 tableView:willDisplayCell:forTableColumn:row:,大概這樣寫:

- (void)tableView:(NSTableView *)aTableView willDisplayCell:(id)aCell forTableColumn:(NSTableColumn *)aTableColumn row:(NSInteger)rowIndex
{
	[aCell setFont:[NSFont systemFontOfSize:11.0]];
}
※ First Responder

Field Editor 這種設計真正讓人抓狂的地方,在於會打亂 First Responder。比方說,你呼叫了 [[textfield window] makeFirstResponder:textfield] 之後,讓一個 NSTextField 變成文字輸入焦點,原本預期 [window firstResponder] 就會是這個 NSTextField,但實際回傳的卻是 Field Editor。如果你所設計的 UI,是在某個 Window 中放了一大堆 NSTextField,你想要知道現在使用者到底在哪個文字框中打字,結果,不管焦點是在哪個 NSTextField,用 NSWindow 的 firstResponder 去查,都是回傳同一個 Field Editor。

要知道到底是在哪一個 NSTextField 裡頭輸入,就是發現 First Responder 是 Field Editor 的時候,去詢問 Field Editor 的 delegate 是誰。每當使用者點到了某個 NSTextField、或是 NSTableView,這個元件向所在的 NSWindow 要求使用 Field Editor 的時候(呼叫 fieldEditor:forObject:),NSWindow 就會把 Fields Editor 的 delegate 指過去。

於是我們可以知道,別想自己設定 Field Editor 的 delegate,就可以一併修改某個 NSWindow 上 Field Editor 的某些行為。就是因為 NSWindow 會不斷重新指派 Field Editor 的 delegate,所以,想要透過 delegate 改變 Field Editor 的行為,就要把這些 delegate method 放在你的 NSTextField 的 subclass 中。

※ Drag and Drop

前面兩點大約在兩年前給了我或多或少的沮喪經驗,而最近一次 Field Editor 造成的沮喪,則是我想要讓某個 NSTextField 可以接受一些不同資料類型的 Drag and Drop。

NSTextField 原本就接受一些資料類型,例如,選好某段文字後用滑鼠按住不放,就可以拖拉這段文字,然後就可以丟到某個 NSTextField 中,從 Finder 拖一個檔案丟到 NSTextField 就會變成以文字表示的檔案路徑…我想做的事情是像 Mail.app 那樣,從通訊錄(Addressbook.app)拖拉一筆聯絡人資訊到我的 NSTextField 裡頭(其實是 NSTokenField),預設的實作只會顯示聯絡人的名字,而我想要的是同時保有聯絡人名稱與電子郵件等資料,並且用我想要的格式呈現。

首先想到的,就是先 subclass NSTextField,並且改寫跟 Drag and Drop 有關的部份,就是 NSDraggingDestination Protocol 所定義的 draggingEntered: 等,我要的是剪貼簿裡頭的 vCard 資料,如果是 vCard 就另外處理,要不就使用 super 的實作。

但問題來了,你發現,如果你的 NSTextField subclass 正在使用 Field Editor,收到 draggingEntered: 之後,一瞬間就變成 Field Editor 在接收 Drag and Drop 事件,你的 NSTextField subclass 就收不到後續的 draggingUpdated: 、draggingEnded:。怎麼辦呢?嗯…既然變成 Field Editor 在收 Drag and Drop 事件,那是不是也要 subclass 這個 Window 上的 Field Editor,在收到 Drag vCard 資料的時候,把資料傳給我們的 NSTextField subclass?

蘋果文件的確有告訴你怎麼替換 Field Editor,在 Text Editing Programming Guide 裡頭,告訴你可以用 NSWindow 的 delegate method:windowWillReturnFieldEditor:toObject:,根據傳入物件的不同,回傳不同的 Field Editor,你可以在這邊選擇自己繼承自 NSTextView 產生的物件。

你瞧瞧,想要改變某個 NSTextField 的 Drag and Drop 行為,卻是從 NSWindow 下手,多奇妙。即使如此,還是沒辦法解決我的問題,這個方法適用一般的 NSTextField ,但我要處理的卻是 NSTokenField。

NSTokenField 非常麻煩,首先,你以為 NSTokenField 在使用 Field Editor 的時候,Field Editor 的 delegate 應該是 NSTokenField,但 Field Editor 的 delegate 卻是 NSTokenField 裡頭的 NSTokenFieldCell;而 NSTokenField 的 Field Editor 的型別,也不是 NSTextView,而是繼承自 NSTextView 的 NSTokenTextView,而這個 class 是 AppkKit 的 private API。原本以為只是一個特製的 NSTextField 與特製的 Field Editor 的關係,結果看到的是 NSTokenField 、NSTokenFieldCell 與 NSTokenTextView 的亂七八糟關係。

你這麼想:既然 NSWindow 會在 NSTokenFieldCell 要求 Field Editor 的時候,傳回 NSTokenTextView ,所以我們不但要 subclass NSTokenField 而已,連 NSTokenFieldCell 與 NSTokenTextView 都要產生 subclass,這樣在我們的 NSTokenTextView 收到 draggingEntered: 的時候,就要先呼叫 NSTokenTextView 的 delegate,也就是一個 NSTokenFieldCell,NSTokenFieldCell 的 delegate 如果是 NSTokenField,這樣就可以通知到 NSTokenFiel 了。所以,哇!要改四個地方-NSWindow delegate、NSTokenFieldCell subclass、NSTokenFieldCell subclass、NSTokenTextView subclass,而說到要繼承 private 物件,是不是還要先用 class-dump 把 header 先dump 出來…。

最後我決定這麼做-在我的 NSTokenField subclass 收到 draggingEntered: 之後,直接在上面多疊一層 view,讓這個 view 收 Drag and Drop 事件;如果 NSTokenField 上面疊了一層 Field Editor,就把這個 view,疊在 Field Editor 上面。

 

 

  • 0
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值