Магия UILabel или приватное API Autolayout

Alexander Goremykin
Яндекс.Карты Mobile
8 min readMar 24, 2018

UILabel — тривиальный компонент UIKit, который каждый использовал огромное число раз. Простой как топор, но отлично выполняющий свои обязанности. Однако, при встрече с Autolayout, лейбл показывает, что и у него есть свои козыри.

Рассмотрим тривиальный пример. UILabel со снятым ограничением на количество строк, прибитый к своей superview констрейнтами с трех сторон: слева, справа и сверху. Высота лейбла никак не ограничивается.

Что будет, если изменится доступная для UILabel ширина? Ожидаемо, его высота обновится так, чтобы идеально совпадать с высотой контента.

UILabel автоматически рассчитывает высоту, основываясь на заданных констрейнтах и контенте.

Ничего удивительного, это именно то, что мы ожидаем, когда оставляем “свободной” высоту. Но как это реализовано?

Чтобы дальнейшие рассуждения были менее абстрактными и их можно было сразу апробировать, предлагаю пописать немного кода. Давайте напишем свой лейбл! Для начала определимся с тем, к чему мы будем стремиться. Как ни странно, нас абсолютно не будет интересовать собственно рендеринг текста. Его можно реализовать через CoreText, CATextLayer или еще как угодно. Сейчас я предлагаю сосредоточиться только на повторении self-sizing поведения оригинального UILabel. Закрепим основные цели, к которым будем идти. Наша реализация:

  • должна быть наследником UIView
  • иметь тривиальный интерфес
var attributedText: NSAttributedString? { get set }
  • должна работать везде где работает оригинальный UILabel и не требовать написания дополнительного, по сравнению с референсом, кода для использования

И назовем все это дело ASLabel’ом.

Звучит несложно. Давайте начинать.

“работать везде ... не требовать написания дополнительного кода” — наша мантра на следующие 10 минут

Ширина и высота intrinsicContentSize определяет константы констрейнтов, которые система неявно добавляет на нашу UIView.

Первое, что приходит в голову это корректное определение intrinsicContentSize. Действительно, это тот мостик, что связывает наш контент с миром Autolayout’а, со стороны которого intrinsicContentSize есть ни что иное, как два неявно добавленных констрейнта.

Определим intrinsicContentSize и будем возвращать размер контента, расcчитаный через boundingRect(with:options:context:)

Однако, одного intrinsicContentSize будет недостаточно. Очевидно, что для его расчета мы будем опираться на текущую ширину UIView, но она еще не посчитана. Получаем замкнутый круг: intrinsicContentSize использует bounds.width, для расчета которого нужно знать intrinsicContentSize...

Но это не проблема. Все что нам надо, так это вызвать invalidateIntrinsicContentSize(), когда станет известна ширина. Получается, что все заработает ценой двойного прохода лейаута.

Первый проход: отдаем некорректный intrinsicContentSize, но тут оно нам и не важно, ведь все поправится во втором проходе. Второй проход: да, высота нашей UIView сейчас неверна, но зато мы знаем ширину, основываясь на которой можно рассчитать корректный intrinsicContentSize.

Напрашивается тривиальная реализация.

Хммм, но ведь мы планировали завязать invalidateIntrinsicContentSize() на факт обновления bounds, так почему пришлось трогать layoutSubviews? Поспешу вас остановить от перемещения этого кода в didSet var bounds: CGRect или даже в колбек KVO-обзервера на bounds— работать не будет. Система просто проигнорирует нашу просьбу о пересчете intrinsicContentSize и не инициирует второй layout-pass. Почему? Остается только гадать.

Слева - знакомая нам ситуация с одиноким лейблом. Все работает. Справа — два UITableView. У синего в ячейках лежит ASLabel, у красного — UILabel. Высота ячеек с ASLabel рассчитана некорректно!

Приведенные выше скриншоты демонстрируют нам, что текущая реализация ASLabel в ячейках UITableView — не работает!

Давайте разберем эту ситуацию подробнее. На экране представлены две UITableView. В методе делегата tableView(_:heightForRowAt:) возвращается константа UITableViewAutomaticDimension. В синей таблице в ячейках лежит ASLabel, в красной — UILabel. Видно, что для ячеек с ASLabel система не смогла рассчитать корректную высоту.

