Auto Layout строит вёрстку на constraint-ах: вы описываете отношения между view («на 16 pt ниже заголовка, той же ширины, что карточка»), а решатель на каждом проходе layout превращает их во frame-ы. При вёрстке на frame каждый прямоугольник вы считаете и задаёте сами.
Адаптируется сам: поворот, ресайз окна на iPad, Dynamic Type, длинные переводы и языки с письмом справа налево не требуют нового кода: решатель пересчитывает те же constraint-ы; код на frame-ах должен пересчитывать позиции в layoutSubviews() для каждого случая.
Управляется контентом: лейблы и кнопки сообщают intrinsic content size, поэтому длина текста может задавать высоту без ручного измерения строк.
Цена: решатель (на основе алгоритма Cassowary) делает больше работы, чем присваивание frame. Для обычных экранов он достаточно быстр; ручные frame-ы всё ещё выигрывают на очень горячих путях, например в сложных ячейках огромной ленты.
Смешивать можно: view на frame-ах может жить внутри родителя на constraint-ах. Нельзя управлять одной и той же view обоими способами сразу.
В коде constraint-ы создают через layout anchors или поручают их UIStackView; в Interface Builder их рисуют.
iOSJunior
Auto Layout и UIKit-вёрстка
Constraint-ы, intrinsic content size, UIStackView, frame-ы, safe area
Закрыто вопросов
0 / 25
- 0Повторить
- 3Часто спрашивают
Вопросы
Любой constraint представляет собой линейное уравнение item1.attribute = multiplier × item2.attribute + constant, где вместо равенства может стоять ≤ или ≥. Layout anchors (topAnchor, widthAnchor, …) дают типобезопасный способ записать это уравнение.
Атрибуты: leading/trailing, top/bottom, width/height, centerX/centerY, firstBaseline/lastBaseline.
Типобезопасность: anchor бывает NSLayoutXAxisAnchor, NSLayoutYAxisAnchor или NSLayoutDimension, поэтому связать top с leading не получится, такой код не скомпилируется. Исходный инициализатор NSLayoutConstraint(item:attribute:relatedBy:toItem:attribute:multiplier:constant:) такое примет и упадёт в рантайме.
Multiplier: у anchors он есть для размеров (widthAnchor.constraint(equalTo:multiplier:)), этого хватает для типичного «половина ширины».
**Знак constant**: он прибавляется ко второму элементу, поэтому отступы для trailing и bottom отрицательные: constant: -16.
Столько, чтобы зафиксировать четыре значения (позицию x и ширину, позицию y и высоту); обычно это четыре constraint-а, по два на ось. View с intrinsic content size, например UILabel, сама задаёт свой размер, поэтому ей хватает двух constraint-ов на позицию.
Подойдёт любая пара, которая фиксирует ось: leading + trailing, leading + width или centerX + width.
Слишком мало: layout неоднозначный (ambiguous): решатель выбирает какой-то ответ, и view может прыгать или пропасть. Interface Builder подсвечивает недостающие constraint-ы; в рантайме в лог ничего не пишется, это показывает только View Debugger.
Противоречат друг другу: layout невыполнимый (unsatisfiable): решатель ломает один constraint и печатает «Unable to simultaneously satisfy constraints».
У card по-прежнему translatesAutoresizingMaskIntoConstraints равно true, поэтому UIKit превращает её frame (.zero) и autoresizing mask в constraint-ы, которые противоречат вашим. Нужно выставить card.translatesAutoresizingMaskIntoConstraints = false до активации constraint-ов.
Пока флаг включён, UIKit добавляет объекты NSAutoresizingMaskLayoutConstraint, которые прибивают view к её текущему frame. Это имя класса в логе «Unable to simultaneously satisfy constraints» служит верным признаком.
Для view, созданных в коде, флаг по умолчанию true. Interface Builder выставляет false сам, а UIStackView делает это для своих arranged subviews.
Не трогайте его у view, которые позиционирует UIKit: корневая view контроллера, contentView ячейки и любая view, которую вы раскладываете через frame.
Сама autoresizing mask (springs and struts: .flexibleWidth, .flexibleHeight, …) является более старой системой на frame-ах. Для простой вёрстки на frame она по-прежнему работает, но не вместе с вашими constraint-ами на той же view.
Constraint-ы активируются до того, как badge попала в иерархию view. Активация устанавливает constraint на ближайшего общего предка обеих view, а у view без superview его нет, поэтому UIKit бросает исключение. Сначала card.addSubview(badge), потом активация.
Создавать объекты constraint-ов заранее можно; по иерархии ходит только активация.
View из разных иерархий связывать нельзя никогда: например, view внутри дочернего контроллера, который вы ещё не добавили.
removeFromSuperview() деактивирует все constraint-ы, где упоминается эта view. Если позже вернуть view обратно, сохранённые constraint-ы нужно активировать заново.
Похожая тихая ошибка в ячейках: constraint к самой ячейке вместо её contentView не падает, но раскладывает контент относительно не той области.
Под safe area понимают часть view, которую не перекрывают статус-бар, navigation и tab bar, home indicator или Dynamic Island. Если привязать контент к view.safeAreaLayoutGuide, он останется видимым на любом устройстве и в любой ориентации.
safeAreaLayoutGuide: layout guide, к которому ставят constraint-ы; safeAreaInsets даёт ту же информацию числами, для кода на frame-ах.
Фоны и картинки, которые должны доходить до краёв экрана, привязывают к самой view; текст и кнопки привязывают к safe area.
additionalSafeAreaInsets у контроллера сужает safe area для его контента, например над собственным плавающим мини-плеером.
UIScrollView учитывает safe area через adjustedContentInset: контент прокручивается под барами, но начинается и заканчивается за их пределами. С плавающими барами Liquid Glass в iOS 26 так и задумано.
safeAreaInsetsDidChange(): колбэк для вёрстки на frame-ах, когда insets меняются.
Leading и trailing следуют направлению чтения, поэтому в арабском и иврите layout отзеркаливается автоматически; left и right обозначают физические стороны и не переворачиваются никогда.
То же разделение есть у отступов: NSDirectionalEdgeInsets (leading/trailing) отзеркаливается, а UIEdgeInsets (left/right) не отзеркаливается.
Left и right используют только для того, что зеркалиться не должно. Для целого контрола, например кнопок управления воспроизведением, вместо этого ставят semanticContentAttribute = .playback или .forceLeftToRight.
Направленным картинкам, вроде шеврона «назад», нужен imageFlippedForRightToLeftLayoutDirection() или настройка Direction в asset catalog; SF Symbols вроде chevron.backward переворачиваются сами.
Проверять стоит запуском со схемой Right-to-Left Pseudolanguage, не дожидаясь настоящего перевода.
UIStackView выстраивает свои subview в строку или столбец и сам создаёт constraint-ы между ними: вы задаёте axis, distribution, alignment и spacing, а constraint-ы ставите только самому стеку.
Меньше кода: никаких constraint-ов между соседями, значит, меньше мест для ошибки.
Показать и скрыть бесплатно: isHidden = true у arranged subview убирает её слот и закрывает промежуток; вручную для этого пришлось бы переключать constraint-ы.
Компонуется: вложенные вертикальные и горизонтальные стеки покрывают большинство карточек и форм; setCustomSpacing(_:after:) задаёт один отличающийся отступ.
Минус: глубина: каждый стек добавляет view плюс constraint-ы, поэтому глубокая вложенность стоит времени layout в ячейках длинных списков.
Минус: ограниченные формы: перекрывающиеся view или выравнивание элементов из двух разных стеков всё равно требуют обычных constraint-ов.
Подвох: нужен addArrangedSubview(_:); view, добавленная через addSubview(_:), не участвует в раскладке. Если строка в стеке выглядит неправильно, исправление обычно сводится к приоритету hugging или compression у arranged subview, а не к новому constraint-у.
distribution задаёт размеры вдоль оси, а alignment отвечает за расположение поперёк неё: одинаковую ширину даёт distribution = .fillEqually, растянутую иконку даёт alignment = .fill по умолчанию.
.fill (distribution по умолчанию) сохраняет intrinsic-размеры и растягивает view с самым низким приоритетом content hugging; при равенстве приоритетов стек выбирает view по её позиции в arrangedSubviews, поэтому приоритеты нужно задавать явно.
.fillEqually делает все view одного размера и игнорирует intrinsic size, поэтому подходит для пары Cancel/Confirm, но не для строки «иконка, заголовок, шеврон».
.fillProportionally делит место пропорционально intrinsic-размерам; .equalSpacing и .equalCentering сохраняют естественные размеры и распределяют свободное место между view.
alignment = .fill растягивает каждую view на высоту стека; .center, .top и .bottom сохраняют собственную высоту view, а .firstBaseline выравнивает текст по базовой линии в горизонтальном стеке.
Intrinsic content size означает размер, который view хочет иметь по своему контенту: текст лейбла, заголовок кнопки с отступами. Content hugging сопротивляется росту сверх него, compression resistance сопротивляется сжатию меньше него, и первой уступает view с более низким приоритетом.
Auto Layout превращает intrinsic size в constraint-ы ширины и высоты; hugging и compression resistance задают просто приоритеты этих constraint-ов.
Значения по умолчанию для view, созданных в коде: hugging 250 (.defaultLow), compression resistance 750 (.defaultHigh). Interface Builder ставит лейблам hugging 251.
Два лейбла в одной строке с приоритетами по умолчанию равны, и не определено, какой растянется или обрежется. Ничью нужно разбить явно.
Типичная строка: заголовок должен растягиваться и обрезаться, а бейдж должен сохранять естественный размер. Значит, у заголовка ниже и hugging, и compression resistance.
Приоритеты задаются по осям, .horizontal и .vertical отдельно.
У обычной UIView нет intrinsic size, и обе настройки на неё не действуют; ей нужны явные constraint-ы размера, либо кастомная view переопределяет intrinsicContentSize.
@IBInspectable выводит свойство кастомной view в Attributes inspector в Interface Builder, а @IBDesignable заставляет Interface Builder собрать view и отрисовать её на канвасе. Сегодня UIKit-view обычно смотрят через макрос #Preview (Xcode 15+).
@IBInspectable работает для простых типов: Bool, чисел, String, CGPoint/CGSize/CGRect, UIColor, UIImage. Interface Builder хранит значение как user-defined runtime attribute и выставляет его через key-value coding при загрузке nib.
@IBDesignable компилирует ваш модуль и запускает его в отдельном процессе-агенте, чтобы нарисовать канвас. Это медленно, ломается при любой ошибке этой сборки и проявляется ошибками «Failed to render and update auto layout status»; настройку только для времени дизайна кладут в prepareForInterfaceBuilder().
#Preview рендерит UIView или UIViewController, собранные в коде (UIKit-превью требуют iOS 17+), поэтому работает и для экранов без storyboard.
Приоритет от 1 до 1000 говорит, насколько сильно решатель старается выполнить constraint: 1000 (.required) обязан выполниться, всё ниже считается необязательным: решатель выполняет его настолько близко, насколько может, и без записи в лог уступает при конфликте с более сильным. 999 означает «как можно сильнее, но может уступить».
Конфликт обязательных даёт ошибку: движок сам выбирает, какой constraint сломать, и пишет в лог «Unable to simultaneously satisfy constraints». Проигравший необязательный constraint в лог ничего не пишет.
Временные конфликты: self-sizing ячейка сначала раскладывается с временной обязательной высотой (UIView-Encapsulated-Layout-Height); нижний constraint с приоритетом 999 на этом проходе уступает, а не ломает что-то другое.
Менять потом: у активного constraint-а можно менять приоритет между необязательными значениями, но переключение на .required или с него у установленного constraint-а бросает исключение. 999 оставляет эту возможность.
Сравнение со встроенными: compression resistance равен 750, hugging равен 250, поэтому constraint ширины с 999 побеждает текст лейбла, а с 500 проигрывает compression resistance, если просит меньше ширины, чем нужно тексту.
Именованные уровни: .required 1000, .defaultHigh 750, .defaultLow 250, .fittingSizeLevel 50; арифметика работает, например .defaultHigh + 1.
В didSet нет вызова invalidateIntrinsicContentSize(). Auto Layout кэширует intrinsic size и перечитывает его только после этого вызова; setNeedsDisplay() лишь перерисовывает тот же прямоугольник.
Исправление: didSet { invalidateIntrinsicContentSize(); setNeedsDisplay() }, лучше с проверкой count != oldValue.
UILabel, UIButton и UIImageView инвалидируют себя сами при смене текста или картинки, поэтому баг появляется только в ваших собственных view.
Для оси без естественного размера возвращайте UIView.noIntrinsicMetric: у разделителя есть высота, но нет ширины.
sizeThatFits(_:) служит аналогом для вёрстки на frame-ах, его вызывает sizeToFit(). Auto Layout его не вызывает никогда, поэтому у view, реализующей только его, под constraint-ами нет размера.
Hugging и compression resistance действуют на intrinsic size, поэтому начинают работать, только когда view сообщает правильный размер.
Край совпадает с буквальными bounds view, layoutMarginsGuide даёт bounds минус margins самой view, а readableContentGuide дополнительно ограничивает ширину, чтобы строки текста на широких экранах оставались удобной длины.
Margins задают через directionalLayoutMargins, они отзеркаливаются в языках с письмом справа налево. Корневая view контроллера соблюдает системный минимум; viewRespectsSystemMinimumLayoutMargins = false позволяет сделать уже.
readableContentGuide на iPad заметно уже самой view: основной текст привязывают к нему, а картинку во всю ширину по-прежнему привязывают к краям.
safeAreaLayoutGuide отвечает на другой вопрос: что не перекрыто барами. По умолчанию margins отсчитываются от safe area (insetsLayoutMarginsFromSafeArea), поэтому привязка к margins заодно уводит контент от выреза экрана.
UIStackView игнорирует свои margins, пока не выставить isLayoutMarginsRelativeArrangement = true.
Обычный UILayoutGuide описывает невидимый прямоугольник, к которому можно ставить constraint-ы. Его используют вместо прозрачной view-распорки: ничего не рисуется, касания не обрабатываются, лишнего элемента доступности нет.
setNeedsLayout() помечает view как требующую layout в следующем цикле обновления, layoutIfNeeded() выполняет отложенный layout прямо сейчас, а layoutSubviews() является методом, который UIKit вызывает во время этого layout: первые два вызываете вы, третий вы переопределяете.
setNeedsLayout() возвращается сразу и стоит дёшево: десять вызовов за один оборот run loop всё равно дают один проход layout.
layoutIfNeeded() выполняется синхронно для view и её поддерева. Его используют, когда новые frame-ы нужны сейчас, в основном внутри блока UIView.animate после изменения constraint-ов, чтобы изменение анимировалось, а не прыгало: поменяйте heightConstraint.constant, затем вызовите layoutIfNeeded() внутри блока анимации.
layoutSubviews() напрямую не вызывают никогда. Его переопределяют для того, что не выразить constraint-ами: frame у CAGradientLayer, shadow path, радиус скругления от итоговой высоты. Сначала вызывайте super, ведь именно там Auto Layout применяет вычисленные frame-ы.
Родственная пара: setNeedsUpdateConstraints() / updateConstraints() выполняются до layout и нужны для изменений constraint-ов, зависящих сразу от нескольких свойств.
Меняйте constant, когда меняется только число (это чисто анимируется), переключайте наборы активных constraint-ов, когда меняются сами отношения, и меняйте приоритеты, когда два конкурирующих constraint-а должны побеждать по очереди.
**constant**: храните ссылку на constraint, присваивайте новое значение и, если нужна анимация, вызывайте layoutIfNeeded() внутри блока анимации.
Активация: создайте оба набора один раз, затем NSLayoutConstraint.deactivate(old) и после этого activate(new). Деактивируйте первым, иначе два набора на мгновение конфликтуют и лог заполняется ошибками.
Приоритет: два constraint-а с 750 и 250; если поменять их приоритеты местами, view сдвинется без деактивации чего-либо. Оставайтесь в пределах необязательных значений: переключение на .required или с него у установленного constraint-а бросает исключение.
**isHidden в стеке**: для простого показа и скрытия лучше всех трёх вариантов: выставьте его у arranged subview.
Типичный баг: создавать новые constraint-ы при каждом обновлении вместо повторного использования сохранённых. Старые остаются активными, layout замедляется, и в какой-то момент два из них начинают противоречить друг другу. Признаком служит вызов constraint(equalTo:), который срабатывает при каждом изменении состояния и не деактивирует предыдущий набор.
layoutIfNeeded() вызывается у panel, но изменённый constraint задаёт положение panel внутри view, поэтому блок ничего не двигает. Панель прыгает на следующем проходе layout, уже вне анимации, вместо того чтобы плавно выехать.
Почему: layoutIfNeeded() раскладывает поддерево получателя: расставляет subview у panel, а собственный frame panel выставляется, когда layout делает её superview. Constraint между panel и view принадлежит их общему предку, то есть view.
Исправление: вызвать в блоке self.view.layoutIfNeeded(): решатель применяет новый constant там, frame меняется внутри анимации, и UIKit добавляет слою анимацию position.
Правило: вызывать у ближайшего общего предка всех view, которых касаются изменённые constraint-ы; для view controller-а это обычно view.
Как поймать: с замедленными анимациями в симуляторе (Debug ▸ Slow Animations) скачок виден сразу; в тесте стоит проверить panel.layer.animationKeys() сразу после showPanel().
Прочитайте лог: в нём перечислены constraint-ы, которые не могут выполниться вместе, и назван сломанный. Задайте им идентификаторы, затем найдите виновника через breakpoint на UIViewAlertForUnsatisfiableConstraints и View Debugger.
Читайте список: в последних строках назван constraint, который движок решил сломать. NSAutoresizingMaskLayoutConstraint в списке означает забытый translatesAutoresizingMaskIntoConstraints = false; UIView-Encapsulated-Layout-Height указывает на self-sizing ячейку (см. вопрос про приоритеты).
Идентификаторы: constraint.identifier = "card.bottom" заменяет безымянные адреса в логе читаемым именем.
Symbolic breakpoint на UIViewAlertForUnsatisfiableConstraints останавливает выполнение в момент конфликта, и стек вызовов показывает, какой код активировал плохой constraint.
View Debugger (Debug View Hierarchy): выберите view, и в инспекторе будут её constraint-ы; неоднозначный layout отображается фиолетовыми runtime issues.
Неоднозначность является отдельной проблемой: она вообще не пишет в лог; проверить её можно через view.hasAmbiguousLayout в отладчике.
Исправляйте причину: обычно это дублирующийся constraint, фиксированная высота у контента, который должен задавать высоту сам, или два обязательных constraint-а, которые не согласны друг с другом. Понижать один до 999 правильно, только если конфликт временный по замыслу.
Нужна непрерывная цепочка вертикальных constraint-ов от верха до низа contentView, чтобы Auto Layout мог вычислить высоту; таблица при этом использует UITableView.automaticDimension, и это уже значение по умолчанию.
Каждая subview включена в цепочку: верх привязан, каждый промежуток задан, последняя view привязана к низу. Без нижнего constraint-а цепочка разорвана: строка получает неверную высоту (часто 44 pt по умолчанию), а layout неоднозначен.
Constraint-ы ставят к contentView, а не к самой ячейке.
Многострочному лейблу нужны numberOfLines = 0 и привязка обоих горизонтальных краёв, потому что его высота зависит от ширины.
Никаких фиксированных высот у того, чей контент должен задавать высоту.
Конфликт с UIView-Encapsulated-Layout-Height при первом layout возникает из-за временной высоты ячейки; приоритет 999 у нижнего constraint-а позволяет ему тихо уступить.
С iOS 16 selfSizingInvalidation (включён по умолчанию) позволяет ячейке поменять размер после изменения контента (вызовите invalidateIntrinsicContentSize() в ячейке) без перезагрузки строки.
Collection view работают так же, только оценка задаётся в layout: .estimated(88) в compositional layout.
Scroll view вычисляет размер контента по его constraint-ам, поэтому контент нужно привязать ко всем четырём краям contentLayoutGuide, а ширину взять от frameLayoutGuide.
contentLayoutGuide описывает прокручиваемую область; constraint-ы к нему определяют contentSize.
frameLayoutGuide описывает видимый frame самой scroll view; constraint-ы к нему определяют, что стоит на экране неподвижно.
Рецепт: привяжите один контейнер или стек ко всем четырём краям content guide, затем приравняйте его ширину к frameLayoutGuide.widthAnchor для вертикальной прокрутки (или высоту, если прокрутка горизонтальная).
Этот constraint ширины чаще всего и пропускают: без него ширина контейнера не задана, и scroll view ничего не показывает или прокручивается вбок.
Внутри контейнера по-прежнему нужна полная цепочка сверху вниз, как в self-sizing ячейке; вертикальный UIStackView даёт её бесплатно.
Привяжите низ поля к view.keyboardLayoutGuide.topAnchor. С iOS 15 этот guide отслеживает клавиатуру в координатах вашей view и анимируется вместе с ней, так что один constraint заменяет весь код на нотификациях.
Когда клавиатура скрыта, guide стоит у нижнего края safe area, поэтому один и тот же constraint работает в обоих состояниях.
followsUndockedKeyboard = true отслеживает ещё и плавающую или разделённую клавиатуру iPad; при значении по умолчанию false guide отстыкованную клавиатуру игнорирует.
usesBottomSafeArea = false (iOS 17) позволяет guide доходить до нижнего края view, а не останавливаться на safe area.
Guide двигает только то, что к нему привязано: низ scroll view тоже нужно привязать, иначе последние поля останутся скрытыми.
Старый подход подписывался на keyboardWillShow/keyboardWillHide и считал view.bounds.height - keyboardFrame.height. Так пропускаются все изменения frame между ними (предиктивная панель, emoji-клавиатура, аппаратная клавиатура), а прямоугольник из keyboardFrameEndUserInfoKey задан в координатах экрана, что неверно в Split View, Slide Over и Stage Manager, если не перевести его в координаты view. Правильной формой всегда была подписка на keyboardWillChangeFrameNotification с конвертацией прямоугольника.
Size classes дают грубые трейты, .compact или .regular, для ширины и высоты, которые реально достались вашей view. Ориентируйтесь на traitCollection.horizontalSizeClass, а не на устройство: приложение на iPad в Split View или в узком окне получает compact-ширину.
Типичные значения: iPhone в портрете имеет compact по ширине и regular по высоте; большинство iPhone в альбомной ориентации имеют compact по обеим осям (самые большие модели получают regular-ширину); iPad на весь экран имеет regular по обеим.
UIDevice.current.userInterfaceIdiom сообщает факт о железе, а не о layout, а UIScreen.main устарел с iOS 16, потому что окно не совпадает с экраном.
Реагировать нужно через registerForTraitChanges([UITraitHorizontalSizeClass.self]) (iOS 17); traitCollectionDidChange(_:) устарел.
traitOverrides.horizontalSizeClass = .compact принудительно задаёт size class дочернему контроллеру, что удобно при встраивании и для snapshot-тестов обеих раскладок.
Interface Builder тоже умеет менять constraint-ы и свойства в зависимости от size class.
Прежде чем собирать две колонки вручную, проверьте UISplitViewController: он сворачивается в одну колонку при compact-ширине и сам управляет навигацией.
В основном никак: constraint-ы относительно родителя и safe area сами пересчитываются под новый размер. Код нужен, только когда меняется структура, и размещают его в viewWillTransition(to:with:) или в обработчике смены size class, а не в нотификациях об ориентации устройства.
viewWillTransition(to:with:) даёт новый размер контейнера; изменения делайте внутри coordinator.animate(alongsideTransition:), чтобы они шли вместе с поворотом, а не прыгали в конце.
Решайте по размеру или size class, а не по ориентации: окно на iPad может быть узким и в альбомной ориентации, а окна меняют размер вообще без поворота.
UIDevice.orientationDidChangeNotification сообщает, как держат устройство, включая «экраном вверх», и срабатывает, даже когда интерфейс не поворачивается; это не сигнал для layout.
Разрешённые ориентации задаёт supportedInterfaceOrientations у корневого или полноэкранного презентованного контроллера (и Info.plist). Когда ответ меняется, вызовите setNeedsUpdateOfSupportedInterfaceOrientations() (iOS 16).
Чтобы запросить ориентацию, используйте windowScene.requestGeometryUpdate(.iOS(interfaceOrientations:)). Старый хак UIDevice.current.setValue(_:forKey: "orientation") с iOS 16 не работает.
Что-то внутри прохода layout снова инвалидирует layout: constraint-ы меняются на основе геометрии, которую только что вычислил тот же проход, и каждый проход планирует следующий.
Типичные формы: присваивать constant у constraint-а из frame, измеренного в layoutSubviews(), активировать или деактивировать там constraint-ы, безусловно вызывать invalidateIntrinsicContentSize() или вызывать layoutIfNeeded() из колбэка layout.
Осцилляция: более тонкий вариант не расходится, а скачет между двумя состояниями: высота лейбла меняет доступную ему ширину, та меняет перенос строк, а перенос снова меняет высоту.
Дешёвое исправление: проверка: присваивайте, только если значение действительно отличается, тогда второй проход ничего не меняет. Числа с плавающей точкой сравнивайте с допуском, а не через ==.
Настоящее исправление: от данных: меняйте constraint-ы на основе модели, трейтов или действий пользователя, но никогда на основе получившихся frame-ов. Работа, которая законно следует за геометрией, например frame подслоя или shadow path, безопасна в layoutSubviews(), потому что не трогает constraint-ы.
Диагностика: запустите с -UIViewLayoutFeedbackLoopDebuggingThreshold 100. UIKit бросит исключение с диагностикой, как только одна view разложится столько раз за один оборот run loop, и это быстро укажет на виновника.
Модель, которую стоит держать в голове: проход layout работает как функция из constraint-ов во frame-ы. Всё, что записывает её результат обратно во вход, делает её рекурсивной.
Выясните, сколько работы layout делает каждая ячейка на шаге прокрутки, и сократите её: меньше view и constraint-ов в ячейке, никаких изменений constraint-ов при переиспользовании, а для немногих «горячих» ячеек, которые всё ещё слишком дороги, используйте frame-ы.
Измерьте
Time Profiler: ищите layoutSubviews, -[NSISEngine ...] и systemLayoutSizeFitting под прокруткой; шаблон Animation Hitches в Instruments показывает, какие кадры пропущены.
Посчитайте view: View Debugger показывает реальную глубину; ячейка с пятью вложенными UIStackView содержит десятки view и сотни constraint-ов.
Снизьте цену
Уплощайте: один стек или несколько прямых constraint-ов вместо стеков внутри стеков; скрывайте через isHidden, а не удаляйте и заново добавляйте view.
Переиспользуйте constraint-ы: создайте их один раз в init ячейки и меняйте только constant или isActive в prepareForReuse() или в методе настройки; не создавайте constraint-ы в каждом cellForRowAt.
Не считайте размер вручную на горячем пути: вызов systemLayoutSizeFitting(_:) для каждой строки на главном потоке повторяет решение; кэшируйте высоты по id модели и задайте таблице точный estimatedRowHeight, чтобы она не измеряла все строки заранее.
Переходите на frame-ы: ячейка, которая всё ещё слишком дорога, может считать frame-ы в layoutSubviews() по закэшированному размеру; для остального экрана оставьте constraint-ы.