在Mac OS X的屏幕最上层绘图

标签:Objective-C, Mac开发

前几天写了篇《监听Mac OS X的全局鼠标事件》,其中虽然实现了鼠标手势中最重要的监听全局鼠标事件和识别鼠标手势方向的功能,但为了更直观地显示鼠标拖动的轨迹,还需要在屏幕上绘制出来。

其实Mac OS X和iOS都有一个window level的概念。当window level相同时,根据出现的顺序,后出现的窗口会叠放在先出现的窗口上面;而当window level不同时,越大的排在越上面。在系统预设的值当中,最低的是普通窗口(NSNormalWindowLevel),值为0;最高的是屏保(NSScreenSaverWindowLevel),值为1000。一般而言我们也没必要覆盖屏保,不过实际上它的取值访问可以更广,至少可以到CGShieldingWindowLevel(),在Lion上它的值貌似是2^31-1。

于是只要创建一个window level很高的透明窗口,在接收到鼠标事件时进行绘图,这样就能显示鼠标拖动的轨迹了。
找了一番后我发现Magic Pen这个小程序,于是拿它修改了一番。

先实现我们要的窗口类:
#import <AppKit/AppKit.h>

@interface FloatPanel : NSPanel

- (id)initWithContentRect:(NSRect)contentRect;

@end


#import "FloatPanel.h"

@implementation FloatPanel

- (id)initWithContentRect:(NSRect)contentRect
{
	self = [super initWithContentRect:contentRect styleMask:(NSBorderlessWindowMask | NSNonactivatingPanelMask) backing:NSBackingStoreBuffered defer:NO];
	if (self) {
		self.backgroundColor = NSColor.clearColor;
		self.level = CGShieldingWindowLevel();
		self.opaque = NO;
		self.hasShadow = NO;
		self.hidesOnDeactivate = NO;
	}

	return self;
}

@end
这个窗口被设置为没有边框和阴影,背景色透明,level为最高,非当前窗口时也处于激活状态(否则第一次鼠标点击会被当成选中窗口)。

再为其实现一个view,它内部保存了显示到窗口的画,并接受鼠标事件:
#import <Cocoa/Cocoa.h>

@interface PanelView : NSView {
	NSColor *color;
	NSImage *image;
	NSPoint lastLocation;
	NSUInteger radius;
}

@end


#import "PanelView.h"

@implementation PanelView

- (id)initWithFrame:(NSRect)frame
{
	self = [super initWithFrame:frame];
	if (self) {
		color = [NSColor.blueColor retain];
		image = [[NSImage alloc] initWithSize:frame.size];
		radius = 2;
	}
	
	return self;
}

- (void)drawRect:(NSRect)dirtyRect
{
	[image drawInRect:NSScreen.mainScreen.frame fromRect:NSZeroRect operation:NSCompositeSourceOver fraction:1.0];
}

- (void)drawCircleAtPoint:(NSPoint)point
{
	[image lockFocus];
	NSBezierPath *path = [NSBezierPath bezierPathWithOvalInRect:NSMakeRect(point.x - radius, point.y - radius, radius * 2, radius * 2)];
	[color set];
	[path fill];
	[image unlockFocus];
}

- (void)drawLineFromPoint:(NSPoint)point1 toPoint:(NSPoint)point2
{
	[image lockFocus];
	NSBezierPath *path = [NSBezierPath bezierPath];
	path.lineWidth = radius * 2;
	[color setStroke];
	[path moveToPoint:point1];
	[path lineToPoint:point2];
	[path stroke];
	[image unlockFocus];
}

- (void)mouseDown:(NSEvent *)event {
	lastLocation = [self convertPoint:self.window.mouseLocationOutsideOfEventStream fromView:nil];
	[self drawCircleAtPoint:lastLocation];
}

- (void)mouseDragged:(NSEvent *)event
{
	NSPoint newLocation = [self convertPoint:self.window.mouseLocationOutsideOfEventStream fromView:nil];
	[self drawCircleAtPoint:newLocation];
	[self drawLineFromPoint:lastLocation toPoint:newLocation];
	[self setNeedsDisplayInRect:NSMakeRect(fmin(lastLocation.x - radius, newLocation.x - radius),
		fmin(lastLocation.y - radius, newLocation.y - radius),
		abs(newLocation.x - lastLocation.x) + radius * 2,
		abs(newLocation.y - lastLocation.y) + radius * 2)];
	lastLocation = newLocation;
}

