Wednesday, October 7, 2009

Rounded Corners on UITableView

The iPhone UI is a soft, graceful collection of rounded corners. Your application icon is cropped and smoothed out with the ubiquitous curves. The highlight on the selected tab of a tab bar has tiny ones. Grouped table views, the corners of navigation items, the little buttons in toolbars. You can't escape the smooth arcs.

Which would be fine if Apple didn't require you to hand craft them yourself on your UI components. You might expect a "draw this with rounded corners" method. Actually, given their prevalence, you'd expect it to be a setting on UIView: cornerRadius or something like that.

But since that doesn't exist, every developer has to write his or her own take on it. So far, mine has become fairly standard. My UIView classes implement drawRect to set a curvy clip region. I have a utility method that takes any rect, with a desired radius, and returns a path for a rounded rectangle. Add that to the graphics context, call CGContextClip, and voila, I have voluptuousness. This has worked well.

Then I tried to do it on a UITableView embedded in a larger view. It's a view, right? So this should work, right? Except that UITableView's drawRect method doesn't really do anything. The corners don't get clipped if you subclass it and clip within drawRect.

Searching on this topic suggests that this is a common problem. I read various ideas, didn't really like them for one reason or another, and then came up with my own strategy. Why not overlay my table with a view that would act as a window? It would draw the part that I normally clip off and then make the interior transparent. And with a bit of work, I could make it a reusable view I could put atop other views that didn't behave the way I wanted. No more mucking around with clipping in random subviews!

I am not a graphics programmer. Not only did this take me a while, but there are probably better ways to do it. However, you may find yourself wondering how to do the same thing, and so I'm sharing my results.

Here's my init method. I used bgColor to represent the color on the outside of the corners, and I set the actual backgroundColor to clearColor I tried using clearColor as the fill color for my rounded path, but that didn't work. It was transparent over the view's background color, which meant the whole rectangle was filled in with the view's background, not the view underneath.



- (id)initWithFrame:(CGRect)frame {
if (self = [super initWithFrame:frame]) {
bgColor = [UIColor blackColor].CGColor;
self.backgroundColor = [UIColor clearColor];
self.userInteractionEnabled = YES;
}
return self;
}


And here's the drawing code. I used EOFillPath to ensure that my interior was empty. Yes, I hardcoded the radius. Yes, that's lame. I'll fix it in a refactor pass.


- (void)drawRect:(CGRect)rect {
// Drawing code
CGContextRef ctxt = UIGraphicsGetCurrentContext();

CGContextSetFillColorWithColor(ctxt,bgColor);
CGContextAddRect(ctxt,rect);
CGPathRef curvePath = [Utils roundedCornerPathFromRect:rect withRadius:10.0];
CGContextAddPath(ctxt, curvePath);
CGPathRelease(curvePath);

CGContextEOFillPath(ctxt);
[super drawRect: rect];
}


That's the result of a fair amount of fiddling, and it now works. Except for one problem: You've covered the view you want the user to see with another view, effectively removing the user's ability to interact with it. To get around this, I created a "coveredView" reference within RoundedCornerOverlay and set it in the view controller managing the whole view. I overrode nextResponder in my view to return this reference. Then I overrode the various event methods you inherit from UIView. The final step was to realize I needed to convert incoming points to be relevant within the covered view.



- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {
[[self nextResponder] touchesBegan:touches withEvent:event];
}

- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event {
[[self nextResponder] touchesMoved:touches withEvent:event];
}

- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event {
[[self nextResponder] touchesEnded:touches withEvent:event];
}

- (void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event {
[[self nextResponder] touchesCancelled:touches withEvent:event];
}

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
return [[self coveredView]
hitTest:[self convertPoint:point toView:[self coveredView]] withEvent:event];
}

- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event {
return [[self coveredView]
pointInside:[self convertPoint:point toView:[self coveredView]] withEvent:event];
}

5 comments:

  1. An alternative "hack" that only works in some circumstances is to put a disabled button behind the control to give you the corners, fit your table inside that and make sure all the colours work, etc so that the button is basically invisible and appears a part of the table.

    ReplyDelete
  2. Thanks for sharing. That seems like an equally viable workaround.

    ReplyDelete
  3. Any chance of getting a downloadable sample?

    ReplyDelete
  4. Hmm, have you thought about #import and then use the layer.cornerRadius property? Every UIView, and therefore a UITableView too, has a @property CALayer * layer. Every CALayer has a @property CGFloat cornerRadius. Set it and you're done.

    (Your assumption about Apple not providing a rounded corner property is also incorrect)

    Redmer

    ReplyDelete
  5. Ah, thanks for the tip. I was still finding my way through OS 3 APIs when I figured out this solution, so I hadn't yet noticed the CALayer.cornerRadius property (which seems to have only been available in 3.0 and later) I'll give that a try.

    ReplyDelete