跳轉到內容

面向初學者的 Cocoa Mac OS X 程式設計/Wikidraw 實現

來自華夏公益教科書,開放世界的開放書籍

上一頁: 基於文件的應用程式 | 下一頁: Wikidraw 的檢視類

到目前為止,我們已經用很少的程式碼完成了很長的路程。但是,我們的應用程式仍然沒有做太多事情,看起來也不太像一個真正的繪圖程式。所以現在我們將編寫應用程式的核心程式碼,它實現了我們可以繪製的實際形狀。我們透過設計將很多功能推遲到這個類,所以這將是一箇中等複雜的物體。但是,一旦我們完成,我們將擁有非常接近繪圖程式的東西!

到目前為止,我們一直使用 Interface Builder 為我們建立骨架程式碼檔案。這次,我們必須手動完成。在 XCode 中,找到檔案 'WKDDrawView.m' 並選擇它。然後選擇檔案->新建檔案... 在助手程式中,找到並選擇 'Objective-C 類',然後單擊下一步。將檔名更改為 'WKDShape.m',然後單擊完成。一個新的 .h 和 .m 檔案將被新增到 '類' 列表中。您將看到 WKDShape 是 NSObject 的子類,這是理想的。

形狀類設計

[編輯 | 編輯原始碼]

每個形狀都將是 WKDShape 類的例項,但每種形狀都將是子類,例如 WKDRectShape 和 WKDOvalShape。在 Shape 類本身中,我們需要儘可能地新增所有形狀共有的功能。事實上,我們會看到這幾乎是所有功能。

每個形狀需要哪些屬性?有一些圖形屬性,例如填充和描邊形狀的顏色,以及形狀是否具有描邊或填充,以及描邊的粗細。還有一些幾何屬性,例如形狀的位置和大小,如果我們正在實現旋轉,則還有旋轉角度(為了簡單起見,我們不會這樣做,但您可能希望考慮如何將其作為進一步的練習新增)。我們不需要包含的狀態資訊,例如物件是否被選中 - 它不需要知道,並且已經被文件/檢視組合處理。

Cocoa 在 NSColor 物件中體現顏色,因此我們可以使用它們來指定我們的描邊和填充顏色。我們可以使用任一者的缺失(nil 值)來表示不執行描邊或填充,從而使我們擁有空心和實心形狀。描邊粗細是一個簡單的浮點數。物件的位置可以使用 NSPoint 指定,大小可以使用 NSSize 指定。我們將決定物件的位置指的是其邊界框的左上角。NSRect 包含 NSPoint(指定矩形的原點)和 NSSize(用於其寬度和高度),這正是我們需要的。形狀被繪製以使其適合這個邊界框矩形。

所以讓我們新增這些資料成員

@interface WKDShape : NSObject
{
	NSRect		_bounds;
	NSColor*	_strokeColour;
	NSColor*	_fillColour;
	float		_strokeWidth;
}

@end

這些立即表明了我們需要的第一個方法集,它們只是設定和獲取這些屬性。為了方便起見,我們還定義了一些用於獨立設定位置和大小的方法。我們還從開發文件類中知道我們需要一個 'containsPoint' 方法,它返回一個 BOOL 值

- (NSRect)          bounds;
- (void)            setBounds:(NSRect) bounds;
- (void)            setLocation:(NSPoint) loc;
- (void)            offsetLocationByX:(float) x byY:(float) y;
- (void)            setSize:(NSSize) size;
- (BOOL)            containsPoint:(NSPoint) pt;

- (NSColor*)        fillColour;
- (void)            setFillColour:(NSColor*) colour;
- (NSColor*)        strokeColour;
- (void)            setStrokeColour:(NSColor*) colour;
- (float)           strokeWidth;
- (void)            setStrokeWidth:(float) width;

現在讓我們考慮一下我們需要形狀物件做什麼。最明顯的事情是繪製自身,所以我們需要一個 'draw' 方法。這將被檢視依次呼叫,用於每個物件。此外,檢視將通知我們是否被選中,因此我們需要一種將此資訊傳遞進去的方法,以便我們可以相應地繪製。

