Thursday, 19 July 2012

Author's note: Well, I've already blown my one-post-per-week
target. UIButton's insets behaviour finally got the
best of me and I had to take a major detour to settle the issue once
and for all. I hope it was worth it!

It's very common to want to adjust the bounds of
a UILabel to fit its contents. The
most common technique I've seen from developers looks something like
this:

This code is using one
of NSString's sizeWithFont:... methods to
calculate the bounds of the label's text, taking its font and width
into consideration.

If you are sizing your labels like this, you need to read on
because you are doing it wrong.

A smell

Let's take a step back and think about this for a moment.

We want to know how much space the label needs to display its
text. Surely that logic belongs in UILabel? Only the
label itself really knows how it renders its content and what
offsets or margins it may be applying.

It seems like a bit of a smell to me that we have to calculate this
stuff ourselves using methods on another class.

Now in the case of UILabel, it
turns out that -sizeWithFont:
returns exactly the size we need. The label is probably doing a
simple -[NSString drawAtPoint:...]
which matches up perfectly with the results we get back from
the sizeWithFont:... methods. But
is this always going to be true?

What if UILabel decides to add support for special
borders, or configurable line heights, or some other visual effects
that will change the required bounds? The above code is going to
break. It isn't future proof, and we are essentially duplicating or
even worse
guessing the behaviour of UILabel's rendering.

Is UILabel really going to change in such a manner?
Well, it just did. As of iOS 6, UILabel supports the
rendering of attributed strings. This means arbitrary ranges in the
label's text can have different fonts and styles applied to them. If
there is word in the middle of the label with a very large font,
bounds required to fit that string in the label are going to
increase.

If you use the previous code unmodified on a label containing an
attributed string, you are going to get incorrect results.

As it turns out, that there are UIKit additions
to NSAttributedString,
namely boundingRectWithSize:options:context:, which
provide similar functionality
to NSString's -sizeWithFont: methods. But
our code still should not be duplicating this kind of logic,
especially when there is a far better and simpler solution...

sizeToFit
and sizeThatFits:

If we want to resize a label to fit its contents, we can just tell
it to do so:

[label sizeToFit];

Bam. Done. The label will take its current width, and adjust its
height to fit its contents (assuming it is a multi-line label). If
the label has a width of 0, its size is adjusted to fit everything
on a single line.

If you wish to size the label without setting a frame beforehand,
you can ask the label for the size it needs like this:

Both sizeToFit and sizeThatFits: are
standard in UIKit and have existed for a very long time. They work
with the new attributed string support in iOS 6, and will continue
to work no matter what changes are made to UILabel.

It pains me to see people writing useless (and often times
incorrect) categories
on UILabel for something as standard as this. I
guess the lesson to take away is to explore as much of the
documentation as you can. I'm sure there are many useful methods out
there that I've overlooked.

Going a little deeper

What you need to know is that sizeThatFits: is
overridable and returns the "most appropriate" size for the control
that fits the constraints passed to it. The method can
decide to ignore the constraints if they cannot be met.

sizeToFit will simply call through
to sizeThatFits: passing the view's current size as the
argument. It will then update the view's frame based on the value it
gets back. So all the important logic goes
in sizeThatFits:, and this is the method you should
override for your own custom controls.

A major detour: UIButton insets demystified

Many of the standard UIKit controls
implement sizeThatFits:, one of which
is UIButton. However, things can get a little tricky
with UIButton, especially when when you throw insets
into the mix.

We'll start by creating a button with an image, a stretchable
background image, and some text:

The button elements are all crammed together with no spacing. This
is expected, and to fix this we need to give the button some insets.

UIButton provides
three UIEdgeInsets properties that
you can play with to adjust the spacing of the elements in the
button. These
are contentEdgeInsets, imageEdgeInsets
and titleEdgeInsets.

