UILabel 如何垂直居中其文本?

How does UILabel vertically center its text?

对于我实际想要实现的目标存在很多困惑。所以请让我重述我的问题。很简单:

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


以下是我寻找 Apple 居中文本方法的一些背景信息:

但是,有些地方我无法将此算法注入到其中,即使使用方法调配也是如此。了解他们使用的确切算法(或等效算法)将解决我个人的问题。


这里有一个 demo project 您可以试验一下。如果您对我的 Apple 实现问题感兴趣,请查看左侧(基本 UILabel):当您使用滑块增加字体大小时,文本会上下跳动,而不是逐渐向下移动。

同样,我要寻找的是 -drawTextInRect: 的实现,它与基 UILabel 完全相同——但不调用 super.

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

    [self.attributedText drawInRect:centeredRect];
}

我没有答案,但我做了一些研究并认为我会分享它。我使用 Xtrace,它可以让您跟踪 Objective-C 方法调用,以尝试找出 UILabel 在做什么。我将 Xtrace git 存储库中的 Xtrace.hXtrace.mm 添加到 Christian 的 UILabel 演示项目中。在 RootViewController.m 中,我添加了 #import "Xtrace.h",然后尝试了各种跟踪过滤器。将以下内容添加到 RootViewController 的 loadView:

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

给我这个输出:

| [<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>]

(我 运行 使用 Xcode 7.0 beta,iOS 9.0 模拟器。)

我们可以看到,Apple 的实现不会将 drawInRect: 发送到 NSAttributedString,而是将 drawWithRect:options:attributes: 发送到 NSString。我猜这些最终会做同样的事情。

我们还可以准确地看到计算出的 Y 偏移量是多少。在滑块的这个初始位置,它是 156.3134765625。有趣的是,它不是落在某个四舍五入的整数值或 0.25 的倍数或类似值上的东西,否则这将是对跳跃的解释。

我们还看到 UILabel 发送 20.287109375 作为高度,这与 self.attributedText.size.height 计算的值相同。所以它似乎正在使用它,尽管我无法追踪那个调用(也许它是直接使用 Core Text?)。

这就是我被困的地方。一直在尝试查看 UILabel 计算的内容与明显的公式 "labelYOffset = (boundsHeight - labelHeight) / 2.0" 之间的区别,但我发现没有一致的模式。

更新 1. 所以我们可以将这个难以捉摸的 Y 偏移建模为

labelYOffset = (boundsHeight - labelHeight) / 2.0 + error

error 是什么,正是我们想要弄清楚的。让我们试着像科学家一样研究这个。 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 根据标签高度绘制误差。

有意思。我们看到误差有一些线性,但如果您理解我的意思,它们会在某些点奇怪地跳到线之间。这里发生了什么?在解决这个问题之前,我会失眠。 :)

我在自己制作的贴图应用中经常处理这个问题。

我认为问题可能不是 UILabel 的问题,而是包含上升和下降的字体。我的假设是 UILabel 在定位文本正文时会考虑这些值,这对明显的垂直位置有影响。很多字体的问题是这些值在 TTF / OTF 文件中完全搞砸了,所以你得到例如使用 .sizeToFit()、重叠线、不稳定的垂直位置等剪裁下降部

有以下三种方法可以解决这个问题:

1) 使用 NSAttributedString 和更多带有 NSBaselineOffsetAttributeName 的基线。 TTTAttributedLabel 是一个很好的快捷方式。

2) 使用 CoreText 和 CoreGraphics 在自定义 UIView 子类中的 CGLayer 中呈现您自己的文本布局,并有效地创建您自己的 UILabel 等效项。这很难!

3) 修复字体文件,使基线、上升和下降都正确。

在 WWDC 两年零一周后,我终于弄明白到底是怎么回事了。

至于为什么 UILabel 会这样:看起来 Apple 试图将 ascender and descender 之间的任何内容居中。然而,实际的文本绘制引擎总是将基线对齐到像素边界。如果在计算绘制文本的矩形时不注意这一点,基线可能会看似随意地上下跳跃。


主要问题是找到 Apple 放置基线的位置。 AutoLayout 提供了一个 firstBaselineAnchor,但遗憾的是无法从代码中读取它的值。只要标签的高度四舍五入为像素,以下代码片段似乎可以正确预测所有屏幕 scales 和 contentScaleFactors 的基线。

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
    }
}

当标签的高度没有四舍五入到像素时,预测的基线往往会偏离一个像素,例如对于 2x 设备上 40.1pt 容器中的 13.0pt 字体或 3x 设备上 40.1pt 容器中的 16.0pt 字体。

请注意,我们必须在此处以像素级别工作。使用点(即立即除以屏幕比例而不是在最后除以)会导致@3x 屏幕上由于浮点不准确而出现差一错误。

例如,将 47px (15.667pt) 的内容置于 121px 容器 (40.333pt) 的中心,我们得到 12.333pt 的偏移量,由于浮点错误,该偏移量上限为 12.667pt。


为了回答我最初提出的问题,我们可以使用(希望是正确的)预测基线实现 -drawTextInRect:,以产生与 UILabel 相同的结果。

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)
    }
}

请参阅 project on GitHub 了解工作实施。