- (void)mouseUp:(NSEvent *)event
{
	[image release];
	image = [[NSImage alloc] initWithSize:NSScreen.mainScreen.frame.size];
	[self setNeedsDisplay:YES];
}

- (void)dealloc
{
	[super dealloc];
	[color release];
	[image release];
}

@end
不将其直接绘制到屏幕的原因是可能会擦除之前绘制过的路径,所以需要一个图像来保存。
此外,在用NSBezierPath来绘图时,需要lockFocus图像,否则不会绘制到图像里。
而在接收鼠标事件时,mouseDown只需要记录lastLocation和画点,mouseDragged需要连线,mouseUp则清除图像。

最后把这个窗口显示出来:
#include <Carbon/Carbon.h>
#import "AppDelegate.h"
#import "FloatPanel.h"
#import "PanelView.h"

@implementation AppDelegate

@synthesize window;

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

- (void)applicationDidFinishLaunching:(NSNotification *)aNotification
{
	NSRect frame = NSScreen.mainScreen.frame;
	window = [[FloatPanel alloc] initWithContentRect:frame];
	NSView *view = [[PanelView alloc] initWithFrame:frame];
	window.contentView = view;
	[view release];
	window.ignoresMouseEvents = NO;
	[window makeKeyAndOrderFront:nil];
}

@end
注意ignoresMouseEvents要设为NO,因为透明窗口默认是不接收鼠标事件的。

现在运行一下程序,它可以工作了,可是没法传递到下层窗口;而且都无法选中菜单来退出,只能按下Command+Q快捷键。
其实不能传递到下层窗口是必然的,window server在决定了哪个窗口能处理这个鼠标事件后,就会将这个事件传递给它,之后就不管了。而要规避这个限制,就只能用CGEventTap来获取鼠标事件再绘图,因为它能在window server之前进行处理。

于是把上次的代码复制过来,简单地修改一下:
static CGEventRef eventCallback(CGEventTapProxy proxy, CGEventType type, CGEventRef event, void *refcon) {
	static AppDelegate *delegate;
	if (delegate == nil) {
		delegate = NSApplication.sharedApplication.delegate;
	}

	NSView *view = delegate.window.contentView;
	NSEvent *mouseEvent = [NSEvent eventWithCGEvent:event];
	switch (mouseEvent.type) {
		case NSRightMouseDown:
			[view mouseDown:mouseEvent];
			break;
		case NSRightMouseDragged:
			[view mouseDragged:mouseEvent];
			break;
		case NSRightMouseUp:
			[view mouseUp:mouseEvent];
			break;
		default:
			return event;
			break;
	}

	return NULL;
}

- (void)applicationDidFinishLaunching:(NSNotification *)aNotification
{
	NSRect frame = NSScreen.mainScreen.frame;
	window = [[FloatPanel alloc] initWithContentRect:frame];
	NSView *view = [[PanelView alloc] initWithFrame:frame];
	window.contentView = view;
	[view release];
	[window makeKeyAndOrderFront:nil];

	CGEventMask eventMask = CGEventMaskBit(kCGEventRightMouseDown) | CGEventMaskBit(kCGEventRightMouseDragged) | CGEventMaskBit(kCGEventRightMouseUp);
	CFMachPortRef eventTap = CGEventTapCreate(kCGSessionEventTap, kCGHeadInsertEventTap, kCGEventTapOptionDefault, eventMask, eventCallback, NULL);
	CFRunLoopSourceRef runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, eventTap, 0);
	CFRunLoopAddSource(CFRunLoopGetCurrent(), runLoopSource, kCFRunLoopCommonModes);
	CGEventTapEnable(eventTap, true);
	CFRelease(eventTap);
	CFRelease(runLoopSource);
}
要注意的是这次window本身需要忽略鼠标事件,所以ignoresMouseEvents需要为YES。

再测试一下,会发现不能正常画线。于是打开PanelView,找到mouseDragged:等方法,改成如下实现:
NSPoint newLocation = event.locationInWindow;

再运行一下,终于搞定了。不过还需要处理屏幕分辨率改变和切换space的事件,这些都可以在Stack Overflow找到答案。
其中space切换可以用一行代码搞定:
window.collectionBehavior = NSWindowCollectionBehaviorCanJoinAllSpaces;

12条评论 你不来一发么↓ 顺序排列 倒序排列

    向下滚动可载入更多评论,或者点这里禁止自动加载

    想说点什么呢?