Рассмотрим алгоритм расчета высоты ячеек таблицы. Когда таблице по какой-либо причине хочется узнать высоту своей ячейки, то, безусловно, все начинается с вызова метода делегата tableView( _:heightForRowAt:). А далее зависит от возвращенного значения. Если была получена константа UITableViewAutomaticDimension, то расчет высоты делегируется системе Autolayout’а, которая посредством вызова метода systemLayoutSizeFitting, основываясь только на добавленных на нее констрейнтах, получает размер UIView.

Тут стоит обратиться к документации и почитать как работает systemLayoutSizeFitting. Самое важное сейчас для нас это то, что этот метод никак не влияет на frame и, соответственно, не вызывает layoutSubviews. Отсюда следует невозможность работы нашей текущей имплементации ASLabel, ведь мы опираемся на bounds, который никогда не обновляется.

Но из той же документации мы узнаем, что параметром в systemLayoutSizeFitting передается некий targetSize: CGSize, который мы как раз и можем использовать как хинт при расчете intrinsicContentSize. Встает следующая проблема: targetSize относится только к ячейке, но не к её контенту, который, вообще говоря, может быть добавлен с некоторыми отступами. Если дерево контента ячейки довольно сложное, а ASLabel, очевидно, лежит в одном из листов, то нам придется протащить targetSize до самого низа, учитывая нужные отступы на каждом из слоев дерева.

Исходный TargetSize необходимо преобразовывать на каждом слое View-Tree по пути до ASLabel, учитывая отступы от superview. LabelTargetSize = CGSize(width: TargetSize — dx1 — dx2 — dx3 — image.size.width, height: TargetSize — dy1 — dy2).

Переопределим systemLayoutSizeFitting у UITableViewCell и пробросим переданный системой targetSize до ASLabel, пересчитав его на каждом уровне View-Tree в зависимости от требуемого лейаута.

Все работает как надо, но не так просто как с UILabel.

Отлично! Мы добились того, что теперь все работает и в UITableView. Кажется, мы все ближе к поставленной цели. Однако, хотя мы и получили работающую имплементацию ASLabel, до UILabel ей еще далеко. В самом деле: нам пришлось поработать с ячейкой таблицы, переопределив systemLayoutSizeFitting, пришлось прокинуть targetSize, применяя к нему необходимые преобразования. Таким образом, мы сделали довольно много дополнительной работы, которую не требует UILabel. Более того, мы довольно плотно поработали с фреймами, хотя планировали реализовать все на Autolayout’е. Пока не дожали. Разбираемся дальше!

“UILabel — работает сам, не требуя дополнительных усилий с нашей стороны.”

Пора придумать новый план. Суммируя все, сделаем предположение о том, что требуемую задачу можно решить как минимум за два прохода лейаута.

Решение задачи требует два прохода лейаута. Первый для получения опорной ширины, которую в дальнейшем можно использовать для верного расчета intrinsicContentSize. В ходе второго мы получим валидные фреймы, учитывающие корректное значение intrinsicContentSize.

Настало время заглянуть под капот UILabel и понять, что он такого делает с Autolayout’ом. В этом нам помогут вместе с Objective-C Runtime библиотеки:

Сперва обратимся к RuntimeBrowser — сдампим и изучим интерфейсы UIView и UILabel. Чтение тысяч строк прототипов функций не очень радует. Однако, памятуя о нашем предположении о двух проходах, попытаемся поискать по тексту что-то намекающее на эти проходы. Немного везения, перебора ключевых слов и, спустя некоторое время, на глаза попадаются две функции:

_prepareForFirstIntrinsicContentSizeCalculation_prepareForSecondIntrinsicContentSizeCalculationWithLayoutEngineBounds

Их названия недвусмысленно намекают, на то, что в какой-то момент у UIView появляется еще один шанс обновить свои констрейнты. При этом, для второй попытки нам любезно предоставляется некий LayoutEngineBounds.

Как уже было сказано, intrinsicContentSize преобразуется в два констрейнта. Таким образом, из возможности обновить свои констрейнты вытекает возможность обновить свой intrinsicContentSize.

Но давайте удостоверимся, что поведение простой UIView отличается от поведения UILabel. Тривиально переопределим метод, поставим внутри breakpoint и посмотрим что произойдет.

Результат ожидаем: в UILabel этот метод вызывается и туда передается некоторый bounds, в то время как в UIView не происходит ни одного обращения. Кажется наше предположение про два прохода подтверждается, и UILabel действительно обновляет свой intrinsicContentSize после первого constraint-pass, имея в своем распоряжении LayoutEngineBounds, который и использует как опорную ширину.

