面向初学者的 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_ 完全包围形状。