Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How does UILabel vertically center its text?

Tags:

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];
}
like image 308
Christian Schnorr Avatar asked Jun 10 '15 14:06

Christian Schnorr


People also ask

How do you align text on top of UILabel?

Set the UILabel number of lines equal to 0 . Embed the UILabel inside an UIView . Set the UIView constraints (top, right, left and bottom) to occupy the maximum space the UILabel should grow to. Then set the UILabel to top, right, and left of the UIView and also set a constraint to bottom with distance >= 0 .

How do I center text vertically in react native?

Here we are using justifyContent and alignItems stylesheet property to display text vertically and horizontally center in react native. justifyContent: 'center' sets the View's inside child component align Vertically center. alignItems: 'center' sets the View's inside child component align Horizontally center.

How do I center something vertically in bootstrap?

Another way to vertically center is to use my-auto . This will center the element within it's container. For example, h-100 makes the row full height, and my-auto will vertically center the col-sm-12 column. Since Bootstrap 4 .


2 Answers

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. :)

like image 177
skagedal Avatar answered Nov 08 '22 07:11

skagedal


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.

like image 24
Christian Schnorr Avatar answered Nov 08 '22 07:11

Christian Schnorr