接下來是使用者與物件的互動。檢視將向我們傳遞點選和拖動,允許使用者直接調整物件大小或重新定位物件。我們可以根據滑鼠點最初單擊的位置自行確定要執行的操作。檢視透過實現三個方法來處理滑鼠操作,分別用於滑鼠按下、滑鼠拖動和滑鼠抬起。我們可以在這裡遵循相同的模型,這使得檢視非常容易將這些東西傳遞給我們。我們需要跟蹤我們正在執行的操作(調整大小或重新定位),以便當我們收到拖動訊息時,我們繼續做正確的事情!我們將使用一個簡單的整數資料成員來跟蹤此狀態。

當一個物件被選中時,它將使用其邊緣周圍的“選擇手柄”繪製。如果我們拖動手柄,我們希望形狀的大小發生變化。如果我們拖動物件本身,它應該保持相同的大小,但移動到新的位置。當拖動手柄時,相對的手柄變成調整大小的“錨點”,因此我們需要一種區分手柄的方法來確定使用哪個錨點。我們將使用我們已經用於此目的的整數資料成員 - 其值將設定為我們最初單擊的手柄的編號。我們還需要記錄最初的滑鼠單擊位置,以便我們可以計算出事物移動了多遠。我們將為繪製手柄提供一個單獨的方法,作為合理的程式碼分解,以及一些用於繪製和命中測試每個手柄的實用方法。

最後,我們需要允許子類實際提供正在繪製的形狀的詳細資訊。我們可以依靠子類根據需要重新實現 draw,但更好的方法是使子類更小更簡單,就是要求形狀的路徑作為 NSBezierPath 物件,然後在公共形狀物件中實現所有其他內容。子類需要做的就是使用 bounds 的當前值來返回適當的路徑,無論何時被要求。

所以讓我們新增這些方法

- (void)            drawWithSelection:(BOOL) selected;
- (void)            drawHandles;
- (void)            drawAHandle:(int) whichOne;
- (int)             handleAtPoint:(NSPoint) pt;
- (NSRect)          handleRect:(int) whichOne;
- (NSRect)          newBoundsFromBounds:(NSRect) old forHandle:(int) whichOne withDelta:(NSPoint) p;

- (void)            mouseDown:(NSPoint) pt;
- (void)            mouseDragged:(NSPoint) pt;
- (void)            mouseUp:(NSPoint) pt;

- (NSBezierPath*)   path;

我們還添加了一個 int 資料成員 _dragState 和一個 NSPoint 資料成員 _anchor。

當我們開始繪製形狀時,它將被建立並新增到我們在單擊的點處的繪圖中。然後它將處於與現有物件被調整大小時的完全相同的狀態,因此建立和以後編輯之間沒有區別 - 我們只是安排事情以便新物件在滑鼠下建立。檢視將處理這個問題。

形狀實現

[編輯 | 編輯原始碼]

現在我們可以實現 WKDShape 的方法。我們將提供一個預設的路徑方法,以便使用非子類化的形狀物件可以工作 - 事實上,我們可以讓通用形狀物件處理簡單的矩形情況。

將方法原型剪下並貼上到 WKDShape.m 檔案中。以下是一個快速技巧來擴充套件它們,這在當前階段將起作用,因為還沒有編寫任何程式碼。透過將尾隨分號替換為一對花括號和額外的行來擴充套件一個方法。選擇並複製花括號和額外的行。開啟查詢/替換對話方塊,對分號執行全部替換,並將您複製的行(將行貼上到替換欄位中)。這將用空方法體和額外的行替換所有分號,從而將所有方法原型擴充套件為完整方法。

以下是屬性方法和初始化的實現

- (id)			init
{
	if ((self = [super init]) != nil )
	{
		[self setFillColour:[NSColor whiteColor]];
		[self setStrokeColour:[NSColor blackColor]];
		[self setStrokeWidth:1.0];
		_bounds = NSZeroRect;
		_dragState = 0;
		_anchor = NSZeroPoint;
	}
	return self;
}


- (void)		dealloc
{
	[_fillColour release];
	[_strokeColour release];
	[super dealloc];
}


