怎樣限制 Mac OS X 表格中編輯區中的輸入字數

這篇文字來自 22 日晚間我在台北 Cocoaheads 活動中所分享的一個題目。

在講完之後,有些朋友覺得講得速度似乎有點快,而我回來之後看了一下自己的投影片,總感覺如果只是將投影片放在網路上,可能根本不知道我在說什麼,而更大的問題是,回來之後又做了一點小研究,發現有個地方講錯。看來,要在網路上分享這則故事,還是要換成比較詳盡的文字。

楔子

故事是這樣的。

一兩個月前,公司正在承接製作國內某知名輸入法廠商的 Mac OS X 版本的工作,專案的其中一環,是要製作一個使用表格輸入介面的自定字詞編輯工具-基本上就是在主視窗當中有一個表格,第一欄是自定詞的拆碼(或字根),第二欄則是這組拆碼所對應到的字詞。-當你在使用 Cocoa Framework 開發的時候,要使用表格介面,你當然會毫不猶疑的使用 NSTableView 物件。

因為這套輸入法的規則是,所有文字的打法最大只會有五碼,所以,在輸入或修改拆碼這個欄位的時候,如果使用者已經打了五個字元,就不應該繼續輸入。這件事情在 Windows 上面很簡單-如果你是寫一個 .Net 程式,你只要寫上一行,設上 DataGridViewTextBoxColumn.MaxInputLength (MSDN 文件)就好了,還可以在 IDE 中直接使用 GUI 設定;但是,在 NSTableView 的文件中,則是完全找不到對應的設定。

另外,客戶也要求,在按下 Enter 的時候要寫入資料,按下 ESC 按鍵的時候,則是要取消編輯狀態。如果是在 Mac OS X 10.5 上面,用 Interface Builder 拉好了一個 NSTableView,基本上這個 table view 在編輯狀態時,按下 Enter 就會完成編輯,這是我們要的,然而,如果按下 ESC 按鍵,預設卻是出現 Auto-complete 選單,幫你做些英文拼寫檢查之類的;至於在 10.4 上面,則是無論按下 Enter 或是 ESC,都不會理你…。

顯然在這個部分,也需要對 NSTableView 做一定的客製。

為了完成任務,第一步,就是先對 NSTableView 做一些小小的研究。

NSTableView

對於 NSTableView 的最基本的操作,當然就是要準備好一個給 NSTableView 使用的 delegate 與 data source 物件。

這邊簡單說一下 Cocoa 在處理物件之間通訊的常用技巧-Framework 所提供的絕大多數物件都會提供一個 delegate(當你撰寫自己的 Class 的時候,也會往往情不自禁的設計上去就是了),中文姑且稱之為「代理」,delegate 的用途就是-「當 A 把 B 設為代理人之後,每當 A 做了某件事情,A 就會告訴 B,B 應該要因為 A 做了這件事情所以應該去做另外一件事情。」比方說,我使用 一個 WebView 寫一個簡單的瀏覽器介面,我把這個 WebView 的 delegate 設定為 myController 這個物件,所以,當 WebView 發現自己載入網頁失敗的時候,就會告訴 myController 應該怎麼辦,myController 就會決定,嗯,我們應該跳出一個提示視窗。

Data source 則反之,是某個物件需要顯示資料的時候,去問這個設定為 data source 的物件,到底應該顯示什麼資料。以 NSTableView 來說,data source物件需要準備好對應的接口,告訴 NSTableView 在表格中的資料總共有幾列,哪一列哪一欄當中是怎樣型別-字串或是圖形-的資料。

而當使用者編輯完表格中某一格資料時,NSTableView 會呼叫一個 method,告訴 data source 在某一欄某一列中的資料已經有了變化-

tableView:setObjectValue:forTableColumn:row:

於是我在 NSTableDataSource 物件中寫了這樣的 code…

- (void)tableView:(NSTableView *)aTableView setObjectValue:(id)anObject forTableColumn:(NSTableColumn *)aTableColumn row:(NSInteger)rowIndex
{
	if ([[aTableColumn identifier] isEqualTo:@"column0"]) {
		NSString *s = (NSString *)anObject;
		if ([s length] > 5) {
			NSString *subString = [s substringToIndex:5];
			NSMutableDictionary *d = [_myArray objectAtIndex:rowIndex];
			[d setValue:subString forKey:@"key"];
 		}
	}
}

專案一開始總是求快,就直接在這個地方,在收到改過的字串的時候,只留下前五個字元存起來便是。這種作法顯然不符合預期-我們想要的是打滿五個字就不能輸入,而不是容許使用者打出一大串字,在編輯完成的時候才裁掉字串。

要處理各種 Cocoa UI 物件-如目前正在討論的 NSTableView-當中的資料,除了提供 data source 物件之外,另外一種方式則是使用 Cocoa Binding,而在 Binding 的過程中,也可以使用 NSValueTransformer,但是 NSValueTransformer 所做的事情,一樣也是讓使用者先打好一大串文字,寫入的時候轉換,不是我們要的。