If you've ever tried adjusting these in Interface Builder, you'll
know things can get a little... interesting. For example, you may
have tried increasing imageEdgeInsets.left 1 point at a
time and seen how the button image seems to move unpredictably,
sometimes making big steps between values:

The reason for this is that a positive inset value will shrink the
layout rectangle for the image and give you you unpredictable
results as the button tries to fit the image into a bounding box
which is too small.

Edge inset values are applied to a rectangle to shrink or expand the
area represented by that rectangle. Typically, edge insets are used
during view layout to modify the view’s frame. Positive values cause
the frame to be inset (or shrunk) by the specified amount. Negative
values cause the frame to be outset (or expanded) by the specified
amount.

What this means is that if we want to reliably shift the image or
text, we must add or subtract equal (but opposite) amounts to both
left/right or top/bottom insets.

Confused? I'm not surprised. To help you visualize all this more
easily and see the effect of sizeToFit at the same
time, I've written a little iPhone app called
ButtonInsetsPlayground. The
source is available on github.

Please forgive the
made-by-a-programmer UI, this is purely for testing.

If you play around with this UI for a while, you should end up even
more confused than you were before. I seriously considered cracking
out IDA and diving in to the UIButton internals to
figure out what is going on, but I really want to finish this post.
(hint to the curious: the relevant selectors are -[UIButton
contentRectForBounds:], -[UIButton
titleRectForContentRect:] and -[UIButton
imageRectForContentRect:])

What you need to know is the following...

contentEdgeInsets

contentEdgeInsets is pretty intuitive and will behave
as you expect. You can easily add space around both the image and
text to pad things out nicely. Use positive values to inset the
content. The implementation of sizeThatFits: causes the
button to grow appropriately when we call sizeToFit:

UPDATE: I originally wrote about how it's possible
to get pixel misaligned images with certain content
insets. Naturally, this turned out to be my own fault.

imageEdgeInsets and titleEdgeInsets

The golden rule when it comes to these two insets is to add equal
and opposite offsets to the left and right insets. So if you add 5pt
to the left title inset, you must apply -5pt to the right. This
means you are using these insets only to offset the image
or text, not to resize them in any way.

If you do not follow this rule, the calculated layout rect for the
title (or image) may become too small and you risk text truncation
and other unexpected results:

This problem may not reveal itself until you have a string of the
appropriate length, so if the text in your buttons is dynamic or
localization-aware you need to be careful.

The top/bottom insets do not seem to have any major issues, but you
should probably follow the same rule for these as well.

UIButton's mystical insets behaviour could be the topic
of an entire blog post of its own, but I think we have enough
information to continue on our way.

Finishing up

Back to our button.

We want to space out the elements a little better, and make
sure sizeToFit does the right thing.

18 comments:

While I agree that in most cases you shouldn't be using sizeWithFont, there are one or two cases where it is necessary. Most notably when you want to create a multiline UILabel that behaves as a single line label would with adjustsSizeToFitWidth enabled.

In this case, (as Im sure you would agree) you should create a subclass of UILabel and override sizeThatFits: to encapsulate this logic.

I haven't been able to get a label with subscript to show up correctly in a UIButton, the button of the text is cut off. I've tried using sizeWithFont and sizeToFit, but no help so far. If you have any suggestion, I would greatly appreciate it.Here's the subscript code I'm adding to the button[btn setTitle:@"\u2080" forState:UIControlStateNormal];

I agree that calculating the required size of a view should be part of the view class and not be done by another class. However, sometimes you don't even have an instance of the view class because you need to know the required size before the view has been created. E.g. for a UITableView with dynamic row heights (depending on the row content) you have to provide the necessary height of each row in advance in tableView:heightForRowAtIndexPath:. I don't think that creating a view just to calculate the height of each row is good practice.

Berating people for not using methods that "have been around forever," without pointing out that until recently (iOS6) these methods were COMPLETELY BROKEN, is just dumb. Do your research before you start picking on people for using the methods that actually work. (And yes, I have clients that still need iOS5 compatibility.)