- (NSRect)		bounds
{
	return _bounds;
}


- (void)		setBounds:(NSRect) bounds
{
	_bounds = bounds;
}


- (void)		setLocation:(NSPoint) loc
{
	_bounds.origin = loc;
}


- (void)		offsetLocationByX:(float) x byY:(float) y
{
	_bounds.origin.x += x;
	_bounds.origin.y += y;
}


- (void)		setSize:(NSSize) size
{
	_bounds.size = size;
}


- (BOOL)		containsPoint:(NSPoint) pt
{
	return NSPointInRect( pt, [self drawBounds]);
}



- (NSColor*)	fillColour
{
	return _fillColour;
}


- (void)		setFillColour:(NSColor*) colour
{
	[colour retain];
	[_fillColour release];
	_fillColour = colour;
}


- (NSColor*)	strokeColour
{
	return _strokeColour;
}


- (void)		setStrokeColour:(NSColor*) colour
{
	[colour retain];
	[_strokeColour release];
	_strokeColour = colour;
}


- (float)		strokeWidth
{
	return _strokeWidth;
}


- (void)		setStrokeWidth:(float) width
{
	_strokeWidth = width;
}

這些應該很簡單。在我們的 init 方法中,我們呼叫我們自己的 set...Colour 方法,將預設顏色設定為白色填充和黑色邊框,並將描邊寬度設定為 1.0,並將其他資料成員設定為零。NSZeroPoint、NSZeroSize 和 NSZeroRect 都是 Cocoa 中表示所有成員為零的這些結構的方便常量。請注意,我們的 set...Colour 方法在釋放之前保留。我們保留這些物件,因為我們在形狀物件的 data 成員中建立了對它們的新的引用。

接下來是 drawWithSelection: 方法

- (void)		drawWithSelection:(BOOL) selected
{
	NSBezierPath* path = [self path];
	
	if ([self fillColour])
	{
		[[self fillColour] setFill];
		[path fill];
	}
	
	if ([self strokeColour])
	{
		[[self strokeColour] setStroke];
		[path setLineWidth:[self strokeWidth]];
		[path stroke];
	}
	
	if ( selected )
		[self drawHandles];
}

首先,我們呼叫我們自己的 path 方法來獲取形狀的路徑。WKDShape 的子類將覆蓋此方法以向我們提供其他形狀,但預設方法將為我們提供一個基本矩形。獲得路徑後,我們可以根據是否有相應的顏色對其進行描邊和填充 - 缺少顏色意味著不要執行該操作。最後,我們使用傳遞給我們的選擇標誌來確定是否應該繪製選擇手柄。讓我們看看手柄是如何繪製的。

- (void)		drawHandles
{
	int h;
	
	for( h = 1; h < 9; h++ )
		[self drawAHandle:h];
}


- (void)		drawAHandle:(int) whichOne
{
	NSRect  hr = [self handleRect:whichOne];
	[[NSColor redColor] set];
	NSRectFill( hr );
}

drawHandles 方法迭代一個簡短的迴圈,為每個手柄呼叫 drawAHandle。我們使用 1 到 8 的數字來識別每個手柄,其中 1 表示左上角手柄,2 表示頂部中心手柄,依此類推,按順時針方向圍繞形狀的邊緣。編號是任意的,但我們需要保持一致。我們沒有使用零作為手柄 ID,因為我們在其他地方使用 0 表示“拖動整個物件”,而不是拖動特定手柄。這個方案非常簡單,儘管可以設計出其他類似的編號方案,這些方案可能會使以後的手柄程式碼更加緊湊。但是,對於此練習,這完全足夠了,並且可以正常執行。

drawAHandle 方法呼叫 handleRect: 來獲取一個表示手柄的矩形,然後簡單地使用鮮紅色對其進行塊填充。

