Горизонтальный ScrollView предлагает своему контенту ширину nil, то есть «назови свою идеальную ширину». Spacer в ответ на вопрос об идеальном размере возвращает свою минимальную длину, поэтому лишнего места, которое он мог бы занять, просто нет.
Три вида предложения: число («вот сколько я могу дать»), nil («какой у тебя идеальный размер?») и .infinity («бери сколько хочешь»). Скролл-контейнер предлагает nil вдоль оси прокрутки, потому что фиксированной длины по ней у него нет.
Вью с собственным размером и гибкие вью: у Text и у Image без resizable() есть идеальный размер, и они его сообщают. У Spacer, Color и фигур его нет, поэтому при nil они сжимаются до минимума: Spacer до minLength, Color до 10 pt.
Исправление: где-то нужно назвать конкретное число. Задайте каждой карточке .frame(width:) или считайте её размер от видимой области скролла через containerRelativeFrame(.horizontal). Отступы между карточками задаются в HStack(spacing:), а не через Spacer.
Когда layout схлопывается в ноль или растёт до бесконечности, спросите себя, что предложил ближайший родитель.
iOSSenior
SwiftUI: layout, идентичность и отрисовка
Протокол Layout, GeometryReader, PreferenceKey, идентичность view, Canvas, ленивые контейнеры
Закрыто вопросов
0 / 21
- 0Повторить
- 4Часто спрашивают
Вопросы
GeometryReader передаёт в замыкание предложенный ему размер, но забирает всё это место себе и прижимает контент к верхнему левому углу, поэтому как обычный ребёнок он расталкивает соседей. Используйте его внутри background или overlay либо возьмите более новый API.
Что он делает с layout
Жадный: идеального размера у него нет: он принимает всё предложение целиком и сообщает его как свой размер. В VStack он забирает всю оставшуюся высоту, а вдоль оси прокрутки ScrollView, где ему предлагают nil, схлопывается до стандартных 10 pt.
Верхний левый угол: контент размещается в углу topLeading, а не по центру, как в стеке.
На проход позже: если копировать proxy.size в @State, вью рисуется второй раз с новым значением: в лучшем случае получится мигание, а в худшем бесконечный цикл layout, если это значение меняет тот размер, из которого его измерили.
Где он по-прежнему уместен
Внутри .background { } или .overlay { }: там он получает ровно размер хоста и ничего не меняет.
Когда нужны proxy[anchor] или proxy.frame(in:), чтобы связать фреймы двух вью.
Более новые альтернативы
onGeometryChange(for:of:action:) (Xcode 16, работает начиная с iOS 16) читает значение, не занимая места, и срабатывает только при его изменении.
visualEffect { content, proxy in } (iOS 17): масштаб, смещение или прозрачность от геометрии вообще без состояния.
containerRelativeFrame (iOS 17) задаёт размер относительно скролла или окна без всяких измерений.
PreferenceKey представляет собой типизированный канал, по которому значения идут от потомков вверх по дереву вью: дети задают его через .preference(key:value:), SwiftUI сливает значения всех детей функцией reduce ключа, а предок читает результат в onPreferenceChange.
**defaultValue: используется, если ни один ребёнок ключ не задал.
reduce(value:nextValue:)**: вызывается по разу для каждой вью, вносящей значение, в порядке дерева; типичные слияния включают max, сумму или добавление в список.
Типичная связка: GeometryReader внутри background пишет размер; это чистый способ измерить вью, не нарушая layout.
**onPreferenceChange**: требует Equatable-значение, поэтому срабатывает только когда слитое значение действительно изменилось.
Без круга через состояние: backgroundPreferenceValue, overlayPreferenceValue и anchorPreference читают значение прямо в той вью, которой оно нужно.
ViewThatFits по очереди спрашивает у каждого ребёнка его идеальный размер по указанным осям и показывает первого, кто помещается в предложенное ему место. Если не помещается никто, показывается последний ребёнок.
Порядок важен: перечисляйте варианты от самого подробного к самому компактному. Последний служит запасным: он используется, даже если не помещается.
**in: выбирает оси**: ViewThatFits(in: .horizontal) не смотрит на высоту, поэтому вариант может стать выше и не будет отброшен. По умолчанию проверяются обе оси.
Идеальный размер: при предложении nil Text сообщает полную ширину в одну строку, поэтому вариант отбрасывается, как только его тексту понадобился бы перенос или обрезка.
Состояние сбрасывается при переключении: когда выигрывает другой вариант, старое поддерево удаляется вместе со своим @State. Держите состояние во вью, которая владеет ViewThatFits, и передавайте вниз биндинги.
Цена: варианты измеряются на каждом проходе layout, поэтому делайте их дешёвыми.
Size class описывает окно, а не место, которое получила конкретная вью. Тот же тулбар в сайдбаре, в панели split view или при крупном Dynamic Type получит от ViewThatFits правильный ответ, а от horizontalSizeClass неправильный.
UIViewRepresentable оборачивает UIKit-вью для SwiftUI, а UIHostingController оборачивает SwiftUI-вью в вью-контроллер. Размер по умолчанию переходит границу через intrinsicContentSize, в SwiftUI через sizeThatFits(_:uiView:context:), а в Auto Layout через sizingOptions.
UIKit внутри SwiftUI
Контракт: makeUIView вызывается один раз на identity, updateUIView после каждого изменения входных данных, а Coordinator держит делегаты; frame, bounds и center принадлежат SwiftUI, поэтому не задавайте их сами.
Размер по умолчанию: SwiftUI читает intrinsicContentSize и приоритеты hugging и compression resistance. Вью без собственного размера занимает всё предложенное место; многострочный UILabel просит ширину своей одной строки, поэтому может получиться шире своей колонки вместо переноса строк.
**sizeThatFits(_:uiView:context:)** (iOS 16): получает ProposedViewSize; измерьте вью по предложенной ширине и верните высоту, либо nil для поведения по умолчанию.
SwiftUI внутри UIKit
Встраивайте как дочерний контроллер: addChild, добавить вью и констрейнты, didMove(toParent:), чтобы до контента доходили колбэки появления, трейты и safe area.
**sizingOptions = .intrinsicContentSize** (iOS 16): hosting-вью инвалидирует свой intrinsic size, как только меняется идеальный размер контента. Без этого баннер с более длинным текстом сохранил прежнюю высоту 20 pt.
Стек спрашивает у каждого ребёнка одно значение, направляющую выравнивания (alignment guide), и сдвигает детей так, чтобы эти значения совпали. alignmentGuide(_:computeValue:) переопределяет его для одного ребёнка, а собственный AlignmentID ставит на одну линию вью из разных строк.
Встроенные направляющие: .leading, .center, .firstTextBaseline и другие. HStack(alignment: .firstTextBaseline) ставит бейдж вровень с первой строкой многострочного заголовка, а не с серединой блока.
Переопределение: замыкание получает ViewDimensions, поэтому значение вычисляется из размера самого ребёнка или других его направляющих, например d[.firstTextBaseline] - 4, а не из магического числа. Выравнивание только двигает вью и никогда не меняет их размер.
Собственное выравнивание: подпишите тип на AlignmentID, оберните его в HorizontalAlignment, передайте внешнему VStack и задайте направляющую одной вью в каждой строке. Тогда значения во всех строках начинаются с одного и того же x.
Ограничение: выравниваются между собой только дети одного стека. Вью в двух несвязанных HStack выровнять нельзя, пока выравнивание не задано на их общем предке; для настоящей таблицы проще Grid.
По identity SwiftUI отличает «ту же вью, но обновлённую» от «другой вью»: структурная identity берётся из типа вью и её позиции в body, а явная из .id(_:) и id в ForEach. Когда она меняется, старая вью уничтожается, а новая начинает с чистого состояния.
Два вида
Структурная: @ViewBuilder превращает body во вложенный generic-тип, поэтому у каждой вью есть фиксированное место. Две ветки if/else дают два разных места; AnyView скрывает место от SwiftUI.
Явная: .id(value) и id каждого элемента ForEach перекрывают место. Новое значение означает новую вью, а не изменённую.
Identity, время жизни, зависимости
Время жизни: промежуток, пока identity не меняется. Ровно столько живут @State, @StateObject, @FocusState и запущенный .task.
Зависимости определяют, когда body выполняется заново, но identity не меняют никогда. Новый item от родителя обновляет вью на месте.
Когда identity меняется
@State начинает заново с начального значения, @StateObject пересоздаётся, .task отменяется и запускается снова, onAppear срабатывает повторно, фокус и клавиатура пропадают.
Старая вью уходит со своим transition удаления, пока появляется новая: то самое мигание, которое видят после добавления .id(item.updatedAt) «чтобы обновить строку».
Используйте .id осознанно, для сброса (например, чистая форма для каждой записи через .id(record.id)), и никогда со значением, вычисленным из меняющихся данных.
SwiftUI хранит @State по identity вью и использует начальное значение только один раз. При выборе другого элемента структура DetailEditor пересоздаётся на том же месте, identity не меняется, State(initialValue:) игнорируется, и в draft остаётся старый заголовок.
Почему: структура служит одноразовым описанием, которое пересоздаётся при каждом обновлении родителя, а хранилище за @State не пересоздаётся. init выполняется снова, но SwiftUI оставляет уже существующее значение.
Исправление 1, предпочтительное: не копируйте модель в локальное состояние, а привяжитесь к ней: @Bindable var item: Item для @Observable-класса или Binding от владельца. Один источник истины.
Исправление 2: если черновая копия действительно нужна (отредактировать, потом «Сохранить» или «Отмена»), привяжите identity к данным: DetailEditor(item: selected).id(selected.id) с исходной структурой из вопроса. Каждая запись получает новый редактор.
Исправление 3: .onChange(of: item.id) { draft = item.title }. Работает, но два источника истины приходится синхронизировать вручную, и каждое новое поле даёт шанс про одно из них забыть.
Два элемента отдают ForEach один и тот же id, и SwiftUI не может сопоставить старые строки с новыми: состояние, фокус и анимации привязываются не к той строке, а один из дубликатов может вообще не отрисоваться. Чинить нужно в данных: id должны быть уникальными и стабильными.
Откуда берётся id строки
ForEach(items) использует Identifiable.id; ForEach(items, id: \.key) использует указанный key path; ForEach(0..<n) использует само число и ожидает константный диапазон.
id должен быть уникальным внутри ForEach и стабильным между обновлениями.
Типичные источники дубликатов
id: \.self по значениям, которые могут повторяться, например два тега "swift" в [String].
Повторный запрос страницы, который добавляет ту же страницу дважды, или две ленты, объединённые без префиксов у id.
Бэкенд, у которого id уникальны только в пределах одной страницы или одного типа объектов.
Почему индексы не решение
id: \.offset из enumerated() уникален, но не стабилен: удаление строки 0 перенумеровывает все строки ниже, и их состояние вместе с анимацией удаления переезжает на чужие строки.
Назначайте стабильный id там, где данные входят в приложение (серверный id, "\(source)-\(remoteID)" или UUID, созданный один раз), и убирайте дубликаты до того, как массив попадёт во вью.
if/else внутри хелпера даёт _ConditionalContent<T, Self>, поэтому подсвеченный и обычный Counter живут в двух разных ветках: это две разные структурные identity. Смена флага уничтожает один Counter вместе с его @State и создаёт другой с нуля.
Что ещё ломается: переключение анимируется как удаление плюс вставка (кроссфейд вместо смены цвета), .task внутри перезапускается, фокус теряется.
Исправление: оставьте одну ветку, а условным сделайте значение модификатора: Counter().foregroundStyle(isHighlighted ? Color.red : Color.primary). Тот же приём работает для .opacity(isHidden ? 0 : 1), .disabled(flag) или .padding(compact ? 4 : 12).
**Когда if уместен**: когда две ветки действительно разные вью или когда условие не меняется за время жизни вью, например проверка if #available.
Хелпер выглядит как модификатор, а ведёт себя как переключатель между двумя вью.
reduce вызывается по разу для каждого ребёнка, который задаёт preference, а value = nextValue() перезаписывает значение: выживает только ширина последней строки, поэтому labelWidth становится шириной «Phone», и все подписи сжимаются до неё.
Почему так происходит: PreferenceKey работает как свёртка: SwiftUI обходит поддерево и вызывает reduce, чтобы объединить значение каждого ребёнка с накопленным. Вариант value = nextValue() из многих туториалов означает «побеждает последний» и верен, только если ключ задаёт одна вью.
Исправление: объединять, а не заменять. Для общей ширины это value = max(value, nextValue()); для суммы это value += nextValue(); для набора элементов нужно добавлять в массив или сливать словарь по id.
Почему баг не сразу виден: с одной строкой или когда последняя подпись случайно самая длинная, всё выглядит верно. Ломается при смене порядка данных.
Как поймать: выведите значение в onPreferenceChange при разном порядке строк; если результат следует за порядком, а не за содержимым, reduce перезаписывает.
Ленивый стек строит строки чуть раньше, чем они видны (точная дистанция не документирована), а onAppear срабатывает при создании, то есть означает «построена», а не «видна». Сделайте триггер загрузки идемпотентным или используйте настоящий API видимости.
Пересборка: onAppear может сработать снова, когда строка пересобирается после прокрутки туда и обратно.
Баг пагинации: «грузить следующую страницу в onAppear последней строки» срабатывает раньше времени и может сработать дважды: одна и та же страница запрашивается и добавляется два раза, а потом ForEach ломается на дублирующихся id.
Сделайте триггер идемпотентным: защитите его флагом isLoading и номером следующей страницы в модели, чтобы ранний или повторный вызов ничего не делал.
Настоящая видимость: на iOS 18 используйте onScrollVisibilityChange(threshold:) на вью или onScrollTargetVisibilityChange(idType:threshold:) на скролле: оба сообщают, что действительно видно на экране.
Для пары десятков дешёвых строк проще обычный VStack: всё строится и измеряется сразу.
scrollTo ищет id в текущем дереве вью, а только что добавленная строка появится лишь после того, как SwiftUI применит изменение состояния, поэтому вызов разрешается по старому контенту. Альтернатива на состоянии: сделать позицию скролла состоянием через .scrollPosition и scrollTargetLayout().
**.scrollPosition(id:) (iOS 17)**: с .scrollTargetLayout() на ленивом стеке SwiftUI записывает в биндинг id ребёнка у якоря и прокручивает, когда вы пишете в биндинг сами.
**ScrollPosition (iOS 18)**: @State var position = ScrollPosition(idType: Message.ID.self) вместе с .scrollPosition($position) прокручивает к id, к точке или к краю: position.scrollTo(edge: .bottom).
**.defaultScrollAnchor(.bottom)**: открывает чат на самом новом сообщении и держит его внизу по мере роста контента, что убирает большую часть ручной прокрутки.
Если scrollTo работает только внутри DispatchQueue.main.asyncAfter, это сигнал перенести позицию в состояние.
ignoresSafeArea предлагает поддереву, к которому он прикреплён, большую область, поэтому на корне он уводит весь контент под статус-бар и индикатор Home. Без аргументов он ещё и игнорирует область клавиатуры, а это отключает подъём контента над клавиатурой.
Ограничьте его слоем, который должен выходить за края: .background { gradient.ignoresSafeArea() } растягивает градиент на весь экран, а контент сохраняет свои отступы.
Выбирайте области и края: .container охватывает бары, вырез и индикатор Home, .keyboard означает клавиатуру. .ignoresSafeArea(.container, edges: .top) как раз нужно hero-картинке; .ignoresSafeArea(.keyboard) оставляет фон на месте, пока поле в фокусе поднимается.
**safeAreaInset(edge:)**: добавляет панель, например кнопку «Сохранить» или поле ввода сообщения, в safe area, поэтому она поднимается вместе с клавиатурой, а контент скролла за ней получает такой же отступ; overlay оставляет последние строки спрятанными под панелью.
**safeAreaPadding(_:) (iOS 17)**: добавляет ваш собственный отступ в саму safe area, поэтому ScrollView по-прежнему прокручивается от края до края, а его контент лежит внутри этого отступа.
Никогда не хардкодьте 34 или 47 точек; если число действительно нужно, читайте GeometryProxy.safeAreaInsets.
sizeThatFits отвечает на предложение размером, который нужен контейнеру, измеряя детей через LayoutSubview; placeSubviews получает итоговые границы и должен разместить каждого ребёнка ровно один раз. Оба метода обязаны считать одинаково, иначе дети нарисуются за пределами заявленного размера.
sizeThatFits(proposal:subviews:cache:)
Теперь родителем становитесь вы: спрашивайте у каждого ребёнка идеальный размер через subview.sizeThatFits(.unspecified) или передавайте конкретное предложение, чтобы его ограничить.
Метод вызывается несколько раз за проход с разными предложениями, включая ширину nil и .infinity. Никогда не делайте force unwrap proposal.width; трактуйте nil как «разложить всё в одну строку».
placeSubviews(in:proposal:subviews:cache:)
bounds не начинается с нуля: сдвигайте каждую позицию на bounds.minX и bounds.minY.
Передавайте в place(at:anchor:proposal:) измеренный размер, чтобы каждый ребёнок отрисовался ровно так, как вы рассчитали.
Кэш и дополнительные возможности
makeCache(subviews:) хранит работу, общую для обоих методов, например разбиение на строки. Стандартный updateCache пересоздаёт кэш при изменении детей; если ещё и привязать его к ширине, для которой он посчитан, повторные предложения ничего не стоят.
subview.priority, subview.spacing и собственный LayoutValueKey позволяют детям передавать подсказки раскладке.
AnyLayout переключает, например, FlowLayout() и VStackLayout() внутри withAnimation, сохраняя identity и состояние каждого ребёнка.
placeSubviews расставляет теги начиная с (0, 0), но bounds описывает область, которую родитель выделил layout, в координатах родителя, и её начало почти никогда не равно нулю. place(at:) работает в тех же координатах, поэтому теги игнорируют заголовок и отступы над ними.
Почему так происходит: sizeThatFits только сообщает размер; где окажется layout, решает родитель, и это приходит в bounds.origin. place(at:) не считает от собственного угла layout, поэтому смещение, которое не прибавили, просто теряется.
Почему баг не сразу виден: расчёт переноса верный, потому что сравнение x + width с bounds.width работает от нуля. Layout выглядит правильно, пока его начало случайно близко к нулю, и ломается, как только над ним или рядом с ним появляется что-то ещё.
Исправление: начинать с bounds.minX и bounds.minY, переносить строку, когда x + width выходит за bounds.maxX, и начинать каждую строку с bounds.minX, а не с 0. sizeThatFits остаётся как есть: он считает размер, начало координат там не участвует.
Как поймать: показывайте кастомный layout в превью или snapshot-тесте внутри .padding(40) и с вью над ним; layout, который игнорирует своё начало координат, сразу себя выдаст.
Тень, прозрачность и режим смешивания, применённые к контейнеру, ложатся на каждого ребёнка отдельно; compositingGroup() собирает поддерево в один слой до этих эффектов. drawingGroup() вдобавок растеризует его вне экрана через Metal в одну картинку ради скорости.
Зачем нужна группировка
Эффекты вроде .shadow, .opacity и .blendMode, применённые к контейнеру, доходят до каждого отрисованного ребёнка по отдельности. Один .shadow на VStack рисует тень у каждого текста и картинки внутри; .opacity(0.5) на двух перекрывающихся кругах даёт просвечивающее пересечение.
.compositingGroup() между детьми и эффектом сначала собирает детей вместе, и тень или прозрачность применяются к результату один раз.
Когда drawingGroup() помогает
Много перекрывающихся фигур, градиентов и путей (графики, эффекты частиц, сложная анимированная векторная графика), которые медленно композитятся отдельными слоями.
Сначала подтвердите это в Instruments: он переносит стоимость, а не убирает её.
Когда вредит
Добавляет offscreen-проход рендера и память под текстуру на каждом кадре, где меняется контент.
Вью на основе UIKit, такие как TextField, Map или UIViewRepresentable, в него не рисуются и показывают заглушку.
Для нескольких простых вью он обычно медленнее обычной отрисовки.
Для тени карточки дешевле всего отбрасывать тень только от фоновой фигуры: .background { RoundedRectangle(cornerRadius: 12).fill(.background).shadow(radius: 8) }.
Переходите, когда элементы являются просто пикселями: пользователь не нажимает на них, не фокусируется и не анимирует их по одному. Каждый Rectangle представляет собой вью с identity, layout и hit testing, а Canvas остаётся одной вью, которая рисует всё в GraphicsContext за один проход.
Что даёт Canvas
Один узел в дереве вью: вы получаете context и size, заливаете Path, рисуете картинки и текст. Внутри ни у чего нет identity, поэтому нечего сравнивать и нечего раскладывать.
context.resolve(Text(...)) один раз готовит текст для многократной отрисовки; замыкание symbols: вместе с context.resolveSymbol(id:) позволяет рисовать внутри настоящие SwiftUI-вью.
Чем вы платите
Hit testing по элементам: считайте его сами по координате нажатия.
Transitions и анимации отдельных элементов: анимируйте данные, а не вью.
Доступность: canvas считается одним элементом, поэтому добавьте метку или accessibilityChildren для значимых частей.
Анимация во времени
TimelineView(.animation) перезапускает свой контент на каждом обновлении экрана и передаёт context.date. Это гораздо дешевле, чем Timer, который 60 раз в секунду пишет в @State и инвалидирует ветку дерева.
Вью нужны для того, что раскладывается, нажимается или читается VoiceOver; Canvas нужен для плотной отрисовки.
Разверните контент на отдельные subview: Group(subviews:) и ForEach(subviewOf:) (iOS 18) превращают значение @ViewBuilder в коллекцию отдельных детей, причём любой ForEach внутри уже развёрнут.
Проблема: generic-параметр Content: View представляет собой одно непрозрачное значение. До iOS 18 приходилось принимать [Data] плюс билдер строки или пользоваться приватным API _VariadicView.
**Group(subviews: content) { subviews in }**: даёт всю SubviewsCollection: count, isEmpty, first, last, срезы. Нужен, когда важна позиция: нет разделителя после последнего ребёнка или пустой контейнер вообще ничего не рисует.
**ForEach(subviewOf: content) { subview in }: перебирает детей, когда каждого оформляют одинаково.
Identity сохраняется**: каждый Subview соответствует Identifiable и сохраняет identity от вызывающего кода, поэтому обёртка не сбрасывает его @State.
Container values: объявите @Entry var isPinned: Bool = false в расширении ContainerValues, задайте значение у ребёнка через .containerValue(\.isPinned, true) и читайте subview.containerValues.isPinned в контейнере.
Как только контейнеру нужно знать, сколько у него детей, разворачивайте subview, а не передавайте content дальше как есть.
Дорогой layout означает, что одно и то же поддерево измеряется много раз за кадр: вложенные гибкие стеки, запросы идеального размера у fixedSize и ViewThatFits, текст, заново разбиваемый на строки для каждой предложенной ширины, и измерения, возвращаемые обратно в состояние.
Убедитесь, что дело в layout
Self._printChanges() молчит, а кадры всё равно пропускаются: значит, время уходит не в body.
Инструмент SwiftUI показывает долгие обновления body; если они короткие, возьмите Time Profiler на главном потоке, чтобы увидеть время внутри layout SwiftUI. Инструмент Hitches называет кадр, не уложившийся в срок. Профилируйте release-сборку на устройстве.
Быстрая проверка: замените подозрительное поддерево на Color.clear.frame(width:height:) того же размера. Если подтормаживание пропало, цена внутри него.
Типичные причины
Циклы «измерил и сохранил»: GeometryReader пишет в PreferenceKey, родитель сохраняет значение в @State и меняет размер ребёнка, что запускает ещё один проход. Если размеры не сходятся, цикл не останавливается.
Повторные запросы идеального размера: fixedSize, ViewThatFits, измеряющий каждый вариант, стеки, которые измеряют гибких детей больше одного раза; всё это умножается на глубину вложенности.
Длинный текст без ограничений: многострочный Text заново разбивается на строки для каждой предложенной ширины.
**Обычный VStack в ScrollView: измеряет все строки до первого кадра.
Собственный Layout без кэша**: раскладка пересчитывается для каждого предложения.
Исправления
Фиксированные или ограниченные размеры строк, lineLimit у длинного текста, GeometryReader только внутри background, alignment guides или собственный Layout вместо «измерил и сохранил», ленивые контейнеры для длинных списков и кэш в собственных раскладках.
AttributeGraph представляет собой приватный граф зависимостей SwiftUI: состояние, значения environment, входные данные вью, результаты body и layout служат узлами, а рёбра фиксируют, кто что читал. Изменение помечает зависимые узлы как «грязные», и следующее обновление пересчитывает только их.
Как распространяется изменение
Запись в @State или в свойство @Observable инвалидирует только те вью, чей body читал это значение при прошлом вычислении. @Observable отслеживает чтение по отдельным свойствам; ObservableObject инвалидирует всех наблюдателей при любом изменении @Published.
Пересчёт ленивый: «грязные» узлы пересчитываются, когда они нужны следующему кадру, поэтому много изменений в одной транзакции дают одно обновление.
Когда выполняется body ребёнка
body родителя выполняется заново и создаёт новую структуру ребёнка. SwiftUI сравнивает её с предыдущей поле за полем; если ничего не изменилось, body ребёнка пропускается.
Свойства-замыкания обычно считаются изменившимися, потому что SwiftUI не умеет их сравнивать, поэтому новое замыкание, как правило, заново запускает ребёнка.
Подпишите вью на Equatable и оберните её в .equatable(), чтобы SwiftUI использовал ваш ==, например чтобы не учитывать колбэк.
Что это значит на практике
Держите вью маленькими и передавайте им только те значения, которые они читают, и тогда изменение инвалидирует небольшую часть графа.
Стабильная identity сохраняет узлы живыми; потеря identity выбрасывает подграф и строит новый.
В Xcode 26 граф Cause & Effect в инструменте SwiftUI показывает, какое изменение инвалидировало какую вью.