DevBits header

Debugging Next Key View Loops

When you create a window and populate it with views, AppKit automatically links your controls into a tab loop by setting the nextKeyView property on each view. This process is handled by the recalculateKeyViewLoop method inside NSWindow. The order of your views in this loop is based on the geometric location of each view in the window. Most of the time, this automatic mechanism works great, and you don’t have to think about it. But sometimes a window is too complex, and the automatic method produces incorrect results. When this happens, manual adjustment of the nextKeyView loop is required.

This is the situation we faced with the item detail view in 1Password for Mac 5. The detail view consists of a view-based NSTableView with multiple custom cell views. During the development of this view, we were constantly breaking the nextKeyView loop. Every time a new text field was added, or a button moved, some part of the nextKeyView loop would invariably break.

To make this debugging simpler, we wrote a utility class to visually overlay nextKeyView information on top of the detail view. Here is how it works.

The header

Let’s start with the header (.h):

//
//  OPTestKeyLoopView.h
//

#import <Cocoa/Cocoa.h>

@interface OPTestKeyLoopView : NSView

+ (void)enableForView:(NSView *)startView;
+ (void)disable;

@end

The header provides a simple interface to enable and disable key loop debugging on a particular view. OPTestKeyLoopView is a Singleton, so these methods operate on the Class.

Private class to store visual overlay info

Moving on to the implementation (.m):

First, let’s define a simple private class used to store the visual overlay information about a particular view.

//
//  OPTestKeyLoopView.m
//

#import "OPTestKeyLoopView.h"


@interface OPTestKeyView : NSObject

@property (nonatomic, strong) NSString *title;
@property (nonatomic, strong) NSString *subtitle;
@property (nonatomic, strong) NSColor *color;
@property (nonatomic, strong) NSFont *font;
@property (nonatomic, assign) NSRect frame;

@end


@implementation OPTestKeyView

@end

This class doesn’t do anything other than store the properties of a view overlay.

OPTestKeyLoopView implementation

Next, we begin implementing OPTestKeyLoopView by writing the Singleton and Class methods.

@interface OPTestKeyLoopView ()

@property (nonatomic, strong) NSView *rootView;
@property (nonatomic, strong) NSMutableArray *keyViews;
@property (nonatomic) NSRect rootFrame;

@end


@implementation OPTestKeyLoopView

+ (OPTestKeyLoopView *)sharedTestKeyLoopView {
    static OPTestKeyLoopView *sharedView = nil;
    
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        sharedView = [[self alloc] init];
        sharedView.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable;
    });
    
    return sharedView;
}

+ (void)enableForView:(NSView *)startView {
    [self sharedTestKeyLoopView].rootView = startView;
    [[NSNotificationCenter defaultCenter] addObserver:[self sharedTestKeyLoopView] selector:@selector(mainWindowFlagsChanged:) name:OPMainWindowFlagsChangedNotification object:nil];
}

+ (void)disable {
    [self sharedTestKeyLoopView].rootView = nil;
    [[self sharedTestKeyLoopView] clear];
    [[NSNotificationCenter defaultCenter] removeObserver:self];
}

Sidebar: OPMainWindowFlagsChangedNotification

This is a good time to pause and talk about the OPMainWindowFlagsChangedNotification notification. We want our overlay to appear when you press the Option key. The standard way to catch an Option key down event is to override

- (void)flagsChanged:(NSEvent *)theEvent

in your NSWindow or NSWindowController subclass. In our case, our window controller overrides this method and posts a notification so that other parts of the UI can easily respond to modifier key events. Here is what it looks like:

// From our NSWindowController subclass
- (void)flagsChanged:(NSEvent *)theEvent {
    [super flagsChanged:theEvent];
    [[NSNotificationCenter defaultCenter] postNotificationName:OPMainWindowFlagsChangedNotification object:nil];
}

Add the notification handler

Moving back to the implementation of OPTestKeyLoopView, next we’ll add the notification handler:

- (void)mainWindowFlagsChanged:(NSNotification *)aNotification {
    NSUInteger modifierFlags = ([NSApp currentEvent].modifierFlags & NSDeviceIndependentModifierFlagsMask);
    if (modifierFlags & NSAlternateKeyMask) {
        NSView *hitView = self.rootView;
        while (YES) {
            NSPoint mouse = [hitView convertPoint:[[self.rootView window] mouseLocationOutsideOfEventStream] fromView:nil];
            BOOL hitSubView = NO;
            for (NSView *subview in hitView.subviews) {
                if (subview == self) {
                    continue;
                }
                if (NSMouseInRect(mouse, subview.frame, hitView.isFlipped)) {
                    hitView = subview;
                    hitSubView = YES;
                    break;
                }
            }
            if (!hitSubView) {
                break;
            }
        }
        
        if (modifierFlags & NSShiftKeyMask) {
            [self showPreviousKeyViewLoopForView:hitView];
        }
        else {
            [self showNextKeyViewLoopForView:hitView];
        }
    }
    else {
        [self clear];
    }
}

This method checks for the Option key down and locates the furthest child view under the mouse. This child view will be the start of our next key view loop. Then we call a method to show the next key view loop overlay. If the shift key is also held down, we reverse the loop and show the previous key view loop.

Insert overlay view into view hierarchy

Next, we implement methods to insert our overlay view into the view hierarchy, and to build our list of keyViews. We do this by following the nextKeyView (or previousKeyView) property of the starting view and recording information about each view we encounter.

