NSWindow,一些真的非常討厭的

所有跟 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 上面。

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.