- (NSRect)		handleRect:(int) whichOne
{
	NSPoint	p;
	NSRect	b = [self bounds];
	
	switch( whichOne )
	{
		case 1:
			p.x = NSMinX( b );
			p.y = NSMinY( b );
			break;
	
		case 2:
			p.x = NSMidX( b );
			p.y = NSMinY( b );
			break;
	
		case 3:
			p.x = NSMaxX( b );
			p.y = NSMinY( b );
			break;
	
		case 4:
			p.x = NSMaxX( b );
			p.y = NSMidY( b );
			break;
	
		case 5:
			p.x = NSMaxX( b );
			p.y = NSMaxY( b );
			break;
	
		case 6:
			p.x = NSMidX( b );
			p.y = NSMaxY( b );
			break;
	
		case 7:
			p.x = NSMinX( b );
			p.y = NSMaxY( b );
			break;
	
		case 8:
			p.x = NSMinX( b );
			p.y = NSMidY( b );
			break;
	}
	
	b.origin = p;
	b.size = NSZeroSize;
	return NSInsetRect( b, -kHandleSize, -kHandleSize );
}

HandleRect: 由一個大型 switch 語句組成,該語句根據形狀的當前邊界設定手柄的位置。這很容易理解,但有點笨拙 - 我們可以使用位域將共享一側的手柄組合成一個更緊湊的函式,但這個函式在乍看之下會更難理解。這種方法要容易得多。我們使用 Cocoa 的實用程式函式 NSMinX、NSMinY、MSMidX、NSMaxY 等來獲取邊界矩形的特定角,我們將在此處定位手柄。最後,我們將這些點擴充套件成一個小的矩形並將其返回。

現在讓我們看看我們如何處理與滑鼠的互動。當滑鼠單擊時,首先發生的事情是檢視確定哪個形狀(如果有的話)被命中,並將單擊傳遞到其 mouseDown 方法

- (void)		mouseDown:(NSPoint) pt
{
	_dragState = [self handleAtPoint:pt];
	_anchor = pt;
}

我們所做的只是呼叫 handleAtPoint: 來檢視是否有任何手柄被命中。如果一個手柄被命中,則返回其編號並存儲在 _dragState 中。如果沒有任何手柄被命中,則返回 0。這意味著“拖動整個物件”。我們可以確定我們是否被命中,因為如果我們沒有被命中,此方法甚至不會被呼叫。我們還在 _anchor 中記錄了最初的滑鼠單擊點。在此階段,我們只確定後續拖動將執行的操作 - 我們不需要實際執行任何操作。讓我們看看 handleAtPoint: 如何工作。

- (int)			handleAtPoint:(NSPoint) pt
{
	int		h;
	NSRect	hr;
	
	if ([self bounds].size.width == 0 && [self bounds].size.height == 0 )
		return 5;
	else
	{
		for ( h = 1; h < 9; h++ )
		{
			hr = [self handleRect:h];
			
			if (NSPointInRect( pt, hr ))
				return h;
		}
	}
	return 0;
}

這很簡單。我們遍歷控制代碼,使用之前用於繪製的 handleRect 獲取每個控制代碼的矩形。如果矩形包含該點,我們立即返回控制代碼的索引號。否則,如果我們找不到滑鼠點選的任何控制代碼,我們返回 0。那麼,這個檢查大小的另一部分是什麼?這有點像駭客技術,它簡化了其他地方的程式碼。當一個物件最初建立時,它的大小為零,並且放置在滑鼠下方。從那時起,物件的建立過程與編輯現有物件的過程相同。但是,在建立時,我們需要欺騙系統認為我們正在拖動形狀的右下角,以便使用者獲得預期的行為。右下角控制代碼的索引號為 5,因此如果我們的大小為零,我們將立即返回 5,就好像點選了這個控制代碼一樣。

在檢視呼叫我們的 mouseDown 方法後,它將不斷呼叫我們的 mouseDragged 方法,直到使用者釋放按鈕,此時它將呼叫我們的 mouseUp 方法。

- (void)		mouseDragged:(NSPoint) pt
{
	NSPoint np;
	
	np.x = pt.x - _anchor.x;
	np.y = pt.y - _anchor.y;
	_anchor = pt;
	
	if ( _dragState == 0 )
	{
		// dragging the object
		[self offsetLocationByX:np.x byY:np.y];
	}
	else if ( _dragState >= 1 && _dragState < 9 )
	{
		// dragging a handle
		
		NSRect nb = [self newBoundsFromBounds:[self bounds] forHandle:_dragState withDelta:np];
		[self setBounds:nb];
	}
}


