How does UILabel vertically center its text?

1.7k views Asked by At

There has been a lot of confusion about what I am actually trying to achieve. So please let me reword my question. It is as simple as:

How does Apple center text inside UILabel's -drawTextInRect:?


Here's some context on why I am looking for Apple's approach to center text:

  • I am not happy with how they center text inside this method
  • I have my own algorithm that does the job the way I want

However, there are some places I can't inject this algorithm into, not even with method swizzling. Getting to know the exact algorithm they use (or an equivalent of that) would solve my personal problem.


Here's a demo project you can experiment with. In case you're interested what my problem with Apple's implementation is, look at the left side (base UILabel): The text jumps up and down as you increase the font size using the slider, instead of gradually moving downwards.

Again, all I am looking for is an implementation of -drawTextInRect: that does the very same as base UILabel -- but without calling super.

- (void)drawTextInRect:(CGRect)rect
{
    CGRect const centeredRect = ...;

    [self.attributedText drawInRect:centeredRect];
}
3

There are 3 answers

1
Christian Schnorr On BEST ANSWER

After two years and a week at WWDC I finally figured out what the hell is going on.

As for why UILabel behaves the way it does: It looks like Apple attempts to center whatever lies between ascender and descender. However, the actual text drawing engine always snaps the baseline to pixel boundaries. If one doesn't pay attention to this when calculating the rect in which to draw the text, the baseline may jump up and down seemingly arbitrarily.


The main problem is finding where Apple places the baseline. AutoLayout offers a firstBaselineAnchor, but it's value can unfortunately not be read from code. The following snippet appears to correctly predict the baseline at all screen scales and contentScaleFactors as long as the label's height is rounded to pixels.

extension UILabel {
    public var predictedBaselineOffset: CGFloat {
        let scale = self.window?.screen.scale ?? UIScreen.main.scale

        let availablePixels = round(self.bounds.height * scale)
        let preferredPixels = ceil(self.font.lineHeight * scale)
        let ascenderPixels = round(self.font.ascender * scale)
        let offsetPixels = ceil((availablePixels - preferredPixels) / 2)

        return (ascenderPixels + offsetPixels) / scale
    }
}

When the label's height is not rounded to pixels, the predicted baseline is a pixel off more often than not, e.g. for a 13.0pt font in 40.1pt container on a 2x device or a 16.0pt font in 40.1pt container on a 3x device.

Note that we have to work at pixel level here. Working with points (i.e. dividing by screen scale immediately rather than at the end) results in off-by-one errors on @3x screens due to floating point inaccuracies.

For example, centering a 47px (15.667pt) content in a 121px container (40.333pt) gives us a 12.333pt offset which gets ceiled to 12.667pt due to floating point errors.


For sake of answering the question I initially asked, we can then implement -drawTextInRect: using the (hopefully correctly) predicted baseline to yield the same results as UILabel does.

public class AppleImitatingLabel: UILabel {
    public override func drawText(in rect: CGRect) {
        let offset = self.predictedBaselineOffset
        let frame = rect.offsetBy(dx: 0, dy: offset)

        self.attributedText!.draw(with: frame, options: [], context: nil)
    }
}

See the project on GitHub for a working implementation.

1
Martin On

I dealt with this a lot in a stickering app I made.

I think the issue is probably not so much with UILabel as the font including the ascender and descenders. My assumption is that UILabel takes these values into consideration when it positions the body of the text which has an impact on the apparent vertical position. The problem with a whole lot of fonts is that these values are totally screwed up in the TTF / OTF file so you get e.g. clipping of the descender with .sizeToFit(), overlapping lines, wonky vertical position etc.

There are three ways to deal with this:

1) Use an NSAttributedString and more the baseline with NSBaselineOffsetAttributeName. TTTAttributedLabel is a good shortcut.

2) Use CoreText and CoreGraphics to render your own text layout in a CGLayer within a custom UIView subclass and effectively create your own UILabel equivalent. This is HARD!

3) Fix the font file so the baseline, ascender and descender are all correct.

2
skagedal On

I don't have an answer, but I did a little research and thought I'd share it. I used Xtrace, which lets you trace Objective-C method calls, to try and figure out what UILabel is doing. I added Xtrace.h and Xtrace.mm from the Xtrace git repository to Christian's UILabel demo project. In RootViewController.m, I added #import "Xtrace.h", and then experimented with various trace filters. Adding the following to RootViewController's loadView:

[Xtrace includeMethods:@"drawWithRect|drawInRect|drawTextInRect"];
[Xtrace traceClassPattern:@"String|UILabel|AppleLabel" excluding:nil];

Gives me this output:

| [<AppleLabel 0x7fedf141b520> drawTextInRect:{{0, 0}, {187.5, 333.5}}]
|  [<NSConcreteMutableAttributedString 0x7fedf160ec30>/NSAttributedString drawInRect:{{0, 156.5}, {187.5, 333.5}}]
| [<UILabel 0x7fedf1418dc0> drawTextInRect:{{0, 0}, {187.5, 333.5}}]
|  [<UILabel 0x7fedf1418dc0> _drawTextInRect:{{0, 0}, {187.5, 333.5}} baselineCalculationOnly:0]
|   [<__NSCFConstantString 0x1051d1db0>/NSString drawWithRect:{{0, 156.3134765625}, {187.5, 20.287109375}} options:1L attributes:<__NSDictionaryM 0x7fedf141ae00> context:<NSStringDrawingContext 0x7fedf15503c0>]

(I'm running this with Xcode 7.0 beta, iOS 9.0 simulator.)

We can see that Apple's implementation does not send drawInRect: to an NSAttributedString, but drawWithRect:options:attributes: to an NSString. I'm guessing these will end up doing the same.

We can also see exactly what the calculated Y offset is. In this initial position of the slider, it is 156.3134765625. It is interesting to note that it is not something that falls on some rounded integer value, or a multiple of 0.25 or similar, which would otherwise be an explanation for the jumpiness.

We also see that UILabel sends 20.287109375 as the height, this is the same value as is calculated by self.attributedText.size.height. So it seems to be using that, though I haven't been able to trace that call (maybe it is using Core Text directly?).

So that's where I'm stuck. Been trying to look at the difference between what UILabel calculates and the obvious formula "labelYOffset = (boundsHeight - labelHeight) / 2.0", but I find no consistent pattern.

Update 1. So we can model this elusive Y offset as

labelYOffset = (boundsHeight - labelHeight) / 2.0 + error

What the error is, is what we're trying to figure out. Let's try to study this like a scientist. Here is Xtrace output when I've dragged the slide to the end and then to the beginning. I also added an NSLog at each change of the slider which helps with separating the entries. I wrote a Python script to plot the error against the label height.

Plot or UILabel error in vertical centering against label height

Interesting. We see that there's some linearity to the errors, but they strangely jump between the lines at certain points, if you understand what I mean. What is going on here? I'll be losing sleep until this is solved. :)