Cocoa编程入门/添加细节
到目前为止,我们已经构建了一个相当粗糙的绘图程序,但它开始看起来像一个真正的应用程序了。我们已经涵盖了编写任何应用程序所需的许多Cocoa核心原则。如果你看看我们实际编写了多少代码,其实并不多,但我们的应用程序已经相当功能齐全了。在本节中,我们将探讨如何添加细节——这些细节将真正设计良好且实现良好的应用程序与我们毫无疑问都见过的那些较差的业余作品区分开来。
首先要解决的是撤销。一个真正的应用程序应该允许几乎所有操作都可撤销,本着宽容用户的精神。如果没有撤销功能,你的应用程序很可能会被放弃,转而使用另一个具有该功能的应用程序,因为惩罚用户犯错或不允许他们尝试是重大的设计错误!
撤销传统上是实现应用程序的“难点”之一。Cocoa 在这里为我们提供了巨大的帮助,因此撤销并不那么难。通常,事后添加撤销功能不是一个好主意——你需要从头开始设计你的应用程序以应对撤销任务。到目前为止,我们还没有提到撤销,但实际上我们已经设计了我们的应用程序,以便可以轻松地添加它。
撤销的原理很简单——每当我们对数据模型的状态进行任何更改时,都会记录先前的状态,以便可以将其恢复。我们可以通过不同的方式记录此先前状态,要么只记录整个状态,要么记录其部分状态以及标签以告诉我们发生了什么变化,或者我们可以记录操作并执行相反的操作来实现撤销。我们将使用这些方法的组合。Cocoa 会自动将多个可撤销的“记录”组合成每个事件的一个可撤销操作,这确实使我们的生活变得非常简单——我们只需要确保在每个可编辑的兴趣点都发生记录。
我们希望以下操作可以撤销
- 添加新对象
- 删除对象
- 更改对象的位置或大小
- 更改笔触和填充颜色
- 更改笔触宽度
请注意,我们也可以使选择本身可撤销,但在此练习中我们不会这样做。
在 Cocoa 中,负责处理撤销的对象是撤销管理器。它体现在 NSUndoManager 类中。它的工作原理是存储调用。我们已经讨论了 Objective-C 如何使用类方法的运行时绑定。调用只是对方法调用的存储描述。当调用“回放”时,实际上会进行存储的方法调用。调用不仅记录要调用的目标和方法,还记录调用记录时所有方法的参数。因此,我们可以创建一个调用,当它被调用时,将撤消我们正在执行的当前操作。操作和撤消同一操作的调用同时创建。
首先让我们添加撤销添加和删除对象的代码。以下是 MyDocument.m 中为 addObject 和 removeObject 修改后的代码
- (void) addObject:(id) object { if(![_objects containsObject:object]) { [[self undoManager] registerUndoWithTarget:self selector:@selector(removeObject:) object:object]; [_objects addObject:object]; [_mainView setNeedsDisplayInRect:[object drawBounds]]; } } - (void) removeObject:(id) object { if([_objects containsObject:object]) { NSRect br = [object drawBounds]; [[self undoManager] registerUndoWithTarget:self selector:@selector(addObject:) object:object]; [self deselectObject:object]; [_objects removeObject:object]; [_mainView setNeedsDisplayInRect:br]; } }
我们可以通过调用 [self undoManager] 获取撤销管理器。每个文档都有自己的撤销管理器实例已设置,我们只需要使用它即可。然后我们使用 registerUndoWithTarget:selector:object: 方法构建撤销管理器存储的调用。当我们添加对象时,我们构建一个调用以 removeObject。当我们删除对象时,我们构建一个调用以 addObject。换句话说,我们记录了我们正在执行操作的相反操作。我们还在这里添加了代码,以便在对象出现和消失时刷新主视图,以便我们可以看到我们的操作产生的效果。
编译并运行项目。创建一些形状。现在撤销它们。你会发现你可以撤销和重做任意次数的操作——一切按预期的方式为撤销命令工作。仅仅两行代码就能做到这一点,还不错吧!
请注意,当执行撤销时,它会调用,例如 removeObject,而 removeObject 又会记录另一个 addObject 撤销操作。这就是重做工作的方式——NSUndoManager 知道它被调用的上下文,因此可以将任务添加到相应的堆栈中。现在让我们使可编辑操作可撤销。为此,我们需要向 WKDShape 添加代码,目前形状没有撤销管理器,也不知道它们所属的文档,因此它们无法获取一个。因此,我们需要做的第一件事是提供一种方法,让形状能够获取适当的撤销管理器。
在 WKDShape.h 中,向名为 _owner 的类添加一个数据成员,类型为 NSDocument*。添加访问器方法 setOwner: 和 owner。在 .m 文件中,添加实现
- (void) setOwner:(NSDocument*) doc { _owner = doc; } - (NSDocument*) owner { return _owner; }
在 MyDocument.m 中,向 addObject 添加一行以调用 setOwner 并使用 self——这确保了每当将对象添加到文档时,都会使用其所属的文档更新对象。请注意,由于任何向文档添加对象的内容(例如粘贴)都会调用此方法,因此此信息始终是最新的。这是一个强大的设计原则——始终尝试最大程度地减少代码中更改数据模型的位置,方法是将代码分解成几个关键方法。然后,你可以在了解这些方法将始终被调用并且没有任何内容可以通过“后门”进入的情况下向这些方法添加代码。如果我们没有这样做,那么实现撤销将比到目前为止要困难得多。
现在我们有了让 WKDShape 获取其拥有文档的 undoManager 的方法,我们可以轻松地实现撤销。
- (void) setFillColour:(NSColor*) colour { [[[self owner] undoManager] registerUndoWithTarget:self selector:@selector(setFillColour:) object:_fillColour]; [colour retain]; [_fillColour release]; _fillColour = colour; [self repaint]; }
在这里,我们没有使用不同的方法设置撤销,而是使用了相同的方法,但使用了颜色的旧值。当调用撤销命令时,它会调用相同的方法,并将原始颜色传回。笔触颜色的代码相同。
笔触宽度的案例稍微复杂一些,因为撤销管理器不会直接存储简单的标量值(如浮点数)。相反,我们需要将值打包到一个对象中。Cocoa 提供了一个简单的类来做到这一点——NSNumber。由于我们需要能够调用一个采用对象参数的方法,因此我们现在必须创建一个。我们将重构代码,以便所有对 setStrokeWidth 的调用现在都通过它进行。
- (void) setStrokeWidth:(float) width { [[[self owner] undoManager] registerUndoWithTarget:self selector:@selector(setStrokeWidthObject:) object:[NSNumber numberWithFloat:_strokeWidth]]; _strokeWidth = width; [self repaint]; } - (void) setStrokeWidthObject:(NSNumber*) value { [self setStrokeWidth:[value floatValue]]; }
不要忘记将方法 setStrokeWidthObject: 添加到你的类声明中。现在,如果任何人调用 setStrokeWidth,旧值将转换为 NSNumber 对象,并用于在 setStrokeWidthObject: 上构建撤销调用。当调用撤销时,setStrokeWidthObject: 会解包封装的值并调用 setStrokeWidth:,因此一切正常。
编译并转到测试,以验证使用检查器更改属性现在是否完全可撤销。它们应该是!
注意 Cocoa 如何为你将每个编辑操作分离到不同的撤销命令中。例如,如果你在颜色选择器中拖动,对象的颜色会发生很多次更改,直到你停止拖动。但是,只需要一个撤销命令即可恢复以前的颜色——它不会明显“回放”所有单独的中间颜色。
但是,有一件事现在不太好。如果我们撤消编辑更改,检查器不会更改!如果我们仔细考虑一下,原因很明显——之前我们只设置了检查器以响应选择更改,这些更改也设置了控件的状态。之后,检查器更改对象的状态,因此它们始终保持“同步”。撤销不会更改选择状态,但它在检查器背后更改了对象的状态,因此我们需要告诉检查器在发生这种情况时重新同步。为此,我们需要添加一个新的通知,检查器可以对其做出响应。
这与“重绘”通知非常相似——实际上在这种情况下可以使用它,但由于在语义上它确实具有不同的含义,因此最好将其作为单独的通知。如果你以后扩展设计,你可能会发现组合通知会导致棘手的问题。因此,创建一个新的通知名称和一个发送它的方法。我将其命名为 resynch。从对象状态可以从其中更改的每个位置调用此方法——setFillColour:、setStrokeColour:、setStrokeWidth: 在检查器中,创建一个新的响应器方法——我将其命名为 resynch:——获取单个 NSNotification 参数。像以前一样在 awakeFromNib 方法中订阅 resynch 通知。resynch: 方法如下所示
- (void) resynch:(NSNotification*) note { WKDShape* shp = (WKDShape*)[note object]; if ( shp == editShape ) [self setupWithShape:shp]; }
如你所见,这非常简单——我们找出哪个对象发生了更改。如果它实际上是我们当前正在编辑的对象,我们只需调用我们的设置方法,以便控件状态与对象的状态匹配。再次编译并运行以验证这次撤消编辑操作是否也反映了检查器中的更改。
最后,我们需要撤消对象的移动和调整大小。通过将所有这些信息通过 setBounds: 方法传递,我们只有一个地方可以添加撤销记录。与 setStrokeWidth 一样,我们需要使用 NSValue 对象来存储旧的矩形。
以下是 WKDShape 中重构和修改后的代码
- (void) setBounds:(NSRect) bounds { [self repaint]; [[[self owner] undoManager] registerUndoWithTarget:self selector:@selector(setBoundsObject:) object:[NSValue valueWithRect:[self bounds]]]; _bounds = bounds; [self repaint]; [self resynch]; } - (void) setBoundsObject:(id) value { [self setBounds:[value rectValue]]; } - (void) setLocation:(NSPoint) loc { NSRect br = [self bounds]; br.origin = loc; [self setBounds:br]; } - (void) offsetLocationByX:(float) x byY:(float) y { NSRect br = [self bounds]; br.origin.x += x; br.origin.y += y; [self setBounds:br]; } - (void) setSize:(NSSize) size { NSRect br = [self bounds]; br.size = size; [self setBounds:br]; }
编译并运行...创建一个形状并移动它。现在撤销。操作是否以你期望的方式撤销?不,不是。该操作是可撤销的,但与颜色案例不同,每个单独的小量运动都被记录为一个单独的撤销任务。撤销管理器未能将这些运动组合成一个大的单一操作。这是为什么呢?NSUndoManager 将在单个事件内发生的全部撤销操作进行分组。但是,拖动对象包含多个事件。我们无法更改这一点,但可以通过提供一些提示来更改撤销管理器对事物进行分组的方式。
在 WKDDrawView.m 中,在方法 mouseDown: 中,在方法顶部添加以下行
[[[self delegate] undoManager] beginUndoGrouping];
并在 mouseUp: 方法中,在方法底部添加以下行
[[[self delegate] undoManager] endUndoGrouping];
这将解决问题——编译并运行以进行验证。它的作用是告诉撤销管理器将从现在开始到调用 endUndoGrouping 之前收到的任何撤销记录分组。我们在鼠标按下时开始分组,在鼠标抬起时结束分组,因此在拖动过程中发生的所有操作都将最终位于同一组中,并且将在单个撤销命令下回放。形状的每个单独偏移量仍然会被记录,但在回放时会回放整个移动,因此用户的效果是预期的行为。
最后,你可能已经注意到菜单命令只显示一个通用的“撤销”。如果我们能给用户一个更有意义的指示,说明撤销操作将完成什么,那就太好了。为此,我们将一个字符串传递给撤销管理器,它用于构建菜单命令文本。在组中接收到的最后一个字符串将是使用的那个。该方法是 NSUndoManager 的 setActionName: 方法。现在我们只对每个操作的字符串进行硬编码 - 在一个真实的应用程序中,我们需要允许它进行本地化。
在 WKDShape 中,这很简单。例如,在 setFillColour 中,添加
[[[self owner] undoManager] setActionName:@"Change Fill Colour"];
紧接在当前撤销记录操作之后。对于所有类似的状态更改,您可以添加一行带有字符串,指示实际更改的内容。请记住,用户会看到这个;您不需要包含“撤销”或“重做”。为了区分调整大小和移动形状,您可以在相应的例程中包含此行 - 它们不需要直接放在 setBounds: 中;另外,不要忘记代码,其中句柄以交互方式拖动 - mouseDragged
MyDocument 的更改类似,但有一个小问题 - 因为我们使用的是“相反”方法,我们需要区分撤销/重做和正常调用,并确保我们只在正常调用时更新字符串 - 即不是来自撤销调用。为此,我们使用
if (! [[self undoManager] isUndoing] && ! [[self undoManager] isRedoing]) [[self undoManager] setActionName:@"Delete Object"];
再次运行以检查所有内容是否按预期工作。
作为奖励,您会注意到,现在,对文档的更改会自动询问您是否要在退出或关闭文档时保存更改。这是因为通过记录撤销任务,您还在通知文档其状态与磁盘文件不同。以前,它无法分辨。Cocoa 现在介入并为您“免费”添加了这一个小功能。不错!
进一步练习
- 添加撤销选择更改的功能。在实践中,这通常会导致更易用的应用程序。
真正的应用程序应该可以打印。同样,在传统的 Mac 应用程序中,添加打印功能通常是一项艰巨的工作,并且经常作为事后考虑,使其变得更加困难!Cocoa 则简单得多,因为它的屏幕图形模型和打印模型之间没有重大差异。事实上,我们已经编写了大部分代码。
打印由称为 NSPrintOperation 的对象处理,与 NSView 协同工作。视图将内容绘制到纸张上。对于我们的简单练习,我们只需要很少的代码,因为跨页面平铺的默认分页方案就足够了。正如您可能想象的那样,Cocoa 提供了许多替代方法,但这些方法超出了使基本打印工作的范围。
由于我们有一个基于文档的应用程序,因此我们使用 printDocument: action 方法来启动操作。以下是代码
- (IBAction) printDocument:(id) sender { NSPrintOperation *op = [NSPrintOperation printOperationWithView:_mainView printInfo:[self printInfo]]; [op runOperationModalForWindow:[self windowForSheet] delegate:self didRunSelector: @selector(printOperationDidRun:success:contextInfo:) contextInfo:NULL]; }
我们创建一个 NSPrintOperation,并将我们的主视图(将呈现绘图)传递给它。获得此视图后,我们只需使用工作表对话框运行它即可。这将处理其余部分。工作表需要一个完成例程,但在这种情况下,我们不需要它实际执行任何操作,因此我们只需将其提供为空方法
- (void) printOperationDidRun:(NSPrintOperation*) printOperation success:(BOOL)success contextInfo:(void *)info { }
不要忘记将其添加到您的类定义中。
就是这样!编译并运行它,您会发现您可以打印出您的图形。
这里有一件事不太好,那就是它打印了选择手柄。实际上,我们不需要在打印图形时看到这些手柄,因此我们现在只需修改主视图绘图代码,以便在打印时它不会费心渲染这些手柄。事实证明这非常简单
while( shape = [iter nextObject]) { if ( NSIntersectsRect( rect, [shape drawBounds])) [shape drawWithSelection:[selection containsObject:shape] && [NSGraphicsContext currentContextDrawingToScreen]]; }
这是我们视图的 drawRect: 方法内的主循环。我们只需检查当前图形上下文是否正在绘制到屏幕上。如果不是,则它必须绘制到打印机,因此对象在不显示手柄的情况下呈现,而不管其选择状态如何。
我们已经了解了 Cocoa 如何自动启用可以在当前上下文中找到目标的菜单项。这非常巧妙,但并不总是足够。例如,当我们实现剪切和粘贴时,我们发现即使没有要剪切的选择或剪贴板中没有我们可以粘贴的内容,这些命令也始终处于启用状态。我们现在将了解如何处理这种情况。
每当显示菜单时,目标都会有机会覆盖正常的启用行为。它通过实现名为 validateMenuItem: 的方法来做到这一点,该方法会为每个项目调用。您可以返回 YES 以启用该项目,返回 NO 以禁用它。因此,在 MyDocument 中,我们添加
- (BOOL) validateMenuItem:(NSMenuItem*) item { SEL act = [item action]; if ( act == @selector(cut:) || act == @selector(copy:) || act == @selector(delete:)) return ([[self selection] count] > 0 ); else if ( act == @selector(paste:)) { NSPasteboard* cb = [NSPasteboard generalPasteboard]; NSString* type = [cb availableTypeFromArray:[NSArray arrayWithObjects:@"wikidrawprivate", nil]]; return ( type != nil ); } else return YES; }
如这里所示,最好比较操作选择器以确定菜单项的目标操作,而不是查看菜单项字符串或位置。另一种方法是使用项目的标记,但这需要您在 IB 中仔细设置这些标记。通过使用选择器,您可以免受可能对菜单进行的文本和位置更改的影响 - 只要它仍然以相同操作方法为目标,它在该方法实际实现的内容方面就会表现正确。
在这里,如果该项目以剪切、复制或删除方法为目标,我们会查看选择数组中是否存在任何项目。如果有,则启用该项目,否则禁用它。对于粘贴情况,我们查看剪贴板以查看它是否具有类型为“wikidrawprivate”的数据可用 - 如果有,我们可以粘贴,因此我们启用该项目。
您可以在菜单命令的任何目标中实现类似的方法,以便对菜单项的启用进行精细控制。如果您愿意,您还可以利用此机会更改菜单项的文本,以反映您在数据模型中的一些信息。