SwiftUI представляет собой декларативный UI-фреймворк Apple: вы описываете, как выглядит экран при текущем состоянии, а фреймворк сам решает, какие изменения применить. В императивном UIKit вы создаёте view и затем сами их изменяете.
Плюсы
Меньше кода и рассинхронизации: UI является функцией от состояния, поэтому нет reloadData() или ручного обновления лейбла, о котором можно забыть.
Живые превью: #Preview отрисовывает view прямо в Xcode без запуска приложения.
Один фреймворк для всех платформ Apple: iOS, iPadOS, macOS, watchOS, tvOS и visionOS; виджеты и Live Activities пишутся только на SwiftUI.
Композиция: маленькие view и модификаторы комбинируются вместо наследования.
Минусы
Меньше контроля: точная работа со скроллом, сложный ввод текста и некоторые раскладки коллекций по-прежнему проще в UIKit, который встраивается через UIViewRepresentable.
Возможности зависят от версии ОС: многие API требуют iOS 17 или 18, поэтому низкий deployment target означает ветки if #available.
Сложнее отладка: когда view перерисовывается слишком часто или теряет состояние, причина спрятана в механизмах идентичности и отслеживания зависимостей фреймворка.
В 2026 году большинство новых экранов пишут на SwiftUI, а UIKit остаётся там, где нужен тонкий контроль, и в старом коде; оба свободно сочетаются в одном приложении.
iOSJunior
Основы SwiftUI
Протокол View, модификаторы, контейнеры, навигация, списки
Закрыто вопросов
0 / 25
- 0Повторить
- 6Часто спрашивают
Вопросы
Декларативный означает, что вы описываете результат (как выглядит экран при данном состоянии), а обновления выполняет фреймворк. View в SwiftUI устроена как дешёвая структура, которая описывает кусок интерфейса; это не тот объект, что рисуется на экране.
View служит описанием: SwiftUI постоянно создаёт и выбрасывает структуры view; настоящие объекты отрисовки и хранилище @State живут внутри фреймворка и переживают эти копии.
Структуры дешёвые: структура из простых значений хранится по месту, без аллокации в куче и без подсчёта ссылок для себя самой, а сравнение старого и нового значения показывает SwiftUI, что изменилось.
Нет общего изменяемого состояния: значение не может изменить кто-то другой у вас за спиной, поэтому единственный способ изменить UI состоит в том, чтобы изменить состояние.
Нет груза наследования: UIView несёт множество хранимых свойств от суперклассов; view в SwiftUI хранит только то, что вы объявили, а переиспользуется через композицию.
Практическое следствие: init у view не является событием жизненного цикла. Данные, которые должны пережить пересоздание, хранят в @State или в модели, а не в обычном хранимом свойстве.
Протоколу View соответствует каждая view в SwiftUI, и его единственным требованием служит вычисляемое свойство body, возвращающее другие view. some View обозначает непрозрачный (opaque) тип возврата: body возвращает один конкретный тип, который знает компилятор, но писать его вам не нужно.
body неявно помечен @ViewBuilder, поэтому в нём можно перечислить несколько view и использовать if или switch; билдер сворачивает их в один тип.
Непрозрачный тип оставляет этот конкретный тип видимым для SwiftUI, и фреймворк по нему понимает, какие части дерева не изменились между обновлениями.
any View не может быть body: экзистенциальный тип сам не соответствует View и скрыл бы структуру, которую сравнивает SwiftUI.
body должен быть быстрым и без побочных эффектов: SwiftUI вызывает его при каждом изменении зависимости, иногда много раз в секунду.
У листовых view вроде Text и Image тип Body равен Never; их фреймворк рисует сам.
@main отмечает точку входа в приложение, протокол App описывает приложение целиком, а его body возвращает одну или несколько сцен, то есть контейнеров вроде WindowGroup, которые система превращает в окна.
**App**: структура, похожая на view, с body: some Scene. Состояние уровня приложения хранится здесь в @State и передаётся вниз через .environment(...).
**WindowGroup**: стандартная сцена; на iPad и Mac пользователь может открыть из неё несколько окон, у каждого своё состояние view. Есть и другие: DocumentGroup, Settings и MenuBarExtra на macOS и ImmersiveSpace на visionOS.
**@Environment(\.scenePhase)**: сообщает .active, .inactive или .background и заменяет большинство колбэков жизненного цикла из app delegate.
**@UIApplicationDelegateAdaptor**: по-прежнему подключает AppDelegate для регистрации пушей или SDK, которым он нужен.
VStack ставит дочерние view сверху вниз, HStack ставит их от leading к trailing, а ZStack накладывает их слоями от заднего к переднему. LazyVStack и LazyHStack раскладывают так же, но создают дочерние view только при приближении к экрану, что важно для длинного контента в ScrollView.
**alignment и spacing**: VStack выравнивает детей по горизонтали (.leading, .center, .trailing), HStack выравнивает по вертикали (.top, .firstTextBaseline); ZStack принимает двухосевой Alignment, например .topTrailing.
**Spacer(): гибкая view, которая забирает свободное место вдоль оси стека и расталкивает соседей.
Обычный или ленивый**: обычный стек сразу создаёт и измеряет всех детей, поэтому VStack на 5 000 строк создаст 5 000 view ещё до первого кадра. Ленивый стек строит только то, что рядом с видимой областью.
Ленивые стеки сами не скроллятся: их кладут внутрь ScrollView.
Один слой поверх другого: для бейджа или подложки .overlay и .background часто лучше ZStack, потому что берут размер основной view.
Бейдж A получается красным прямоугольником с текстом внутри стандартного отступа, B выглядит как узкая красная полоса за текстом с невидимым отступом вокруг, а C красный только за текстом внутри более широкой пустой рамки. Каждый модификатор оборачивает view, на которой вызван, поэтому влияет только на то, что было до него в цепочке.
A: .padding() даёт view побольше (текст плюс отступ), и .background заливает эту увеличенную view.
B: .background заливает голый текст; отступ добавляется снаружи, где ничего не рисуется.
C: .frame(width: 200) создаёт контейнер шириной 200 точек вокруг view, которая уже красная, поэтому цвет остаётся по размеру текста. Чтобы залить всю ширину, ставьте .background после .frame.
Читайте цепочку сверху вниз как «возьми эту view и оберни её в…». То же правило объясняет, почему .clipShape перед .shadow работает, а .clipShape после .shadow обрезает тень.
ViewModifier нужен, чтобы собрать набор модификаторов для любой view, например вид карточки, а ButtonStyle нужен, чтобы стилизовать кнопки, потому что он дополнительно получает состояние нажатия и роль кнопки.
**ViewModifier**: реализуйте body(content:) и оберните его в расширение View, чтобы в месте вызова было .cardStyle(). Модификатор может хранить собственный @State и читать @Environment.
Обычное расширение: если вы только объединяете готовые модификаторы и состояние не нужно, достаточно extension View { func cardStyle() -> some View }.
**ButtonStyle**: реализуйте makeBody(configuration:); configuration.label представляет содержимое кнопки, а configuration.isPressed даёт состояние нажатия без кода жестов. Обработка тапа, accessibility-трейты и hit testing остаются за системой.
Применяется через environment: .buttonStyle(.primary) на контейнере стилизует все кнопки внутри. Тот же паттерн есть у ToggleStyle, LabelStyle, TextFieldStyle и ProgressViewStyle.
.frame по умолчанию центрирует дочернюю view, поэтому нужен .frame(maxWidth: .infinity, alignment: .leading). alignment у стека выравнивает детей относительно друг друга, alignment у frame размещает дочернюю view внутри рамки, а multilineTextAlignment выравнивает строки внутри одного Text.
Выравнивание стека: VStack(alignment: .leading) влияет только на детей, которые по ширине меньше стека; сам стек шириной с самого широкого ребёнка, поэтому до края экрана он не дотягивается.
Выравнивание frame: .frame(maxWidth: .infinity, alignment: .leading) забирает всю предложенную ширину и прижимает содержимое к leading-краю. Значение по умолчанию .center и есть причина того, что растянутые view остаются посередине.
Выравнивание текста: .multilineTextAlignment(.center) ничего не меняет, пока текст не перенесётся на несколько строк.
Две оси сразу: ZStack, .overlay и .background принимают полный Alignment, например .bottomTrailing.
Leading, а не left: .leading и .trailing автоматически отражаются в языках с письмом справа налево.
Если что-то смещено ровно на половину свободного места, значит, сработал .center по умолчанию.
Image сохраняет свой натуральный размер, пока вы не вызовете .resizable(), а .scaledToFill() рисует за пределами рамки, если её не обрезать. У AsyncImage нет кэша декодированных картинок в памяти, поэтому строка, вернувшаяся в список при скролле, снова запрашивает картинку и снова её декодирует.
Порядок модификаторов: сначала .resizable(), затем .scaledToFit() или .scaledToFill(), потом .frame, потом .clipped() или .clipShape(...).
**.scaledToFill() вылезает: layout по-прежнему сообщает размер рамки, но пиксели рисуются поверх соседей, пока вы не обрежете.
SF Symbols ведут себя как текст**: размер Image(systemName:) задают через .font или .imageScale, а цвет задают через .foregroundStyle; .resizable() ломает их выравнивание по тексту.
**AsyncImage**: даёт плейсхолдер и phase с веткой ошибки, но опирается только на URLCache и не умеет повторять запрос. Для скроллящейся ленты нужен загрузчик с кэшем в памяти (свой или библиотека вроде Nuke или Kingfisher); большие фото сначала уменьшайте до размера отображения через UIImage.preparingThumbnail(of:).
List служит прокручиваемым контейнером в стиле платформы, а ForEach создаёт по строке на каждый элемент коллекции. SwiftUI сопоставляет строки между обновлениями по id, поэтому id должен браться из данных и никогда не меняться, иначе строки теряют состояние и анимируются неправильно.
**Identifiable**: дайте модели id (идентификатор из базы или UUID, хранящийся в модели) и пишите ForEach(items) { ... }; иначе передайте key path: ForEach(items, id: \.code).
Плохие id: индексы массива сдвигаются при вставке и удалении, поэтому состояние переезжает на чужую строку, а индекс может выйти за границы; id: \.self на значениях с дубликатами даёт двум строкам одну идентичность; UUID(), созданный внутри body, делает каждую строку новой при каждом обновлении.
Редактируемые строки: ForEach($items) { $item in ... } передаёт каждой строке Binding на её элемент.
Возможности List: Section для заголовков, .onDelete и .swipeActions для действий над строкой, .listStyle(.insetGrouped), а также .searchable и .refreshable на самом списке.
Оберните контролы в Form, который даёт им сгруппированный вид как в настройках, и привяжите каждый контрол к состоянию через $, например TextField("Email", text: $email). Контрол сам читает и пишет это состояние, поэтому не нужно подключать делегат или target-action.
**TextField и SecureField**: ввод настраивается через .keyboardType(.emailAddress), .textContentType(.emailAddress) (включает автозаполнение), .textInputAutocapitalization(.never) и .autocorrectionDisabled(). TextField(_:text:axis: .vertical) растягивается на несколько строк.
**Toggle, Picker, Stepper, DatePicker**: каждый принимает binding; Picker сопоставляет строки со значениями через .tag или через id внутреннего ForEach.
**Section: группирует поля под заголовком и футером, подходящее место для подсказки о валидации.
Валидация**: вычисляйте валидность из состояния и ставьте .disabled(!isValid) на кнопку отправки, вместо проверки по тапу.
Работа с клавиатурой: .submitLabel(.next) и .onSubmit обрабатывают клавишу Return; перевод фокуса между полями делается через @FocusState.
Показ экрана задаётся модификатором, привязанным к состоянию: установите Bool или опциональное значение, и SwiftUI покажет sheet или alert; сбросьте, и экран закроется. Используйте .sheet(item:) всякий раз, когда sheet показывает конкретное значение, например нажатую строку.
.sheet(isPresented:) подходит для экрана без входных данных, вроде настроек.
.sheet(item:) принимает опциональное Identifiable-значение и передаёт его в замыкание с содержимым, поэтому sheet никогда не покажет старый или nil-выбор (классический баг связки Bool-sheet и отдельной переменной selected).
Та же пара есть у .fullScreenCover и .popover; .alert и .confirmationDialog принимают значение presenting:, которое попадает в их кнопки. Для alert добавляйте явную кнопку .cancel; .confirmationDialog добавляет её сам.
Показанная view закрывает себя сама через @Environment(\.dismiss), не зная, какой binding её показал.
.presentationDetents([.medium, .large]) даёт sheet в половину высоты, а .interactiveDismissDisabled() блокирует закрытие свайпом, пока в форме есть несохранённые правки.
Вешайте модификаторы показа на view, которая остаётся на экране, а не на строку внутри ForEach.
LazyVGrid раскладывает коллекцию по колонкам и строит ячейки лениво, что подходит для галереи; Grid, неленивая таблица, строки которой делят ширину колонок, подходит для формы или таблицы сравнения. .adaptive(minimum:) вмещает столько колонок, сколько позволяет ширина.
**Размеры GridItem**: .fixed(100), .flexible(minimum:maximum:) или .adaptive(minimum:). Массив из трёх элементов .flexible() даёт ровно три колонки.
Точное число колонок: для фиксированных 2 или 4 переключайтесь по @Environment(\.horizontalSizeClass): Array(repeating: GridItem(.flexible()), count: sizeClass == .compact ? 2 : 4); size class не то же самое, что устройство (iPad в Split View тоже compact).
Ленивость: LazyVGrid и LazyHGrid кладут внутрь ScrollView; каждый дочерний элемент занимает одну ячейку.
**Grid**: каждый GridRow задаёт строку, ячейки одной колонки имеют общую ширину, .gridCellColumns(2) растягивает ячейку на две колонки, а отдельная view между строками, например Divider(), занимает всю ширину.
Ловушка adaptive: колонки подбираются по minimum, поэтому минимум, завышенный на пару точек, незаметно превращает три колонки в две на том устройстве, которое вы не проверили.
Используйте макрос #Preview (по одному именованному превью на каждое состояние, способное сломать вёрстку) и подавайте в него тестовые данные и фейковые сервисы, чтобы превью никогда не ходило в сеть или в настоящую базу.
По превью на каждое рискованное состояние: загрузка, пусто, ошибка, много элементов, самая длинная реалистичная строка.
Проверяйте тёмную тему и крупный текст через .preferredColorScheme(.dark) и .environment(\.dynamicTypeSize, .accessibility3).
Держите тестовые данные рядом с моделью (User.preview), а не создавайте новую фикстуру в каждом превью.
Подставляйте фейковый сервис: для @Observable-зависимости это делают через .environment(MockService()).
@Previewable @State (Xcode 16) даёт превью живой binding без view-обёртки.
PreviewProvider: старая форма, которая ещё встречается в существующем коде.
Если view нельзя показать в превью без сетевого запроса или залогиненного пользователя, она знает слишком много; вынесите зависимость за протокол.
List подходит для длинных однотипных данных: он переиспользует строки и бесплатно даёт разделители, swipe-действия, выделение и режим редактирования. ScrollView с LazyVStack берут, когда дизайну нужна своя вёрстка, с которой стиль списка будет бороться.
Память: List построен на collection view и переиспользует строки, поэтому память не растёт. LazyVStack создаёт строки лениво, но сохраняет уже построенные view вместе с их состоянием, так что память растёт с тем, насколько далеко пользователь проскроллил.
**Бесплатно в List**: swipe-действия, .onDelete и .onMove, выделение, режим редактирования, липкие заголовки секций и навигация с клавиатуры.
**Бесплатно в ScrollView**: любой дизайн строк, нет разделителей и отступов, которые надо убирать, смешанный контент, горизонтальные карусели, постраничный скролл через .scrollTargetBehavior(.paging) и закреплённые заголовки через LazyVStack(pinnedViews:).
Практическое правило: тысячи похожих строк кладите в List, а экран с продуманным дизайном и ограниченным числом карточек собирайте в ScrollView.
Layout устроен как переговоры: родитель предлагает размер, дочерняя view выбирает свой размер, а затем родитель её размещает. Последнее слово за ребёнком, и это объясняет большинство сюрпризов вёрстки.
**.frame не меняет размер ребёнка**: он оборачивает ребёнка в новую view заданного размера, предлагает этот размер и центрирует то, что вернулось. Text внутри .frame(width: 100) переносится по ширине 100 точек; сам frame текст не растягивает.
Разные view занимают разное место: Text берёт сколько нужно, переносит или обрезает; Color, Spacer, фигуры и .frame(maxWidth: .infinity) забирают всё предложенное.
Стеки делят место по гибкости: сначала размер получают наименее гибкие дети, а гибкие делят остаток; layoutPriority(1) позволяет одному ребёнку забрать своё место первым.
**fixedSize()**: запрашивает у ребёнка идеальный размер и игнорирует предложение; так лечат Text, который обрезается вместо переноса.
**GeometryReader жадный**: он принимает всё предложение целиком, поэтому, добавленный в стек, расталкивает соседей.
NavigationStack хранит свои экраны как путь (path) из значений: NavigationLink(value:) добавляет значение, .navigationDestination(for:) превращает значение этого типа в экран, а кнопка «Назад» удаляет последнее. Привяжите path к состоянию, и навигация сводится к изменению массива.
Push и pop из кода: path.append(.settings) открывает экран, path.removeLast() закрывает, path.removeAll() возвращает в корень.
Типизированный или стёртый path: [Route] с Hashable-enum, когда все маршруты одного типа, и NavigationPath, когда экраны пушат несвязанные типы.
Диплинки: разберите URL в .onOpenURL в [Route] и присвойте его; весь стек появится сразу, без цепочки пушей.
Где регистрировать destination: на содержимом стека, вне ленивых контейнеров вроде строк List; ссылка, не нашедшая destination, ничего не делает.
**NavigationView устарел** (с iOS 16): его NavigationLink(destination:) создавал экраны заранее и не давал надёжного управления из кода. Для раскладки «сайдбар и детали» на iPad и Mac используйте NavigationSplitView.
Восстановление: NavigationPath.codable позволяет сохранить и восстановить стек.
.navigationDestination стоит внутри строк List. List строит строки лениво, поэтому стек не может рассчитывать, что увидит destination в момент срабатывания ссылки, и ссылка может ничего не сделать; перенесите модификатор на сам List.
Почему важна ленивость: строка, которая ещё не построена или была переиспользована, не зарегистрировала свой destination. SwiftUI требует объявлять destination вне ленивых контейнеров вроде List, LazyVStack и LazyVGrid.
Дубликаты: даже когда кажется, что всё работает, каждая строка регистрирует один и тот же destination для Product.self; SwiftUI предупреждает об этом и использует только объявление, ближайшее к корню.
Исправление: один .navigationDestination(for: Product.self) на List (или на любой неленивой view внутри NavigationStack), рядом с .navigationTitle.
Что ещё проверить: Product должен быть Hashable, а ссылка должна находиться внутри NavigationStack; value-ссылка вне стека не работает.
В обычном запуске при первом появлении выводится init, body, appear; после каждого тапа выводится только init. body родителя перевычисляется и заново создаёт структуру Child, но новое значение равно старому, поэтому SwiftUI пропускает Child.body, а дочерняя view не уходила с экрана, поэтому onAppear повторно не срабатывает.
**init не является событием жизненного цикла**: он вызывается каждый раз, когда body родителя строит view, иногда много раз, пока она на экране. Держите его тривиальным: без сетевых запросов и без создания модели вне @State.
**body пропускается, если входные данные равны**: SwiftUI сравнивает хранимые свойства новой view и читаемое ею состояние с предыдущими. Self._printChanges() внутри body выводит, что вызвало перевычисление.
**onAppear и onDisappear: срабатывают, когда view входит в видимую иерархию и покидает её: push и pop, переключение вкладок, прокрутка строк в ленивом списке. За жизнь view они могут сработать много раз.
Асинхронная работа**: место для .task, который отменяется, когда view исчезает (см. тему про состояние).
Task, запущенный в .onAppear, не привязан к view, и его никто не отменяет: каждое возвращение на экран запускает ещё один бесконечный цикл, и все они продолжают увеличивать seconds, даже пока этот экран закрыт другим.
Почему: .onAppear срабатывает заново при каждом возвращении view, например когда закрывается запушенный поверх экран, а неструктурированный Task { } живёт, пока не вернётся его замыкание. Цикл while true не возвращается никогда.
Исправление: .task запускает работу при появлении view и отменяет её при исчезновении, поэтому, пока секундомер на экране, работает ровно один цикл.
Пусть отмена завершает цикл: оставляйте try await внутри do/catch: с try? CancellationError от Task.sleep проглатывается, и отменённый цикл крутился бы без пауз.
Реальное прошедшее время: с .task счёт ставится на паузу, пока экран закрыт. Если отсчёт должен идти дальше, храните Date старта и показывайте его через Text(start, style: .timer) или TimelineView.
Как это поймать: добавьте лог в цикл, а затем несколько раз откройте и закройте экран поверх: больше одной строки в секунду означает больше одного цикла.
@ViewBuilder работает как result builder, который сворачивает список выражений в один вложенный generic-тип view; поэтому в body можно написать несколько view и if, не выписывая этот тип вручную. AnyView стирает этот тип, и SwiftUI больше не может сравнивать старое и новое дерево структурно.
Что строит билдер: соседние view превращаются в TupleView, if/else превращается в _ConditionalContent<A, B>, а if без else становится опциональной view.
Тип задаёт идентичность: две ветки if/else имеют разные типы, поэтому переключение ветки уничтожает одно поддерево и создаёт другое, сбрасывая весь @State внутри.
Ограничения билдера: он понимает только выражения-view, поэтому цикл for нужно заменить на ForEach. Помечайте @ViewBuilder свои вычисляемые свойства, функции и параметры init контейнеров, чтобы получить тот же синтаксис.
**Цена AnyView: когда обёрнутый тип меняется, SwiftUI не может сопоставить поддеревья, поэтому разрушает и пересоздаёт их, теряя состояние и выполняя лишнюю работу. Кроме того, структура скрыта от механизма сравнения фреймворка.
Альтернативы**: хелпер с @ViewBuilder, Group или generic-параметр Content: View. Оправданных применений AnyView мало: разнородная коллекция view или разрыв рекурсивного типа.
withAnimation анимирует все изменения, вызванные мутацией состояния внутри его замыкания, где бы ни находились затронутые view; модификатор .animation(_:value:) анимирует реакцию одной view на одно конкретное значение.
**withAnimation(.snappy) { isExpanded.toggle() }**: прикрепляет анимацию к транзакции, поэтому анимируется каждая view, зависящая от isExpanded.
**.animation(.smooth, value: count)**: локальная: анимируется эта view и только при изменении count.
**Почему .animation(_:) устарел**: без value он анимировал реакцию view на любое изменение, включая те, которые вы не планировали, и это давало «плывущую» вёрстку, причину которой трудно найти.
Переходы: для вставки и удаления используется .transition(...), который срабатывает, только если изменение, вставляющее или удаляющее view, анимировано.
Современные пресеты основаны на пружинах: .smooth, .snappy и .bouncy (iOS 17) заменяют ручной подбор длительности; .contentTransition(.numericText()) прокручивает цифры счётчика, а .transaction { $0.animation = nil } исключает поддерево из анимации.
Внутри строки List кнопки со стилем по умолчанию используют всю строку как область нажатия, поэтому один тап срабатывает у всех кнопок строки. Задайте каждой кнопке .buttonStyle(.borderless) или .plain, чтобы она отвечала только за область своего лейбла.
Почему: строка списка работает как ячейка, и областью нажатия служит вся строка; кнопки со стилем .automatic наследуют эту область вместо рамки своего лейбла. Исправление выглядит как стилизация, но на деле это hit testing.
Лучше раскладка: смешивать строку-NavigationLink с кнопками внутри строки в любом случае путает пользователя. Вынесите второстепенные и деструктивные действия в .swipeActions или .contextMenu, а тап по строке оставьте для навигации; если нужна одна кнопка в строке, используйте .borderless, как ниже.
Обратный баг: вне списка пустое место, например от Spacer, не принимает тапы, пока не добавить .contentShape(.rect).
Если один тап запускает две вещи, ищите контейнер, который сам является целью нажатия.
Возврат в корень сводится к одному изменению данных (очистке массива, управляющего NavigationStack), поэтому глубокому экрану нужен доступ к этому path, ведь @Environment(\.dismiss) откатывает только один уровень. В sheet у флоу свой NavigationStack и свой роутер.
**dismiss() зависит от контекста: в корне sheet он закрывает sheet, а в запушенной view закрывает один экран. Закрыть несколько уровней он не умеет.
Поделитесь path**: @Observable-роутер в environment с var path: [Route] позволяет любому потомку вызвать router.path.removeAll(); для NavigationPath эквивалентом служит path.removeLast(path.count).
Одно присваивание, без проблем с таймингом: «сохранить, вернуться в корень и открыть новый элемент» записывается как router.path = [.item(newID)], без цепочки анимированных pop.
У sheet свой стек и свой роутер: создайте второй Router в содержимом sheet и передайте его через .environment(sheetRouter); иначе унаследованный внешний роутер изменит не тот path. Флоу завершают через sheetRouter.path.removeAll() (вернуться в корень sheet) или editing = nil (закрыть sheet).
Разделяйте два вида состояния: .sheet(item:) для модального экрана, отдельный path для пушей внутри него и .interactiveDismissDisabled(isDirty), пока в форме есть несохранённые правки.
updateUIView пустой, поэтому новый url так и не доходит до WKWebView. makeUIView вызывается один раз на идентичность view; когда родитель передаёт другой URL, SwiftUI оставляет тот же UIKit-view и вызывает только updateUIView, который ничего не делает.
Контракт: makeUIView создаёт UIKit-view один раз; updateUIView вызывается сразу после него и затем при каждом изменении входных данных или окружения, и его задача в том, чтобы перенести текущее состояние SwiftUI во view; Coordinator является долгоживущим объектом, который один раз создаёт makeCoordinator и который хранит состояние между обновлениями.
Не перезагружайте при каждом обновлении: updateUIView вызывается и при посторонних изменениях, например при новом размере или смене цветовой схемы. Безусловная загрузка перезагружала бы страницу и сбрасывала позицию скролла, поэтому исправление хранит последний загруженный URL в Coordinator и загружает страницу, только если он отличается.
**Почему не сравнивать с webView.url**: он меняется при редиректах и когда пользователь переходит по ссылке, так что такое сравнение выдёргивало бы пользователя обратно на url.
**Альтернатива через .id**: WebView(url: article.url).id(article.id) даёт каждой статье новую идентичность, и SwiftUI уничтожает view и снова вызывает makeUIView. Это проще, но при каждом переключении выбрасывает web view вместе с его историей.
Как это поймать: на ревью считайте багом любой вход, который читает только makeUIView; превью, которое меняет вход после первой отрисовки, сразу это покажет.