至於在處理 Enter 與 ESC 按鍵的部分,第一個想到的,是自己 subclass NSTableView,在自己的 subclass 中,覆蓋繼承自 NSResponder、用以接收鍵盤事件的 keyDown: method,像是這樣的程式-

- (void)keyDown:(NSEvent *)theEvent {
	if ([theEvent keyCode] == 53) {
		//ESC
		// ....
	}
	else if ([[theEvent charactersIgnoringModifiers] characterAtIndex:0] == 13) {
		//Enter
		// ....
	}
}

沒用。當表格處於編輯狀態的時候,事件根本沒有送到我們的 keyDown method 裡頭,只會在表格被選起來(周圍有一圈藍色發光的 focus ring 的那個狀態)卻不是編輯文字的時候有用。在做了這麼一點小研究之後,可以確定-我們要處理的不是 NSTableView 本身,而是用來編輯文字的那個編輯區本身那個物件,用蘋果的術語來說,那個編輯區叫做 Field Editor

Field Editor

Cocoa 是這樣設計的-就算你在同一個視窗當中,放置了好幾個型別為 NSTextField 的文字框,但是其實同一時間只會有一個文字框在負責文字輸入、編輯的工作-原因無他,就算有很多文字框,使用者一次也只會在一個文字框中打字,所以,在平常的狀況下,文字框其實只要負責把一段文字畫在螢幕上就夠了,等到使用者需要在這個文字框中打字,文字框才去跟 NSWindow 物件要求一個整個視窗共用的、具有編輯功能的文字輸入框,放在這個 NSTextField 物件的顯示範圍裡頭。這個為整個視窗中所有繼承自 NSControl 的物件所共用的編輯區,就叫做 Field Editor。

根據 header,Field Editor 是從 NSWindow 的 fieldEditor:forObject: method 中產生,回傳的型別是 NSText-當時因為年幼無知,年紀小不懂事,不知道讀文件應該要整個讀仔細,光看到 header 的型別是 NSText,就做了一個 NSText subclass,裡頭只實作一個 keyDown: method,用來抽換 NSWindow 產生的 Field Editor。然而,Xcode 便如此告訴我-

All NSText methods must be implemented by subclasses. They should not call super.

耶-怎麼會一繼承下去,所有的 method 都要重新實作?而一看 NSText.h,嚇,裡頭定義的 method 總共大約七十來個,當場打消了 subclass NSText 的念頭,來研究看看 NSText 有什麼 delegate method,看看是不是可以在 delegate 物件這一端處理問題。

如前述,就因為讀文件不仔細,所以接下來這個嘗試也是失敗的。

Field Editor 的 delegate

看了看 textDidChange: 這個 method 應該就是我要的-這個 method 的用途是,當文字編輯區中的文字內容有改變時,就告訴 delegate 物件應該要做點事情;當一個 NSControl 物件處於編輯狀態的時候,Field Editor 的 delegate 會設為這個 NSControl,所以,我們來 subclass 一個 NSTableView,就可以收到文字是否有修改的訊息。

也有另外一種方法,NSControl 也有 delegate,而 NSControl 又會把自己收到的 textDidChange:controlTextDidChange: 傳遞出去,所以我們也可以在 NSControl 的 delegate 處理-就像是我不在我的戶籍地,但是里長可以先通知我父親,我父親再通知我去領消費券,而不需要我在我父親那邊留一筆任務是他一定要代替我領消費券(頂爛的比喻)。

寫了一小段程式,在收到文字編輯區的文字內容有變動的通知時,自動截掉第五個字元之後的內容-效果頗糟,原因是,使用者輸入文字時,輸入游標不一定是在文字的結尾,所以當原本的內容是「12345」時,我將游標移動到 2 與 3 之間,再打一個 A,原本預期的是不會有影響,但是這個作法卻是內容變成「12A34」…。

重新釐清目標-不是在輸入後才改變輸入的結果,而是在輸入的同時就決定要不要處理鍵盤事件。

但是 NSText 的 delegate method 卻解決了 Enter 鍵與 ESC 鍵的問題。程式碼如下:

- (BOOL)control:(NSControl*)control textView:(NSTextView*)textView doCommandBySelector:(SEL)commandSelector
{	
	if ([[textView delegate] isKindOfClass:[NSTableView class]]) { 
		// 型別為 NSTableView
		if (commandSelector == @selector(insertNewline:)) {
			// Enter
			[[textView window] endEditingFor:textView];
			[[textView window] makeFirstResponder:[textView delegate]];
			return YES;
		} 
		else if (commandSelector == @selector(cancelOperation:)) {
			// ESC
			[[textView delegate] abortEditing];
			[[textView window] makeFirstResponder:[textView delegate]];	
			return YES;
		}	
	}
	if ([textView respondsToSelector:commandSelector]) {
		[textView performSelector:commandSelector withObject:nil];
		return YES;
	}
	return NO;

}

