Implementing drawRect with UIPopoverBackgroundView

August 10, 2013

Recently I found myself styling a UIPopoverView. I wasn’t content with the results setTintColor: was giving me and I didn’t wish to use a Photoshop approach. Instead, I tried achiving the effects I wanted by subclassing UIPopoverBackgroundView and using Core Graphics to draw. Here’s what I came up with.

I started by subclassing UIPopoverBackgroundView and implementing the arrowHeight, arrowWidth and contentViewInsets methods. The arrowHeight and arrowWidth methods return the height and width of the popover arrow while the contentViewInsets method returns UIEdgeInsets denoting how much the popovers contents should be inset from the outside edge.

+ (CGFloat)arrowBase {
    return 30.0f;
}

+ (CGFloat)arrowHeight {
    return 15.0f;
}

+ (UIEdgeInsets)contentViewInsets {
    return UIEdgeInsetsMake(10.0f, 10.0f, 10.0f, 10.0f);
}

Then I moved onto drawRect: and determined the popovers frame based on the arrow direction. The CGRect given to drawRect: encompasses the entire popover view, including the arrow. Using the arrow direction, I created a CGRect inset in one direction by the arrow height. For example, in the picture below, the inset rect for the popover frame created is the red rectangle while the larger black rectangle is the bounding rect for both popover and arrow.

I created a helper method for returning the insets and then used UIEdgeInsetsInsetRect to inset one side of the rectangle.

- (UIEdgeInsets)edgeInsetsForArrowDirection:(UIPopoverArrowDirection)direction {
    if (direction == UIPopoverArrowDirectionUp) {
        return UIEdgeInsetsMake(ARROW_HEIGHT, 0.0f, 0.0f, 0.0f);
    } else if (direction == UIPopoverArrowDirectionDown) {
        return UIEdgeInsetsMake(0.0f, 0.0f, ARROW_HEIGHT, 0.0f);
    } else if (direction == UIPopoverArrowDirectionLeft) {
        return UIEdgeInsetsMake(0.0f, ARROW_HEIGHT, 0.0f, 0.0f);
    } else if (direction == UIPopoverArrowDirectionRight) {
        return UIEdgeInsetsMake(0.0f, 0.0f, 0.0f, ARROW_HEIGHT);
    } else {
        return UIEdgeInsetsZero;
    }
}

The technique I use for drawing rounded one pixel rectangles involves using an outer and inner rectangle, where the inner is uniformly inset from the outer by a corner radius. Then a path is walked around the outer rectangle, using arcs at the corners whose centers are the corners of the inset inner rect. The inset rect is an imaginary rect. We really only care about corners of it. To make things a little clearer, here is an illustration of how the drawing takes place. The blue dotted rect is the invisible inset rect. The four red dots represent the points rotated around to form the four arcs.

Below is the (abbreviated) helper method I used for returning the UIBezierPath for the popover path. I am drawing the path manually using addLineToPoint instead of using something like bezierPathWithRoundedRect:cornerRadius:, so I can ensure the line is crisp and drawing without aliasing.

- (UIBezierPath *)popoverPathWithRect:(CGRect)rect direction:(UIPopoverArrowDirection)direction {
    // corner radius of popover
    CGFloat radius = 10.0f;
    
    // edge insets adjusted for arrow direction
    UIEdgeInsets edgeInsets = [self edgeInsetsForArrowDirection:self.arrowDirection];
    
    // setup two rectangles for popover
    CGRect outerRect = UIEdgeInsetsInsetRect(rect, edgeInsets);
    CGRect innerRect = CGRectInset(outerRect, radius, radius);
    
    UIBezierPath *path = [UIBezierPath bezierPath];
    
    if (self.arrowDirection == UIPopoverArrowDirectionLeft) {
        // determine where the arrow is
        CGFloat arrowEdge = CGRectGetMidY(outerRect) + self.arrowOffset - (ARROW_BASE / 2.0f);
        CGRect arrowRect = CGRectMake(CGRectGetMinX(rect), CGRectGetMinY(outerRect) + arrowEdge, ARROW_HEIGHT, ARROW_BASE);
        
        // start in upper left corner
        [path moveToPoint:CGPointMake(CGRectGetMinX(innerRect) + 0.5f, CGRectGetMinY(outerRect) + 0.5f)];

        // add three arcs, connected with lines
        [path addArcWithCenter:CGPointMake(CGRectGetMaxX(innerRect) - 0.5f, CGRectGetMinY(innerRect) + 0.5f)
                               radius:radius startAngle:DEG_TO_RAD(270.0f) endAngle:DEG_TO_RAD(0.0f) clockwise:YES];
        [path addArcWithCenter:CGPointMake(CGRectGetMaxX(innerRect) - 0.5f, CGRectGetMaxY(innerRect) - 0.5f)
                               radius:radius startAngle:DEG_TO_RAD(0.0f) endAngle:DEG_TO_RAD(90.0f) clockwise:YES];
        [path addArcWithCenter:CGPointMake(CGRectGetMinX(innerRect) + 0.5f, CGRectGetMaxY(innerRect) - 0.5f)
                               radius:radius startAngle:DEG_TO_RAD(90.0f) endAngle:DEG_TO_RAD(180.0f) clockwise:YES];
        [path addLineToPoint:CGPointMake(CGRectGetMaxX(arrowRect) + 0.5f, CGRectGetMaxY(arrowRect) - 0.5f)];
        [path addLineToPoint:CGPointMake(CGRectGetMinX(rect) + 0.5f, CGRectGetMidY(arrowRect) - 0.5f)];
        [path addLineToPoint:CGPointMake(CGRectGetMaxX(arrowRect) + 0.5f, CGRectGetMinY(arrowRect) - 0.5f)];
        [path addArcWithCenter:CGPointMake(CGRectGetMinX(innerRect) + 0.5f, CGRectGetMinY(innerRect) + 0.5f)
                               radius:radius startAngle:DEG_TO_RAD(180.0f) endAngle:DEG_TO_RAD(270.0f) clockwise:YES];
    } else if (self.arrowDirection == UIPopoverArrowDirectionDown) {
        // handle direction down
    } else if (self.arrowDirection == UIPopoverArrowDirectionUp) {
        // handle direction up..
    } else if (self.arrowDirection == UIPopoverArrowDirectionRight) {
        // handle direction right
    }
    
    return path;
}

I created a Gist with full implemention. It should be easy to modify arrow and inset parameters, or wrap this into a more generic package.