- (void)insertIntoViewHirarchy {
    if (self.superview) {
        [self removeFromSuperview];
    }
    self.frame = [[[self.rootView window] contentView] frame];
    [[[self.rootView window] contentView] addSubview:self positioned:NSWindowAbove relativeTo:nil];
}

- (void)showPreviousKeyViewLoopForView:(NSView *)startView {
    [self insertIntoViewHirarchy];
    
    if (!self.keyViews) self.keyViews = [NSMutableArray new];
    [self.keyViews removeAllObjects];
    
    NSLog(@"+++PreviousKeyViewLoop");
    NSView *view = startView;
    NSMutableSet *visitedViews = [NSMutableSet new];
    NSInteger num = 0;
    do {
        NSRect frameInWindow = [view convertRect:view.bounds toView:nil];
        OPTestKeyView *keyView = [[OPTestKeyView alloc] init];
        keyView.frame = [self convertRect:frameInWindow fromView:nil];
        keyView.title = [NSString stringWithFormat:@"%ld", num];
        keyView.color = [NSColor redColor];
        keyView.font = [NSFont fontWithName:@"Menlo" size:12];
        
        NSLog(@"%ld %@", num, [view className]);
        [self.keyViews addObject:keyView];
        
        [visitedViews addObject:view];
        view = view.previousKeyView;
        num--;
    } while (view && ![visitedViews containsObject:view]);
    NSLog(@"---");
    
    self.rootFrame = [self convertRect:[startView convertRect:startView.bounds toView:nil] fromView:nil];
    [self setNeedsDisplay:YES];
}

- (void)showNextKeyViewLoopForView:(NSView *)startView {
    [self insertIntoViewHirarchy];
    
    if (!self.keyViews) self.keyViews = [NSMutableArray new];
    [self.keyViews removeAllObjects];
    
    NSLog(@"+++NextKeyViewLoop");
    NSView *view = startView;
    NSMutableSet *visitedViews = [NSMutableSet new];
    NSInteger num = 0;
    do {
        NSRect frameInWindow = [view convertRect:view.bounds toView:nil];
        OPTestKeyView *keyView = [[OPTestKeyView alloc] init];
        keyView.frame = [self convertRect:frameInWindow fromView:nil];
        keyView.title = [NSString stringWithFormat:@"%ld", num];
        keyView.color = [NSColor blueColor];
        keyView.font = [NSFont fontWithName:@"Menlo" size:12];
        
        NSLog(@"%ld %@", num, [view className]);
        [self.keyViews addObject:keyView];
        
        [visitedViews addObject:view];
        view = view.nextKeyView;
        num++;
    } while (view && ![visitedViews containsObject:view]);
    NSLog(@"---");
    
    self.rootFrame = [self convertRect:[startView convertRect:startView.bounds toView:nil] fromView:nil];
    [self setNeedsDisplay:YES];
}

- (void)clear {
    [self.keyViews removeAllObjects];
    [self removeFromSuperview];
}

Draw the overlay

Now that we have overlay information to display, and our OPTestKeyLoopView is in the view hierarchy, we can draw the overlay.

- (NSDictionary *)attributesWithFont:(NSFont *)font color:(NSColor *)color {
    NSMutableParagraphStyle *paragraphStyle = [[NSParagraphStyle defaultParagraphStyle] mutableCopy];
    [paragraphStyle setAlignment:NSCenterTextAlignment];
    [paragraphStyle setLineBreakMode:NSLineBreakByTruncatingTail];
    [paragraphStyle setLineSpacing:0];
    
    NSDictionary *attributes = [[NSDictionary alloc] initWithObjectsAndKeys:
                                font, NSFontAttributeName,
                                color, NSForegroundColorAttributeName,
                                paragraphStyle, NSParagraphStyleAttributeName, nil];
    return attributes;
}

- (void)drawRect:(NSRect)dirtyRect {
    if (self.keyViews.count > 0) {
        [[NSColor colorWithDeviceRed:1 green:0 blue:0 alpha:0.03] set];
        NSRectFillUsingOperation(self.rootFrame, NSCompositeSourceOver);
    }
    
    for (OPTestKeyView *keyView in self.keyViews) {
        [keyView.color set];
        [NSBezierPath setDefaultLineWidth:0.5];
        [NSBezierPath strokeRect:NSOffsetRect(NSIntegralRect(keyView.frame), 0.25, 0.25)];
        NSDictionary *attributes = [self attributesWithFont:keyView.font color:keyView.color];
        NSSize size = [keyView.title sizeWithAttributes:attributes];
        NSRect textRect = NSIntegralRectWithOptions(NSInsetRect(keyView.frame, (keyView.frame.size.width - size.width)/2, (keyView.frame.size.height - size.height)/2), NSAlignMinXOutward|NSAlignMaxXInward|NSAlignMinYOutward|NSAlignMaxYInward);
        [[NSColor whiteColor] set];
        NSRectFill(textRect);
        [keyView.title drawInRect:textRect withAttributes:attributes];
    }
}

In drawRect: we loop through our keyView array and draw the overlay information for each keyView.

Tidy up

Lastly, we do some housekeeping by making sure our OPTestKeyLoopView is non-opaque and does not respond to mouse clicks.

- (BOOL)isOpaque {
    return NO;
}

- (NSView *)hitTest:(NSPoint)aPoint {
    return nil;
}

@end

Here is what this looks like in action:

Debugging Next Key View Loops

Here is the code contained in this article (under an MIT license):

We’ve found this technique extremely useful when dealing with complex windows, and we hope you find it helpful too.

0 replies

Leave a Reply

Want to join the discussion?
Feel free to contribute!

What's on your mind?