Подтвердить что ты не робот

Как UILabel вертикально центрирует свой текст?

Было много путаницы в том, чего я на самом деле пытаюсь достичь. Поэтому, пожалуйста, позвольте мне переформулировать мой вопрос. Это так же просто, как:

Как текст центра Apple внутри UILabel -drawTextInRect:?


Вот какой контекст: почему я ищу подход Apple к центру текста:

  • Я не доволен тем, как они центрируют текст внутри этого метода.
  • У меня есть собственный алгоритм, который делает работу так, как я хочу.

Однако есть некоторые места, в которые я не могу внедрить этот алгоритм, даже не с помощью swizzling. Знакомство с точным алгоритмом, который они используют (или эквивалентом этого), решит мою личную проблему.


Здесь вы можете поэкспериментировать с демонстрационным проектом. Если вам интересно, что моя проблема с реализацией Apple, посмотрите на левую сторону (базовая UILabel): текст вскакивает вверх и вниз, когда вы увеличиваете размер шрифта с помощью ползунка, а не постепенно перемещаетесь вниз.

Опять же, все, что я ищу, это реализация -drawTextInRect:, которая делает то же самое, что и base UILabel, но без вызова super.

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

    [self.attributedText drawInRect:centeredRect];
}
4b9b3361

Ответ 1

Через два года и неделю в WWDC я наконец понял, что, черт возьми, происходит.

Что касается того, почему UILabel ведет себя так, как это делается: похоже, что Apple пытается сосредоточить все, что лежит между ascender и descender. Однако фактический механизм рисования текста всегда привязывает базовую линию к границам пикселей. Если вы не обращаете на это внимание при вычислении прямоугольника, в котором нужно нарисовать текст, базовая линия может прыгать вверх и вниз, казалось бы, произвольно.


Основная проблема - найти, где Apple помещает базовую линию. AutoLayout предлагает firstBaselineAnchor, но его значение, к сожалению, не может быть прочитано из кода. Следующий фрагмент, по-видимому, правильно предсказывает базовую линию на всех экранах scale и contentScaleFactor, пока высота метки округлена до пикселей.

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

Когда высота метки не округлена до пикселей, предсказанная базовая линия является пикселем чаще, чем нет, например. для шрифта 13.0pt в контейнере 40.1pt на устройстве 2x или шрифте 16.0pt в контейнере 40.1pt на трехмерном устройстве.

Обратите внимание, что мы должны работать на уровне пикселей здесь. Работа с точками (т.е. Деление по шкале экрана сразу, а не на конец) приводит к ошибкам за один раз на экранах @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)
    }
}

Смотрите проект на GitHub для рабочей реализации.

Ответ 2

У меня нет ответа, но я сделал небольшое исследование и подумал, что поделюсь им. Я использовал Xtrace, который позволяет отслеживать вызовы методов Objective-C, чтобы попытаться выяснить, что делает UILabel. Я добавил Xtrace.h и Xtrace.mm из репозитория Xtrace git в демонстрационный проект 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, это то, что мы пытаемся выяснить. Попробуем изучить это, как ученый. Здесь есть вывод Xtrace, когда я перетащил слайд до конца, а затем в начало. Я также добавил NSLog при каждой смене слайдера, которая помогает с разделением записей. Я написал Python script для построения ошибки относительно высоты метки.

Plot or UILabel error in vertical centering against label height

Интересно. Мы видим, что существует некоторая линейность ошибок, но они странно перепрыгивают между линиями в определенных точках, если вы понимаете, что я имею в виду. Что здесь происходит? Я потеряю сон, пока это не решится.:)

Ответ 3

Я много разбирался в этом приложении, которое я сделал.

Я думаю, что проблема, вероятно, не столько в UILabel, сколько в шрифте, включая ascender и descenders. Мое предположение заключается в том, что UILabel учитывает эти значения, когда он позиционирует тело текста, которое влияет на кажущееся вертикальное положение. Проблема со множеством шрифтов заключается в том, что эти значения полностью завинчиваются в файле TTF/OTF, поэтому вы получаете, например, отсечение спускателя с помощью .sizeToFit(), перекрывающихся линий, вертикальной позиции и т.д.

Есть три способа справиться с этим:

1) Используйте NSAttributedString и более базовую линию с NSBaselineOffsetAttributeName. TTTAttributedLabel - хороший ярлык.

2) Используйте CoreText и CoreGraphics для рендеринга собственного текстового макета в CGLayer в пользовательском подклассе UIView и эффективно создайте свой собственный эквивалент UILabel. Это тяжело!

3) Исправьте файл шрифта, чтобы исходный, восходящий и спускатель были правильными.