Остается понять, как получить второй шанс обновления констрейнтов. Настало время вспомнить про Xtrace. Эта библиотека позволяет логировать все обращения к объекту. Получившийся лог это своего рода развернутый во времени call-stack. Натравим Xtrace на инстанс UILabel’а и изучим вызовы методов между обращениями к _prepareForFirstIntrinsicContentSizeCalculation и _prepareForSecondIntrinsicContentSizeCalculationWithLayoutEngineBounds.

Глаз сразу цепляется за метод _needsDoubleUpdateConstraintsPass, название которого явно намекает на то, что у UIView напрямую спрашивается о необходимости второго constraint-pass. При этом, UILabel возвращает true.

UILabel явно говорит, что ему нужен еще один constraint-pass посредствам возврата true из метода _needsDoubleUpdateConstraintsPass.

Теперь посмотрим, что там с UIView. Повторяем работу c Xtrace и видим, что в случае обычной вью метод _needsDoubleUpdateConstraintsPass возвращает false.

Кажется, мы нашли точку разветвления между UIView и UILabel!

Ок, у нас есть возможность обновить констрейнты, но ведь чтобы верно посчитать intrinsicContentSize, нам надо знать опорную ширину. Откуда её достать? Самое простое это как раз завязаться на аргумент LayoutEngineBounds, который система нам предоставляет. Сохранить его и далее использовать при расчете размера. Но давайте копнем еще чуть-чуть и поймем, как мы можем вытащить LayoutEngineBounds напрямую.

Предлагаю не расписывать все шаги подробно — они в большей степени повторяют все то, что мы только что сделали. Сразу раскрою основные поинты.

За решение системы констрейнтов отвечает движок Autolayout’а — NSISEngine, инстанс которого мы можем получить через обращение к nsli_layoutEngine() -> NSISEngine. После первого расчета констрейнтов в движке уже есть фреймы удовлетворяющие констрейнтам, но пока они не присвоены вьюхам в иерархии. Эти промежуточные фреймы мы можем вытащить через nsis_compatibleBoundsInEngine(_:), передав параметром полученный NSISEngine.

Собирая все вместе, приходим к следующей упрощенной схеме цикла Autolayout’а:

ICS = IntrinsicContentSize

Пора применить полученные знания на практике!

Если вас что-то заинтересовало в приведенном выше коде с технической точки зрения, то предлагаю пройти по ссылке и ознакомиться с особенностями работы с Objective-C Runtime из Swift. Идейно это компиляция всего того, что мы узнали об устройстве UILabel.

Запускаем и убеждаемся, что все работает как надо!

При этом нам не пришлось совершать каких-то действий на стороне ячейки и прокидывать targetSize через View-Tree. Все было сделано строго на стороне ASLabel и является деталями его реализации. Кажется, теперь мы можем утверждать, что выполнили поставленную задачу. Отлично!

Подведем краткие итоги. Мы получили три подхода в решении задачи. Каждый из них имеет свои плюсы и минусы.

  • Тривиальное определение корректного intrinsicContentSize

Простота реализации несомненный плюс, но стоит помнить, что в сочетании с UITableViewAutomaticDimension это работать не будет. Тем не менее, если вы уверены, что ваша UIView никогда не попадет в таблицу, то это рабочее решение.

  • Переопределение systemLayoutSizeFitting

Нужны таблицы? Тогда это ваш выбор. Однако, придется смириться с тем, что код будет не так элегантен и потребует плотной работы с фреймами.

  • Private API и _needsDoubleUpdateConstraintsPass

Тут все ясно. Готовы идти в продакшен, используя приватное API — это идеальное для вас решение.

Вместо заключения.

Что мы вообще сделали? Зачем кому-то понадобится писать свой лейбл?! На самом деле, я не преследовал цели рассказать, как написать свой контрол для вывода строк текста. Ключевой идеей было показать на примере UILabel как можно реализовывать компоненты с нетривиальным self-sizing поведением. Возможно, перед вами будет стоять задача написать сложную UIView с gl-контекстом, которая будет требовать какого-нибудь хитрого поведения касательно её размеров или вы просто перепишите свою self-sizing таблицу, которая подстраивает свой размер под contentSize... Главное, что вы теперь знаете: Autolayout не так прост и позволяет делать невозможные на первый взгляд вещи.

Надо только копнуть.

--

--