Обновлено: 14 августа 2026
ScrollView в SwiftUI в iOS 26, это полностью декларативный контейнер с прокруткой. Через модификаторы scrollTargetBehavior, scrollTransition, scrollPosition и containerRelativeFrame он закрывает почти весь функционал, ради которого раньше приходилось падать в UIKit. Ниже я собрал новые API от iOS 17 до iOS 26, разложил их по реальным сценариям (paging, snapping, отслеживание позиции, анимации при скролле) и показал, как заменить UIScrollView и UICollectionView без потери контроля.
Что нового в 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() на стеке:
struct CardCarousel: View {
let cards: [Card]
var body: some View {
ScrollView(.horizontal) {
LazyHStack(spacing: 16) {
ForEach(cards) { card in
CardView(card: card)
.containerRelativeFrame(
.horizontal, count: 2, spacing: 16
)
}
}
.scrollTargetLayout()
}
.contentMargins(.horizontal, 16, for: .scrollContent)
.scrollTargetBehavior(.viewAligned)
}
}
Здесь скролл будет останавливаться так, чтобы ближайшая карточка выравнивалась по краю. 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.
ScrollView {
LazyVStack(spacing: 24) {
ForEach(articles) { article in
ArticleCard(article: article)
.scrollTransition(.animated) { content, phase in
content
.opacity(phase.isIdentity ? 1 : 0.3)
.scaleEffect(phase.isIdentity ? 1 : 0.85)
.blur(radius: phase.isIdentity ? 0 : 6)
}
}
}
}
Первый параметр, это ScrollTransitionConfiguration. Варианты: .animated (плавно), .interactive (жёстко привязано к пальцу, лучший вариант для карусели) и .identity (без анимации, используется для отладки). Можно передать .animated(.smooth(duration: 0.35)), если нужен свой timing curve.
Параметрический transition через phase.value
У фазы есть свойство value, это Double в диапазоне [-1, 1]: -1 значит верхний край, 0 значит центр, 1 значит нижний. Это позволяет собирать нелинейные эффекты:
.scrollTransition(axis: .horizontal) { content, phase in
content
.rotation3DEffect(
.degrees(phase.value * 45),
axis: (x: 0, y: 1, z: 0),
perspective: 0.5
)
.offset(x: phase.value * -20)
}
Такой код за десяток строк даёт эффект Cover Flow, то, ради чего раньше писали кастомные UICollectionViewLayout. Подробнее о фазовых анимациях и timing curves я рассказывал в статье анимации в SwiftUI: PhaseAnimator и KeyframeAnimator. Там та же ментальная модель фаз, только без привязки к скроллу.
containerRelativeFrame: адаптивные карусели
Модификатор containerRelativeFrame(_:count:span:spacing:alignment:), это младший брат frame, но считает размер не в абсолютных точках, а от ближайшего контейнера скролла. Это критично для горизонтальных каруселей на iPad и iPhone разных размеров. Жёсткий .frame(width: 300) сломается, а containerRelativeFrame(.horizontal, count: 3) сам подстроится.
ScrollView(.horizontal) {
LazyHStack(spacing: 12) {
ForEach(products) { product in
ProductTile(product: product)
.containerRelativeFrame(
.horizontal,
count: horizontalSizeClass == .regular ? 4 : 2,
spacing: 12
)
}
}
.scrollTargetLayout()
}
.scrollTargetBehavior(.viewAligned)
Параметр 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 они дошли до продакшн-стабильности и активно используются в системных приложениях.
Прячем навбар при скролле
@available(iOS 18.0, *)
struct DetailView: View {
@State private var isScrolled = false
var body: some View {
ScrollView {
content
}
.onScrollGeometryChange(for: Bool.self) { geometry in
geometry.contentOffset.y > 44
} action: { _, isScrolledNow in
withAnimation(.easeInOut(duration: 0.2)) {
isScrolled = isScrolledNow
}
}
.toolbarBackground(
isScrolled ? .visible : .hidden,
for: .navigationBar
)
}
}
Первый параметр, это тип производной величины. Второй — функция, которая её вычисляет из 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(), иначе стеклянная поверхность обрежется по границам скролла.




