Mac OS X Lion supports a new Cocoa object called NSPopover
. This object
acts a lot like the popover introduced in CocoaTouch on iPad. There are
lots of places normal applications can use NSPopover
naturally. I
recently started working on a personal project that uses an NSStatusItem
,
and wanted to use an NSPopover
to manage the primary user interface for
the content shown when interacting with the NSStatusItem
.
Unfortunately, the interactions between a hidden object called
NSPopoverWindow
and Mac OS X’s NSStatusBarWindow
create some problems.
On top of this, having an NSPopover
where the content view contains edit
fields creates further problems.
However, I was finally able to get the interactions working correctly (at
the expense of using the built-in NSPopoverBehavior
styles). This post
takes you through the problems and describes my solutions.
Yes, I could have used the Popup project, but I just
wanted to make NSPopover
work.
Initial Code
The initial code is pretty simple: just create a custom view and attach it
to an NSStatusItem
object. Even though I did not need more than the
functionality provided by the default NSStatusItem
object, NSPopover
requires a view for the showRelativeToRect:ofView:preferredEdge:
selector. I couldn’t figure out a way to access the _fView
member of the
NSStatusItem
that’s visible in the debugger, so I was stuck
re-implementing the basic NSStatusItem
view functionality of an image
that flips when activated. I call this BRStatusItemIconView
. The code
in the application delegate looks like this:
NSStatusItem* statusItem = [[NSStatusBar systemStatusBar]
statusItemWithLength:32];
[statusItem setHighlightMode:YES];
_iconView = [[BRStatusItemIconView alloc]
initWithStatusItem:statusItem];
_iconView.image = [NSImage imageNamed:@"Status"];
_iconView.highlightedImage = [NSImage imageNamed:@"StatusHighlighted"];
The initWithStatusItem:
selector creates the necessary subviews (since
it’s not loaded from a nib) and attaches itself to statusItem
through
setView:
.
I used a separate object to be the popover controller and attach itself to
the NSStatusItem
. Since I used a custom view, the action
and target
properties of the NSStatusItem
couldn’t be used. I created a
BRStatusItemIconViewDelegate
protocol that had one selector:
- (void)activated:(BRStatusItemIconView*)sender;
When the icon was clicked, it would invoke this selector on a delegate. My
BRStatusItemPopoverController
object is the popover’s controller and
attaches itself to the BRStatusIconView
created in the application
delegate. The popover was initialized like this:
_popover = [[NSPopover alloc] init];
_popover.behavior = NSPopoverBehaviorApplicationDefined;
_popover.contentViewController = viewController;
_popover.delegate = self;
_popover.animates = NO;
Note I used NSPopoverBehaviorApplicationDefined
, the importance of which
will be discussed later. The viewController
here was a controller for a
complex view that includes an edit field, which is also important to note.
So, now all I needed was to open and close the NSPopover
! The simple
version of the code in BRStatusItemPopoverController
looked like this:
- (void)close {
[_popover close];
_shown = NO;
}
- (void)open {
BRStatusItemIconView* view = _statusItem.view;
[_popover showRelativeToRect:view.bounds ofView:view
preferredEdge:NSMaxYEdge];
_shown = YES;
}
- (void)activated:(BRStatusItemIconView*)sender {
if (_shown) {
[self close];
} else {
[self open];
}
}
Hit run to try it out, and… it works! However, if the popover’s content
view contained an NSTextField
, you’d find that the field refuses to
become first responder! Even making sure the field was marked editable in
Interface Builder didn’t help. #FAIL.
Fixing Edit Fields
According to this
StackOverflow question, the problem with the NSTextField
is the parent
window’s inability to become the key window. The answer in that question
is to write a global category for your application:
NSWindow+canBecomeKeyWindow.h
@interface NSWindow (canBecomeKeyWindow)
@end
NSWindow+canBecomeKeyWindow.m
@implementation NSWindow (canBecomeKeyWindow)
//This is to fix a bug with 10.7 where an NSPopover with a text field
//cannot be edited if its parent window won't become key
//The pragma statements disable the corresponding warning for
//overriding an already-implemented method
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wobjc-protocol-method-implementation"
- (BOOL)canBecomeKeyWindow
{
return YES;
}
#pragma clang diagnostic pop
@end
Trying this out appears to work when you click the status item. You can
click the NSTextField
and it becomes editable! But, this has created a
subtle problem: the status item only works correctly if the application
that owns it is active. When inactive, the status item weirdly requires a
double click to activate it, and even then, the double click sends two
mouseDown:
events to BRStatusItemIconView
, causing the interaction to
be out of phase with the user’s intention.
Fixing the double click
Long story short, we need to control when the NSStatusBarWindow
window,
the parent of BRStatusItemIconView
, is allowed to become key. When the
popover is opened, we want that to happen so that the popover’s content
view can become first responder. When the popover is closed, we want to go
back to the original behavior so that input to BRStatusItemIconView
doesn’t get blocked. But, we aren’t familiar with the original behavior
because it’s hidden by the original implementation of
canBecomeKeyWindow:
. Using a technique called method swizzling that helps us do this. Note that
there is an updated version of method swizzling discussed here, but I’ve
not implemented it in my code yet. The new code looks like this:
#import "NSWindow_canBecomeKeyWindow.h"
#include
BOOL shouldBecomeKeyWindow;
NSWindow* windowToOverride;
@implementation NSWindow (canBecomeKeyWindow)
//This is to fix a bug with 10.7 where an NSPopover with a text field
//cannot be edited if its parent window won't become key
//The pragma statements disable the corresponding warning for
//overriding an already-implemented method
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wobjc-protocol-method-implementation"
- (BOOL)popoverCanBecomeKeyWindow
{
if (self == windowToOverride) {
return shouldBecomeKeyWindow;
} else {
return [self popoverCanBecomeKeyWindow];
}
}
+ (void)load
{
method_exchangeImplementations(
class_getInstanceMethod(self, @selector(canBecomeKeyWindow)),
class_getInstanceMethod(self, @selector(popoverCanBecomeKeyWindow)));
}
#pragma clang diagnostic pop
@end
Now, shouldBecomeKeyWindow
and windowToOverride
just need to be set
correctly. As I said above, we want windowToOverride
to be the
NSStatusBarWindow
and shouldBecomeKeyWindow
to change based on the
status of the popover being visible. The new
BRStatusItemPopoverController
code is this:
- (void)close
{
[_popover close];
shouldBecomeKeyWindow = NO;
_shown = NO;
}
- (void)open
{
BRStatusItemIconView* view = (BRStatusItemIconView*)_statusItem.view;
shouldBecomeKeyWindow = YES;
[_popover showRelativeToRect:view.bounds ofView:view
preferredEdge:NSMaxYEdge];
windowToOverride = view.window;
[view.window becomeKeyWindow];
_shown = YES;
}
Adding transient behavior
Finally! Things work as they should. However, one cool thing about
NSPopover
’s default functionality is its ability to be a transient
presence for the user. For applications that use the status bar, this is
good behavior. However, because NSPopoverBehaviorApplicationDefined
was
used, we have to duplicate that functionality. Basically, we want the
popover to be able to disappear when the application resigns being active.
This can be done by listening to the appropriate notifications in
BRStatusItemPopoverController
and changing the ability of the
NSStatusBarWindow
to become key, or close the popover.
- (void)applicationDidResignActive:(NSNotification*)n
{
if ((self.behavior == BRStatusItemPopoverBehaviorTransient) &&
_shown) {
[self close];
} else if (self.behavior == BRStatusItemPopoverBehaviorPermanent) {
shouldBecomeKeyWindow = NO;
}
}
Conclusion
Apple has some bugs to fix related to NSPopover
and NSStatusBarWindow
.
It seems obvious to me that applications that live in the status bar
should use NSPopover
to present complex user interfaces, but the
complexity of doing so right now is too high.