为初学者用 Cocoa 编程 Mac OS X/Wikidraw 的视图类
我们已经有了基本的形状类和一个用来存储它们的文档结构。拼图的最后一块是视图,它将所有内容整合在一起,允许用户交互式地创建绘图。让我们考虑一下它的设计。
一个通用的 NSView 提供了两个主要的功能 - 在窗口中绘制图形的能力,我们已经讨论过,以及处理用户鼠标的能力,这是一个新的东西。我们已经讨论过,这分为三个部分 - 鼠标按下事件、一系列鼠标拖动事件和鼠标抬起事件。我们的形状类已经准备好处理这些事件,当它们从我们的视图中传递过来时。
然而,视图需要做的不仅仅是传递这些事件,因为它还需要处理用户交互的其他方面,例如绘图中会有很多不同的形状,以及需要在用户点击时管理对它们的选中。
此外,形状的创建将取决于当前在工具调色板中选择的工具(我们还没有设计)。由于形状的创建也是通过使用鼠标和在视图中绘制来完成的,因此视图需要提供一种方法来区分不同的工具。
在绘制方面,视图需要渲染整个绘图,并确定是否选中了对象。虽然形状本身处理它自己的实际渲染,但视图必须确保调用每个对象的 drawWithSelection: 方法,并将选中状态传递过去。
视图从文档中获取信息在哪里?我们将设置以便视图能够调用文档作为委托。我们通过文档的 objects 和 selection 方法建立了用于获取对象和选中的非正式协议。只要我们设置了有效的委托,视图就可以使用这个非正式协议调用它,并获取它需要的信息。NSView 默认没有委托,因此我们需要自己添加。在 WKDDrawView 类中添加一个 _delegate 数据成员,类型为 'id'。然后添加设置和获取它的方法 - setDelegate 和 delegate。注意,在这种情况下,视图不需要保留它的委托 - 这是一个对创建对对象的新引用的通常规则的例外。在这种情况下没问题,因为我们知道文档和视图将始终一起创建和销毁。
- (void) setDelegate:(id) aDelegate { _delegate = aDelegate; } - (id) delegate { return _delegate; }
现在我们需要确保在创建视图时将委托设置为文档。为此,我们将创建一个 Interface Builder 中的链接,以便文档可以找到视图,然后调用它的 setDelegate 方法,并将自身传递过去。这似乎有点绕弯,但它可以防止视图和文档之间过于相互依赖。
在 MyDocument.h 中,添加一个数据成员,如下所示
IBOutlet id _mainView;
然后保存文件。我们在这里做的是创建一个名为 _mainView 的对象的引用。我们还将其标记为 IBOutlet。之前我们看到 IBAction 的返回值可以用来向 Interface Builder 提供信息。这类似。它告诉 IB 这个特定数据成员可以是一个 _出口_,即与另一个对象的连接的一端,可以使用控制拖动连接方法访问。在 Xcode 中,IBOutlet 被定义为空,因此它不会以任何方式影响代码。
将 MyDocument.h 拖到在 Interface Builder 中打开的 MyDocument.nib 窗口。它将重新解析文件,并获取新的出口。现在我们需要将这个出口连接到窗口的视图(WKDDrawView)。
你可能想知道我们如何做到这一点,因为 MyDocument 类似乎没有在 Interface Builder 中被表示。事实上,这个类是在我们打开文件或选择新建时由 Cocoa 创建的,那么我们如何解决这个问题呢?这就是神秘的“文件所有者”图标发挥作用的地方。拥有 'MyDocument.nib' 的对象实际上是 MyDocument 类。因此,文件所有者代表它。将 MyDocument.h 拖进去后,你会注意到文件所有者图标上有一个小的感叹号。这意味着该对象中存在未连接的出口,这是真的,因为我们刚刚添加了出口 _mainView,但还没有连接它。
从文件所有者控制拖动到窗口内的视图。确保突出显示的是 WKDDrawView,而不是包含它的滚动条。当你松开时,检查器将显示连接,并切换到出口。选择 '_mainView' 并点击连接。保存文件并返回 Xcode。
现在,当实例化 MyDocument 时,数据成员 _mainView 将被自动设置为指向窗口中的 WKDDrawView。因此,我们现在只需要使用这个引用来设置视图的委托。当实例化 MyDocument 时,在初始化过程中调用的最后几个方法之一是 'windowControllerDidLoadNib:'。此时,在 .nib 文件中声明的所有连接(如我们刚刚创建的连接)都保证到位,因此我们只需在这里添加代码
- (void) windowControllerDidLoadNib:(NSWindowController *) aController { [super windowControllerDidLoadNib:aController]; [_mainView setDelegate:self]; }
从现在开始,无论何时视图需要文档中的任何内容,它都会调用它的委托方法来获取文档,以及使用非正式协议来获取它想要的内容。视图不会再知道关于文档的任何信息,反之亦然 - 已经避免了过度耦合。注意,视图可以在另一个使用完全不同的对象但实现相同非正式协议以完成工作的应用程序中使用。这是你可以设计类以便在各种项目中重复使用的一种方法。
我们的视图要做的第一件事是渲染文档的内容。以下代码
- (void) drawRect:(NSRect) rect { [[NSColor whiteColor] set]; NSRectFill( rect ); NSArray* drawList = [[self delegate] objects]; NSArray* selection = [[self delegate] selection]; NSEnumerator* iter = [drawList objectEnumerator]; WKDShape* shape; while( shape = [iter nextObject]) { if ( NSIntersectsRect( rect, [shape drawBounds])) [shape drawWithSelection:[selection containsObject:shape]]; } }
和以前一样,我们首先擦除要重绘的背景部分。接下来,我们使用之前设计的非正式协议向委托请求对象和选中。然后,它只需要遍历对象并绘制每个对象。为了效率,我们还检查刷新矩形是否与对象的边界相交,如果否,我们就不费事 - 这只是避免绘制任何不可见或不受刷新影响的内容,从而加快绘制速度。选中状态是通过查看对象是否在选中数组中来确定的。
眼尖的读者会注意到,在形状对象上调用了一个名为 drawBounds 的方法。我们还没有讨论过这个,但我们需要讨论。如果我们使用形状的边界,我们会发现它绘制的东西的小部分不会总是被擦除,因为有些东西实际上是在 bounds 定义的区域之外绘制的。例如,选中句柄集中在边界上,因此至少一半的句柄在边界之外。同样,当我们描边形状时,bounds 定义了描边线的 _中心_。我们需要考虑到这些,以便我们可以正确地刷新绘制的整个区域。drawBounds 方法返回一个基于 bounds 的矩形,但略微扩展了它
- (NSRect) drawBounds { return NSInsetRect([self bounds], -kHandleSize - [self strokeWidth], -kHandleSize - [self strokeWidth]); }
它考虑了句柄的大小和描边宽度,以便保证所有内容都落在这个区域内。任何简单地重新绘制或刷新对象的绘制都将使用 drawBounds 而不是 bounds,但必须记住,bounds 是形状的严格数学边界。
这就是绘制的全部内容。处理鼠标的情况更加复杂。让我们现在看看它。
- (void) mouseDown:(NSEvent*) evt
{
NSPoint pt = [self convertPoint:[evt locationInWindow] fromView:nil];
_dragShape = [[self delegate] objectUnderMousePoint:pt];
if (_dragShape == nil)
{
_dragShape = [self shapeForCurrentTool];
[[self delegate] addObject:_dragShape];
[_dragShape setLocation:pt];
}
if (([evt modifierFlags] & NSShiftKeyMask) == 0)
{
[[self delegate] deselectAll];
[[self delegate] selectObject:_dragShape];
}
else
{
if ([[[self delegate] selection] containsObject:_dragShape])
{
[[self delegate] deselectObject:_dragShape];
_dragShape = nil;
}
else
[[self delegate] selectObject:_dragShape];
}
[self setNeedsDisplay:YES];
[_dragShape mouseDown:pt];
}
- (void) mouseDragged:(NSEvent*) evt
{
NSPoint pt = [self convertPoint:[evt locationInWindow] fromView:nil];
NSRect update = [_dragShape drawBounds];
[_dragShape mouseDragged:pt];
[self setNeedsDisplayInRect:NSUnionRect([_dragShape drawBounds], update)];
}
- (void) mouseUp:(NSEvent*) evt
{
NSPoint pt = [self convertPoint:[evt locationInWindow] fromView:nil];
[_dragShape mouseUp:pt];
[self setNeedsDisplayInRect:[_dragShape drawBounds]];
_dragShape = nil;
}
涉及三种方法——mouseDown、mouseDragged 和 mouseUp。每个方法都会传递一个 NSEvent 对象,表示触发该调用的鼠标事件。首先我们需要将鼠标坐标从窗口调整到我们的本地视图。NSEvent 始终存储相对于窗口的鼠标坐标,可以使用 locationInWindow 方法提取此信息。NSView 的 convertPoint:fromView: 将转换坐标,fromView: 为 nil 表示从窗口本身转换。
在 mouseDown: 中,一旦我们处于本地坐标系中,就可以使用之前编写的 方法查询文档(委托)以获取鼠标下的对象。这将返回最顶层的命中对象,或返回 nil 表示未命中任何对象。我们将结果存储在一个名为 _dragShape 的数据成员中。这将跟踪我们稍后调用 mouseDragged 和 mouseUp 方法时命中的形状。
如果我们命中空白区域,我们将(暂时)使用它来表示根据当前工具创建新对象。稍后我们将对其进行改进,以实现通过拖动选框(选择框)来选择对象的功能。因此,让我们看看当我们创建一个新的形状对象时会发生什么。首先,我们调用另一个方法 shapeForCurrentTool,以创建适当的形状。这是一个工厂方法,因此返回的对象将被自动释放。我们立即将新对象添加到文档中,并将它的位置设置为我们的鼠标点所在位置。如果我们命中了现有对象,则会跳过创建对象并将其添加到文档中的步骤。
接下来,我们测试 Shift 键是否按下。如果按下,我们希望使用 Shift 选择该项目,这意味着 _不_ 先取消选择所有项目。然后我们选择我们命中的对象。同样,我们使用 Shift 键添加另一个改进——如果 Shift 键按下,我们切换对象的选中状态,否则我们只选中它并取消选择所有其他对象。最后,我们将点击传递到对象的 mouseDown 方法,我们之前已经讨论过。由于我们可能更改了任何数量的位于绘图中任何位置的对象的选中状态,因此我们标记需要刷新整个绘图,以确保所有这些更改立即在屏幕上生效。请注意,这种方法很简单,但并非一定是最优的。一旦绘图变得复杂,每次刷新整个绘图都会变得非常昂贵且缓慢。相反,我们可以跟踪实际更改的对象,并且只刷新这些区域。但是对于这个简单的教程,我们不会尝试通过这样做来使事情复杂化。
mouseDragged: 与之前一样转换坐标,并将拖动传递到由 _dragShape 指定的当前对象,我们在 mouseDown: 中设置了 _dragShape。由于拖动可以移动和调整对象的大小,因此我们需要刷新由对象的新位置及其旧位置影响的区域。因此,我们记录了其边界在拖动之前和之后的状态,将它们组合在一起并刷新该区域。如果我们没有这样做,我们会发现对象在拖动时会在其后面留下难看的痕迹。
mouseDragged: 目前没有尝试处理多个对象可以拖动的情况。在这种类型的大多数应用程序中,如果选择了两个对象并且拖动了一个,则两个对象将一起拖动。作为练习,您可能希望考虑如何修改 mouseDragged: 以实现此功能。
最后,mouseUp: 通过将调用传递到拖动的对象、刷新该位置的屏幕,然后将 _dragShape 设置为 nil 来清理。
处理工具
[edit | edit source]正如我们所见,要添加到绘图中的形状取决于 shapeForCurrentTool。它只需要查看我们选择了哪个工具,并创建正确类型的对象。但是,我们还没有选择工具的界面,因此让我们创建一个界面,以便我们可以实现它。由于工具面板在应用程序中的所有文档中都是通用的,因此我们将其添加到 MainMenu.nib 中。
Cocoa 的关键设计范式之一是它遵守面向对象设计的模型-视图-控制器 (MVC) 方法。到目前为止,我们还没有直接遇到它,但现在必须对此说些什么。MVC 是一个很好的方法,可以将功能分离到 _模型_(管理应用程序处理的实际有意义的数据)和 _视图_(该数据的屏幕上的可视化表示,以及控制它操纵它的控件)之间。位于它们之间的是 _控制器_,它负责将视图映射到数据,反之亦然。到目前为止,我们一直将 MyDocument 用作组合的模型和控制器,但对于工具面板,我们将使用更严格划分的 MVC 方法。
工具面板本身将是视图。我们需要一个控制器来处理面板中工具按钮的选择,并将此信息以所选工具的形式传递出去。模型将只是一个单一变量,存储当前的工具选择。
视图可以在 Interface Builder 中完全创建。控制器可以在 Interface Builder 中部分设置,并辅以我们稍后需要编写的少量代码。首先让我们看一下我们的“模型”。由于我们的 WKDDrawView 将承担根据所选工具创建对象的责任,因此显而易见的方法是让视图在需要知道时简单地询问工具控制器当前的工具是什么。但是,有一个问题。对于视图(在众多可能的视图中只有一个,因为我们可以打开多个文档),没有简单的方法来定位工具控制器,而无需对它有明确的引用。一种解决方案是使用一个全局变量来引用工具控制器,这是一个可行的方案。但是,在面向对象的应用程序中不鼓励使用全局变量,此外,仅设置这个全局变量会以我们希望在本教程中避免的方式使事情复杂化。相反,我们可以在视图类中声明一个静态变量,它将存储所选工具的 ID。作为一个静态变量,任何视图都可以访问它(静态变量就像仅对声明它们的相同文件中的代码可见的全局变量)。因此,此变量包含工具控制器拥有的工具 ID 的副本。通常我们会避免这种情况,因为相同数据的副本需要小心管理以确保它们不会不同步,但在这种情况下,它相当简单。
那么,当用户点击面板按钮时,我们如何同步工具 ID 呢?答案是使用通知。通知是 Cocoa 以不具有明确对象引用的方式传递信息的方式。控制器将在每次工具更改时发布通知。任何对它感兴趣的人都可以订阅该消息并以适当的方式响应。在这种情况下,每个视图都会订阅该消息,但在响应中设置静态变量。这里有些过分——如果有两个视图,每个视图都会收到该消息,并且该变量将被设置为相同的值两次。在这种情况下,我们可以忍受这种行为,因为我们只是在谈论一个简单的整数。
那么,当创建一个新视图时,但工具设置为某个奇数值时会发生什么?视图仍然会知道该工具,因为它是一个静态变量,在所有视图实例之间共享。因此,我们不必担心新视图在可以使用正确的工具之前从未收到通知的情况,就像变量是每个视图的本地变量那样。
好的,让我们开始行动。
在 IB 中打开“MainMenu.nib”。在小部件面板(从左数第四个按钮)中选择 Windows 面板,并将一个面板拖到主窗口中。双击新图标以打开面板。在检查器中,删除窗口的标题。使用“大小”面板将最小宽度设置为 60,最小高度设置为 100。将面板调整为窄而高,并将其放置在屏幕的左上角。使用“属性”检查器以选中“实用程序窗口(仅限面板)”和“非激活面板(仅限面板)”。确保“最小化”和“缩放”框未选中。
切换到“控件”面板,并将一个大方形按钮拖到窗口中。按 Option 键并向下拖动底部的选择手柄,直到您拥有四个垂直排列的按钮。使用 Option 键拖动按钮会创建所谓的按钮 _矩阵_,而不是单个按钮。选择矩阵后,使用属性检查器将行为设置为“单选”。
在新的界面构建器版本中,您通过使用 Option 键拖动按钮来创建按钮列表。然后选择所有按钮,并使用布局 -> 将对象嵌入到 -> 矩阵中。这将创建一个包含所有按钮的矩阵。
依次双击每个按钮,并将每个按钮的行为设置为开/关。请注意,当您浏览按钮时,“标记”字段会为每个按钮提供不同的值。这个标记值将成为我们的工具 ID。完成后,我们将拥有一个粗略但功能齐全的工具面板界面。您可以在 IB 中通过选择文件 -> 测试界面来测试它的操作。验证工具面板是否作为一组单选按钮运行,并且一次只能选择一个,并且您是否可以从突出显示中判断出选择的是哪个。要返回 IB,请选择“退出”。
现在我们需要一个工具面板的控制器。IB 通过实际为我们创建此对象来帮助我们,并创建了一些我们可以稍后添加代码的骨架 .h 和 .m 文件。
在主窗口的“类”选项卡中,找到 NSWindowController 类(它是 NSResponder 的子类)。突出显示 NSWindowController 并选择类 -> 子类 NSWindowController。将添加一个新名称;将其命名为 ToolsController。现在我们可以要求 IB 实际创建此对象,从而为我们提供一个可以连接到视图的实际实例。为此,请突出显示新类,并选择类 -> 实例化 ToolsController。将一个新对象添加到主窗口中,它看起来像一个方框,并命名为 ToolsController。返回到“类”视图(一种快捷方式是双击新对象)。在检查器中,使用“操作”面板添加一个新操作:点击添加,然后键入操作名称 selectionDidChange:(记住冒号!)。这将创建两个操作,应该已经存在一个 showWindow: 操作。
返回到实例。从 ToolsController 对象 _拖动控制_ 到工具窗口(您可以拖动到实际窗口的标题栏,或者直接拖动到主窗口中的图标)。将它连接到“窗口”出口。接下来,从 NSMatrix(四个按钮 - 确保您将四个按钮作为一个集合选中,而不是只选中其中一个 - 最简单的方法是从其中一个按钮的边缘的空白区域开始拖动)_拖动控制_ 到 ToolsController。将其连接到您刚刚添加的操作“selectionDidChange:”。
最后,我们需要一种在需要时显示面板的方法。将一个新菜单项拖动到“窗口”菜单中,将其放置在“缩放”项目下方。将其命名为“显示工具”。从菜单项_拖动控制_ 到 ToolsController。将其连接到操作“showWindow:”。
保存文件。选择 ToolsController 后,选择类 -> 为 ToolsController 创建文件。将 .h 和 .m 文件添加到 Wikidraw。我们在 IB 中完成了操作,因此返回到 Xcode。
编码控制器
[edit | edit source]向我们的 ToolsController 类添加一个 int 数据成员,名为 _curTool。添加一个方法来返回它,名为 currentTool。您的类定义应如下所示
@interface ToolsController : NSWindowController { int _curTool; } - (IBAction) selectionDidChange:(id) sender; - (int) currentTool; @end extern NSString* notifyToolSelectionDidChange;
我们还声明了一个外部字符串变量,它将包含我们通知的名称。
现在让我们转向实现部分。这里我们不需要 'init' 方法,因为我们继承自 NSWindowController 的 'init' 方法已经足够了,但我们需要在从 .nib 文件创建后进行一些设置。我们可以通过实现 'awakeFromNib' 方法来做到这一点。它只是一个方法(没有参数),当我们从 .nib 文件中被创建时调用。以下是我们需要做的事情。
- (void) awakeFromNib { [(NSPanel*)[self window] setFloatingPanel:YES]; [(NSPanel*)[self window] setBecomesKeyOnlyIfNeeded:YES]; _curTool = 0; }
我们需要告诉我们的窗口成为一个浮动面板,并且除非绝对必要,否则不要成为关键窗口。这是为了防止工具调色板中的点击将焦点从当前文档中移开,这将是一种非常奇怪的行为。我们也趁机将 _curTool 初始化为 0。
我们的控制器的主要方法是 action selectionDidChange;。以下是它的代码。
- (IBAction) selectionDidChange:(id) sender { _curTool = [[sender selectedCell] tag]; [[NSNotificationCenter defaultCenter] postNotificationName:notifyToolSelectionDidChange object:self]; }
您会记得,动作的发送者是一个 NSMatrix。矩阵会跟踪当前选定的项目,并在我们调用 selectedCell 时返回它。然后我们需要获取与该项目相关的标签值,所以我们调用 'tag'。然后它就被赋值给 _curTool。
接下来是有趣的部分。我们发布了一个通知,告诉任何感兴趣的对象,工具选择刚刚被用户更改。我们使用一种叫做“默认通知中心”的东西来实际发送这个消息。这是一个全局的 Cocoa 对象,存在于此目的,用于将消息转发给任何感兴趣的对象。因为它对所有类可见(以我们的 ToolsController 不具备的方式),所以它是将信息传递给我们不了解的对象的理想方式。postNotificationName:object: 方法完成了这项工作。通知名称只是一个字符串,我们定义它,它唯一地指示正在发生的事情,而对象就是控制器本身。这个名称被设置为一个字符串
NSString* notifyToolSelectionDidChange = @"toolSelectionChanged";
字符串本身并不重要,虽然给它一个描述性的名称很有用,因为你可能希望在调试期间将你收到的通知记录到某个地方,如果名称是有意义的,你就能很容易地知道它是从哪里来的。
这就是我们的控制器的全部内容——它所做的只是接收按钮的更改并将更改作为通知转发。
接收通知
[edit | edit source]回到 WKDDrawView.h 中,我们需要声明一个方法来处理通知的接收。我们称它为 toolChange:,它看起来像这样
- (void) toolChange:(NSNotification*) note;
在 .m 文件的顶部,在实现之外,添加
static int sCurrentTool = 0;
这将是当前工具 ID 的本地副本。我们将它声明为静态的,以便它对我们视图类的任何实例可见。接下来我们需要注册通知。我们将在我们的 init 方法中完成此操作
- (id) initWithFrame:(NSRect)frameRect { if ((self = [super initWithFrame:frameRect]) != nil) { _delegate = nil; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(toolChange:) name:notifyToolSelectionDidChange object:nil]; } return self; } - (void) dealloc { [[NSNotificationCenter defaultCenter] removeObserver:self]; [super dealloc]; }
我们使用 addObserver:selector:name:object: 来注册对该通知的兴趣。当对象被释放时,我们必须取消注册,以避免在释放后发送任何通知时出现异常。当发送与名称匹配的通知时,我们传递的选择器所命名的 方法会被调用
- (void) toolChange:(NSNotification*) note { sCurrentTool = [[note object] currentTool]; }
我们所做的只是询问通知哪个对象发送了消息([note object]),由于我们知道它是一个 ToolsController,我们可以简单地询问它工具 ID,然后将其存储在 sCurrentTool 中。因此,sCurrentTool 应该始终与实际选定工具的当前值匹配。如果视图需要在任何时间了解哪个工具被选中,它可以简单地查看 sCurrentTool,而不是试图找出 ToolsController 在哪里并询问它。
当然,为了让它编译,我们需要将 ToolsController.h 文件导入到 WKFDDrawView.m 中
#import "ToolsController.h"
在我们继续之前,让我们构建并检查一下,看看我们是否已准备好开始。您应该能够从窗口菜单中选择“显示工具”以使调色板可见,并且单击按钮应该选择它们。我们还不能确定它是否正在执行任何有用的操作,因为我们还没有编写根据所选工具的值进行操作的代码。但是,我们可以记录对通知的接收。在开发和/或调试应用程序时,记录此类信息总是很方便的。
要记录任何东西,请使用 NSLog() 函数。它接受一个 NSString 常量,该常量将被写入日志。日志本身在应用程序运行时在 Xcode 的主窗口中可见。传递给 NSLog 的字符串接受类似 printf 的格式化指令,所以让我们添加
NSLog(@"tool changed to %d", sCurrentTool);
到 toolChange: 方法的末尾。再次构建并运行,并验证单击工具按钮是否会将工具 ID 写入日志。