附註:我在這邊只有檢查發生文字有改變的 NSControl 物件是不是 NSTableView,如果您要拿這段程式去用,您還是需要檢查是不是你要的那個物件,畢竟您有可能把很多個 NSTableView 的 delegate 指定到相同的物件上。

[NSWindow sendEvent:(NSEvent *)event]

一想到要解決問題是要讓事件不要送出,一時之間又搞不清楚怎樣 subclass NSText 物件收事件,當時想到的方法就是-我要攔截事件,卻搞不定收事件的這一段,那,就從發事件的那一端著手。

Mac OS X 在收到各種(鍵盤、滑鼠)事件後,處理的順序為-先去尋找哪一個是目前使用者正在用的應用程式,把事件送過去,應用程式再判斷使用者正在使用那個視窗,把事件送到視窗(NSWindow)去,視窗再判斷哪一個是使用者正在使用的 UI 元件如按鈕、文字框等,再把事件送過去。我們想要在 NSTableView 收到事件前攔截事件,那就是在視窗將事件送過去時攔截,NSWindow 負責傳遞事件的 method 就叫做 sendEvent:,的確是非常容易顧名思義。

一試之下居然可以了…。

- (void)sendEvent:(NSEvent *)event
{
	if ([event type] == NSKeyDown) {
		if (isprint([[event charactersIgnoringModifiers] characterAtIndex:0])) {
			// 英數字母
			NSText *text = [self fieldEditor:YES forObject:nil];
			if ( != nil &&  // 有人正在用 FieldEditor
				[ isKindOfClass:[NSTableView class]]) {
				// Field Editor 正被 NSTableView 使用
				NSTableView *tableView = ;
				// 我習慣重新宣告型別
				if ([tableView editedColumn] == 0 && 
					// 在編是我們想要的那一列
					[ length] >= 5 && 
					// 已經有五個字
					!.length) {
					// 沒有選擇範圍
					NSBeep(); // 發出錯誤聲
					return; // 不處理
				}
			}
		}
	}
	[super sendEvent:event];
}

如前所述,這邊我也只是單純判斷在使用 Field Editor 的物件的型別而已;另外就是在判斷文字編輯區的內容時,還要加上「有沒有選擇範圍」這個條件-就算打滿了,但是有一部分文字被選起來,還是要可以送出鍵盤按鍵,當你打了「ABCDE」,全選,之後再打一個「A」,預期效果不該是不出字,而是全部內容換成「A」。

對 Enter 與 ESC 的處理,也可以用相同的作法。

- (void)sendEvent:(NSEvent *)event
{
	if ([event type] == NSKeyDown) {
		if ([event keyCode] == 53) {
			// ESC
			NSText *text = [self fieldEditor:YES forObject:nil];
			if ( != nil && 
				// 有人正在用 FieldEditor
				[ isKindOfClass:[NSTableView class]]) {
				// FieldEditor 正被 NSTableView 使用
				NSTableView *tableView = ;
				[tableView abortEditing];
				// 取消編輯
				[self makeFirstResponder:tableView];
				// 把焦點從 FieldEditor 轉回 TableView
				return;
			}
		}
	}
	[super sendEvent:event];
}

既然能動,產品就做出來了。所以可以回頭來看-那個不能呼叫 super 的 NSText subclass 是怎麼一回事啊?

結語

其實在蘋果的在 NSTextNSWindow 的文件就說得很清楚,雖然 fieldEditor:forObject: 回傳的型別是 NSText,但是 Field Editor 的型別卻是繼承自 NSText 的 NSTextView,所以,之所以直接繼承自 NSText 的型別需要自己實作所有的 method,就是因為 NSText 就只是抽象定義,沒有實作,所有實作都在 NSTextView 嘛…。

所以要用自己的 subclass 收 keyDown: 事件,就是不要繼承 NSText,改成繼承 NSTextView 就好了(嘆)。但是想想這樣寫好像也沒有比較好-在 NSWindow 的 sendEvent: 那端改,只要 subclass NSWindow 就好,至於抽換 Field Editor,一方面要 subclass NSWindow,另一方面也要 subclass NSTextView,反而要生出兩個 subclass,對於我這種懶人而言,好像還更麻煩。

但不管用什麼作法,都可以得到以下結論-為什麼我要改一個表格的行為,不管怎樣,都要從視窗物件改起啊?更況且,在 Windows 上面只要寫一行,在 Mac OS X 上面,卻變成一則不太想要統計字數的故事。

4 thoughts on “怎樣限制 Mac OS X 表格中編輯區中的輸入字數

  1. 大師,我現在要控制TableView欄位的可輸入長度。sendEvent要寫在哪啊?看起來是覆寫NSTableView所處的NSWindow?簡單說我不知道如何觸發sendEvent。Please advise ! Thanks !

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.