ScrollView в SwiftUI iOS 26: scrollTargetBehavior, scrollTransition и новые API
Полный разбор ScrollView в SwiftUI и iOS 26: paging и snapping через scrollTargetBehavior, отслеживание позиции через scrollPosition, фазовые анимации scrollTransition и адаптивные карусели. Замена UIScrollView без потери контроля.
ScrollView в SwiftUI в iOS 26, это полностью декларативный контейнер с прокруткой. Через модификаторы scrollTargetBehavior, scrollTransition, scrollPosition и containerRelativeFrame он закрывает почти весь функционал, ради которого раньше приходилось падать в UIKit. Ниже я собрал новые API от iOS 17 до iOS 26, разложил их по реальным сценариям (paging, snapping, отслеживание позиции, анимации при скролле) и показал, как заменить UIScrollView и UICollectionView без потери контроля.
scrollTargetBehavior(.paging) и .viewAligned дают paging и snapping без ручной работы с offset и жестами.
scrollPosition(id:) в iOS 17 и scrollPosition(_:anchor:) в iOS 18/26 позволяют читать и программно менять позицию скролла напрямую.
scrollTransition запускает анимации на элементах на основе фазы (topLeading, identity, bottomTrailing), причём без GeometryReader.
containerRelativeFrame(_:count:spacing:) считает размер элемента от контейнера, что критично для горизонтальных каруселей.
onScrollGeometryChange и onScrollPhaseChange в iOS 18+ дают точную телеметрию скролла без PreferenceKey-хаков.
В iOS 26 модификаторы стабильны, работают с Liquid Glass и поддерживают Dynamic Type и VoiceOver из коробки.
Что нового в SwiftUI ScrollView к iOS 26
Скажу честно: долгое время ScrollView в SwiftUI был удобен только для тривиальных списков. Всё, что чуть сложнее вертикального стека (paging, snapping, синхронизация нескольких скроллов, точное отслеживание offset), требовало либо UIScrollView через UIViewRepresentable, либо гимнастики с GeometryReader и PreferenceKey. Начиная с iOS 17 Apple полностью перевернула ситуацию. Появился новый семейный API «Scroll Target», в iOS 18 добавились геометрические наблюдатели, а в iOS 26 всё это отшлифовано, задокументировано и стабильно работает с новым дизайном Liquid Glass.
Если кратко, современный ScrollView в iOS 26 умеет: делать paging и snapping через scrollTargetBehavior, читать и записывать позицию через scrollPosition, применять фазовые анимации через scrollTransition, считать размеры элементов относительно контейнера через containerRelativeFrame и отдавать метрики скролла через onScrollGeometryChange. Всё это публичные API, без обходов через UIKit и без хаков с PreferenceKey. В остальной части статьи я разбираю каждый модификатор с рабочими примерами на Swift 6 и Xcode 26.
scrollTargetBehavior и scrollTargetLayout: paging и snapping
Раньше, чтобы сделать paging-скролл в SwiftUI, приходилось руками ловить DragGesture, считать инерцию и подстраивать offset. С iOS 17 два модификатора закрывают почти все сценарии snapping: scrollTargetBehavior(_:) на самом ScrollView, и scrollTargetLayout() на контейнере с элементами, к которым нужно прилипать. Первый говорит, «как» прилипать (страницами или к элементам). Второй, «к чему» прилипать.
Полностраничный paging
Самый простой случай, это вертикальный или горизонтальный paging по размеру самого ScrollView:
struct PagingGallery: View {
let photos: [Photo]
var body: some View {
ScrollView(.horizontal) {
LazyHStack(spacing: 0) {
ForEach(photos) { photo in
PhotoCard(photo: photo)
.containerRelativeFrame(.horizontal)
}
}
}
.scrollTargetBehavior(.paging)
.scrollIndicators(.hidden)
}
}
containerRelativeFrame(.horizontal) здесь важен. Без него LazyHStack подстроится под контент, а не под ширину скролла, и paging разъедется. Модификатор .paging, это готовое поведение, аналог UIScrollView.isPagingEnabled = true.
Snapping к отдельным элементам (viewAligned)
Если элементы разного размера или их нужно центрировать по одному, используем .viewAligned плюс scrollTargetLayout() на стеке:
Здесь скролл будет останавливаться так, чтобы ближайшая карточка выравнивалась по краю. contentMargins(_:_:for:), это ещё одна важная новинка iOS 17: она добавляет отступы у контента, но в отличие от .padding() не «съедает» индикаторы скролла и корректно работает с safe area в iOS 26.
scrollPosition: как отслеживать позицию скролла в SwiftUI
Чтобы отследить позицию скролла в SwiftUI и программно её изменить, в iOS 17 появился модификатор scrollPosition(id:), а в iOS 18/26 расширенный scrollPosition(_:anchor:) с типом ScrollPosition. Через них можно узнать, какой id элемента сейчас в поле зрения, и заскроллить к любому id без ScrollViewReader.
Отслеживание видимого элемента (iOS 17)
struct FeedView: View {
let posts: [Post]
@State private var visiblePostId: Post.ID?
var body: some View {
ScrollView {
LazyVStack(spacing: 12) {
ForEach(posts) { post in
PostRow(post: post)
.id(post.id)
}
}
.scrollTargetLayout()
}
.scrollPosition(id: $visiblePostId)
.onChange(of: visiblePostId) { _, newId in
guard let newId else { return }
Analytics.log(.postSeen(id: newId))
}
}
}
Обратите внимание: scrollTargetLayout() нужен и здесь. Он подсказывает SwiftUI, к каким элементам «привязывать» позицию. Без него visiblePostId останется nil. Я хорошо помню этот баг, пару часов ушло у меня на нём в одном из первых проектов на iOS 17.
Программный скролл и точечная навигация (iOS 18+)
В iOS 18 API стал ещё гибче: ScrollPosition позволяет задать точку прокрутки, а не только id, и указать anchor:
@available(iOS 18.0, *)
struct ChatView: View {
let messages: [Message]
@State private var position = ScrollPosition(edge: .bottom)
var body: some View {
ScrollView {
LazyVStack {
ForEach(messages) { message in
MessageBubble(message: message)
.id(message.id)
}
}
.scrollTargetLayout()
}
.scrollPosition($position, anchor: .bottom)
.onChange(of: messages.count) {
withAnimation(.smooth) {
position.scrollTo(edge: .bottom)
}
}
}
}
Это идиоматичный способ реализовать «прилипание к низу», как в мессенджерах. Никакого ScrollViewReader, никакого DispatchQueue-хака на onAppear.
scrollTransition: анимации элементов на основе фазы
Модификатор scrollTransition, доступный на любой view внутри ScrollView, это, пожалуй, самая эффектная новинка. Он получает замыкание (content, phase) -> Content, где ScrollTransitionPhase имеет три состояния: .topLeading (элемент только въезжает), .identity (в центре видимой области) и .bottomTrailing (уходит из зоны). SwiftUI сам интерполирует между ними, поэтому не нужны GeometryReader и вычисления offset.
Первый параметр, это ScrollTransitionConfiguration. Варианты: .animated (плавно), .interactive (жёстко привязано к пальцу, лучший вариант для карусели) и .identity (без анимации, используется для отладки). Можно передать .animated(.smooth(duration: 0.35)), если нужен свой timing curve.
Параметрический transition через phase.value
У фазы есть свойство value, это Double в диапазоне [-1, 1]: -1 значит верхний край, 0 значит центр, 1 значит нижний. Это позволяет собирать нелинейные эффекты:
Такой код за десяток строк даёт эффект Cover Flow, то, ради чего раньше писали кастомные UICollectionViewLayout. Подробнее о фазовых анимациях и timing curves я рассказывал в статье анимации в SwiftUI: PhaseAnimator и KeyframeAnimator. Там та же ментальная модель фаз, только без привязки к скроллу.
containerRelativeFrame: адаптивные карусели
Модификатор containerRelativeFrame(_:count:span:spacing:alignment:), это младший брат frame, но считает размер не в абсолютных точках, а от ближайшего контейнера скролла. Это критично для горизонтальных каруселей на iPad и iPhone разных размеров. Жёсткий .frame(width: 300) сломается, а containerRelativeFrame(.horizontal, count: 3) сам подстроится.
Параметр count говорит, сколько элементов помещается в контейнер целиком. span позволяет одному элементу занять несколько «колонок». Так вы получаете сетку в App Store буквально из одного стека, без LazyVGrid и без GeometryReader. И, что важно, поведение корректно при повороте устройства и при появлении Split View на iPad.
onScrollGeometryChange и onScrollPhaseChange в iOS 18 и iOS 26
До iOS 18 узнать точный offset или размер видимой области можно было только через GeometryReader с PreferenceKey. Способ рабочий, но многословный и хрупкий. В iOS 18 появились два новых модификатора: onScrollGeometryChange(for:of:action:) для отслеживания геометрии и onScrollPhaseChange для отслеживания состояний жеста. В iOS 26 они дошли до продакшн-стабильности и активно используются в системных приложениях.
Первый параметр, это тип производной величины. Второй — функция, которая её вычисляет из ScrollGeometry. SwiftUI кэширует значение и вызывает action только когда оно реально меняется. Это дешевле, чем перерисовка на каждом кадре скролла.
Отслеживание фаз жеста
.onScrollPhaseChange { oldPhase, newPhase in
switch newPhase {
case .interacting: haptics.play(.selection)
case .decelerating: prefetcher.begin()
case .idle: prefetcher.pause()
default: break
}
}
Фазы: .idle, .tracking, .interacting, .decelerating, .animating. Полезно для тактильных откликов (см. руководство по хаптик-фидбеку в iOS 26) и для интеллектуального prefetch.
scrollBounceBehavior, scrollClipDisabled и другие полезные модификаторы
Помимо крупных API, SwiftUI в iOS 17-26 получил десяток небольших, но выручающих модификаторов. Они не тянут на отдельную статью, но каждый закрывает частый баг-репорт.
scrollBounceBehavior(.basedOnSize), отключает bounce, если контент помещается на экран целиком. Обязательный модификатор для «полностраничных» экранов без списка.
scrollClipDisabled(), снимает clipping у ScrollView, что позволяет теням и scrollTransition-эффектам выходить за границы. Без него красивая карусель обрежется.
scrollDismissesKeyboard(.interactively), интерактивное скрытие клавиатуры жестом вниз, как в Сообщениях.
defaultScrollAnchor(.center), начальный якорь при первом появлении, полезно для галерей и чатов.
contentMargins(_:_:for:), «правильные» отступы, которые не ломают safe area и индикаторы.
scrollIndicators(.hidden) и scrollIndicatorsFlash(onAppear:), управление системными индикаторами.
Модификатор
Минимальная версия
Основной сценарий
scrollTargetBehavior
iOS 17
paging и snapping
scrollTargetLayout
iOS 17
«якоря» для snapping и scrollPosition
scrollTransition
iOS 17
фазовые анимации элементов
scrollPosition(id:)
iOS 17
чтение и запись текущего id
containerRelativeFrame
iOS 17
адаптивные карусели и сетки
contentMargins
iOS 17
отступы контента без ломки safe area
onScrollGeometryChange
iOS 18
реактивное отслеживание offset
onScrollPhaseChange
iOS 18
реакция на фазы жеста
ScrollPosition (структура)
iOS 18
программный скролл к edge/point
Как заменить UIScrollView на SwiftUI ScrollView
Короткий ответ: в iOS 26 в 90% случаев UIScrollView в SwiftUI больше не нужен. Полностраничный paging закрыт scrollTargetBehavior(.paging), snapping закрыт .viewAligned, отслеживание offset решается через onScrollGeometryChange, а программная навигация через ScrollPosition. Остаются буквально несколько кейсов, где UIKit пока выигрывает: pinch-to-zoom для одиночного изображения (SwiftUI MagnificationGesture работает, но без встроенного «резинового» отскока), refresh с pull-to-load-more (реализуется, но не так элегантно, как UIRefreshControl), и очень сложные UICollectionViewCompositionalLayout с несколькими секциями смешанной ориентации.
Ниже, таблица соответствий, которую я держу под рукой при миграции:
Что нужно
UIKit
SwiftUI (iOS 17+)
Paging по экрану
isPagingEnabled = true
.scrollTargetBehavior(.paging)
Snapping к ячейкам
targetContentOffset в делегате
.scrollTargetBehavior(.viewAligned)
Отслеживание offset
scrollViewDidScroll(_:)
.onScrollGeometryChange
Программный скролл
setContentOffset(_:animated:)
ScrollPosition/scrollPosition(id:)
Скрытие индикаторов
showsVerticalScrollIndicator = false
.scrollIndicators(.hidden)
Отступы контента
contentInset
.contentMargins(_:_:for:)
Анимация ячеек
willDisplay/didEndDisplaying
.scrollTransition
Если вы всё ещё держите смешанный проект UIKit+SwiftUI, начните миграцию с самых визуально насыщенных экранов. Там выигрыш от scrollTransition и Liquid Glass наиболее заметен. Про архитектурные аспекты SwiftUI-навигации я подробно писал в гайде по SwiftUI Navigation, а про интеграцию скролла с новым дизайном iOS 26, в руководстве по Liquid Glass.
Частые проблемы и решения
За полтора года работы с новым ScrollView API я собрал список типичных подвохов. Ниже, самые частые.
scrollPosition всегда nil
Причина в 99% случаев, это забытый scrollTargetLayout() на стеке. SwiftUI не отслеживает произвольные view: ему нужен маркер контейнера, к элементам которого можно «привязаться». Ставьте .scrollTargetLayout() на тот LazyVStack или LazyHStack, чьи элементы имеют .id(...). Я на этом попадался при шипинге фичи с бесконечной лентой, полдня искал, почему visibleId не обновляется.
scrollTargetBehavior(.paging) прыгает не туда
Проверьте два места. Первое, это размер элементов: без containerRelativeFrame(.horizontal) paging возьмёт intrinsicSize, что почти всегда неправильно. Второе, это contentMargins вместо .padding(). Обычный padding смещает точку snapping.
scrollTransition обрезается
Тень, скейл или blur могут выходить за границы ScrollView, и стандартный clip их обрежет. Добавьте .scrollClipDisabled(). Только не забывайте, что тогда контент технически рисуется поверх соседей, и hitTesting может пострадать.
onScrollGeometryChange «дребезжит»
Возвращайте из первой функции ту величину, которую реально хотите наблюдать (например, Bool, «offset больше X»), а не сам CGPoint. SwiftUI сравнивает возвращаемое значение через Equatable, и если оно не меняется, action не вызывается. Вместе с withAnimation это даёт бесплатное дебаунсирование.
ScrollView не работает в Preview
С iOS 17 Xcode Previews иногда не запускают анимации scrollTransition сразу. Попробуйте включить interactive preview кнопкой Play или запустить на симуляторе. Официальная информация есть в документации Apple по ScrollView и в сессии WWDC23 «Beyond scroll views». Кроме того, следите за релиз-нотами iOS/iPadOS 26. Apple туда стабильно добавляет уточнения по поведению новых модификаторов.
Часто задаваемые вопросы
Как сделать paging в SwiftUI ScrollView?
Начиная с iOS 17 добавьте модификатор .scrollTargetBehavior(.paging) на ScrollView и оберните элементы в LazyHStack/LazyVStack, каждому применив containerRelativeFrame(.horizontal). Дополнительный ScrollViewReader или UIKit не требуется.
Что такое scrollTargetBehavior в SwiftUI?
Это модификатор iOS 17+, задающий поведение скролла при остановке. Основные значения: .paging для полностраничного paging и .viewAligned для snapping к отдельным элементам. Он работает в паре с scrollTargetLayout() на контейнере.
Как отслеживать позицию скролла в SwiftUI?
В iOS 17 используйте .scrollPosition(id:) для отслеживания видимого элемента по id, а в iOS 18+ применяйте onScrollGeometryChange(for:of:action:), если нужен точный offset или размер видимой области. Это заменяет старую связку GeometryReader и PreferenceKey.
Можно ли заменить UIScrollView на SwiftUI ScrollView?
Да, в iOS 26 практически во всех сценариях. Paging, snapping, программная навигация, отслеживание offset и анимации ячеек покрыты нативными модификаторами. UIKit остаётся оправданным только для сложного pinch-to-zoom и некоторых UICollectionViewCompositionalLayout-раскладок.
Совместим ли scrollTransition с Liquid Glass в iOS 26?
Да, полностью. scrollTransition работает как с обычными материалами, так и с glassEffect. Единственный нюанс: при активных blur/scale не забывайте про .scrollClipDisabled(), иначе стеклянная поверхность обрежется по границам скролла.
Разбираем хаптик-фидбек в SwiftUI: когда хватает .sensoryFeedback, а когда пора идти в Core Haptics. Параметры intensity и sharpness, декларативные AHAP-паттерны, синхронизация с анимациями и правила accessibility для iOS 26.
Практический гайд по StoreKit 2 в Swift: загрузка продуктов, JWS-верификация транзакций, SubscriptionStoreView и серверная проверка через App Store Server API для iOS 26.
TipKit в SwiftUI: как показать подсказки popover и inline, задать правила и события, донатить активность пользователя, тестировать и не сломать accessibility. Полный гайд для iOS 26 с примерами кода.