Как UILabel вертикально центрирует свой текст?
Было много путаницы в том, чего я на самом деле пытаюсь достичь. Поэтому, пожалуйста, позвольте мне переформулировать мой вопрос. Это так же просто, как:
Как текст центра Apple внутри UILabel
-drawTextInRect:
?
Вот какой контекст: почему я ищу подход Apple к центру текста:
- Я не доволен тем, как они центрируют текст внутри этого метода.
- У меня есть собственный алгоритм, который делает работу так, как я хочу.
Однако есть некоторые места, в которые я не могу внедрить этот алгоритм, даже не с помощью swizzling. Знакомство с точным алгоритмом, который они используют (или эквивалентом этого), решит мою личную проблему.
Здесь вы можете поэкспериментировать с демонстрационным проектом. Если вам интересно, что моя проблема с реализацией Apple, посмотрите на левую сторону (базовая UILabel): текст вскакивает вверх и вниз, когда вы увеличиваете размер шрифта с помощью ползунка, а не постепенно перемещаетесь вниз.
Опять же, все, что я ищу, это реализация -drawTextInRect:
, которая делает то же самое, что и base UILabel
, но без вызова super
.
- (void)drawTextInRect:(CGRect)rect
{
CGRect const centeredRect = ...;
[self.attributedText drawInRect:centeredRect];
}
Ответы
Ответ 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) Исправьте файл шрифта, чтобы исходный, восходящий и спускатель были правильными.