- (void)		mouseUp:(NSPoint) pt
{
	_dragState = 0;
}

mouseDragged:首先計算滑鼠自上次移動的距離。_anchor 用於儲存上一次滑鼠位置,因為我們只需要從它減去新點的座標,然後將 _anchor 更新到新點以備下次使用。

接下來,我們檢視 _dragState。如果它為零,則表示我們正在拖動整個物件,因此我們可以使用 offsetLocationByX:byY: 使用我們計算的 delta 將我們的位置簡單地偏移。否則,如果 _dragState 是控制代碼索引值之一,我們知道我們正在拖動一個控制代碼,以及它是什麼。我們委託給另一個方法 newBoundsFromBounds:forHandle:withDelta: 來計算給定舊邊界、被拖動的控制代碼索引號和控制代碼的 delta 偏移量我們的新邊界矩形將是什麼。它返回重新計算的邊界,因此我們可以簡單地呼叫 setBounds 使其生效。

mouseUp:這裡不需要做太多事情——它只是將 _dragState 設定為 0。這不是絕對必要的,但我們這樣做只是以防萬一有人正在使用此資訊——因為我們已經完成了拖動,所以我們忘記了我們拖動了哪個控制代碼(如果有的話)。

newBoundsFromBounds:forHandle:withDelta: 的程式碼在確定控制代碼拖動如何影響邊界方面做了大部分的努力。它在這裡

- (NSRect)		newBoundsFromBounds:(NSRect) old forHandle:(int) whichOne withDelta:(NSPoint) p
{
	// figure out the desired bounds from the old one, the handle being dragged and the new point.
	
	NSRect nb = old;
	
	switch( whichOne )
	{
		case 4:
			nb.size.width += p.x;
			break;
			
		case 6:
			nb.size.height += p.y;
			break;
			
		case 2:
			nb.size.height -= p.y;
			nb.origin.y += p.y;
			break;
			
		case 8:
			nb.size.width -= p.x;
			nb.origin.x += p.x;
			break;
			
		case 1:
			nb.size.width -= p.x;
			nb.origin.x += p.x;
			nb.size.height -= p.y;
			nb.origin.y += p.y;
			break;
			
		case 3:
			nb.size.height -= p.y;
			nb.origin.y += p.y;
			nb.size.width += p.x;
			break;
			
		case 5:
			nb.size.width += p.x;
			nb.size.height += p.y;
			break;
			
		case 7:
			nb.size.width -= p.x;
			nb.origin.x += p.x;
			nb.size.height += p.y;
			break;
	}

	return nb;
}

類似於 handleRect:,它使用 switch 語句來確定根據控制代碼索引移動矩形的哪些邊。這很簡單——如果右側或底側邊移動,則可以將 delta 簡單地新增到寬度或高度。如果左側或頂側邊移動,我們需要調整寬度或高度以及矩形的原點位置。

這基本上就是了。當我們檢視檢視的工作原理時,我們會發現我們需要在這裡新增一兩個額外的次要方法來幫助進行操作,但我們稍後再擔心。這裡要看的最後一件事是 path 的實現。您會記得,這是子類旨在覆蓋以提供我們將要繪製的特定形狀的內容。預設版本如下所示

- (NSBezierPath*)	path
{
	return [NSBezierPath bezierPathWithRect:[self bounds]];
}

它所做的只是使用 NSBezierPath 的工廠方法從矩形(在本例中為我們的邊界)建立路徑。返回的路徑已經是自動釋放的,因此我們可以直接使用它並忘記它,這就是我們的 drawWithSelection: 方法所做的。其他形狀物件可以返回它們想要的任何路徑,只要它們遵守我們在此定義的唯一規則即可——即 _bounds_ 完全包圍形狀。

上一頁: 基於文件的應用程式 | 下一頁: Wikidraw 的檢視類

華夏公益教科書