Using NSPopover with NSStatusItem

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.