ScrollView в SwiftUI iOS 26: scrollTargetBehavior, scrollTransition и новые API

Полный разбор ScrollView в SwiftUI и iOS 26: paging и snapping через scrollTargetBehavior, отслеживание позиции через scrollPosition, фазовые анимации scrollTransition и адаптивные карусели. Замена UIScrollView без потери контроля.

SwiftUI ScrollView в iOS 26: гайд

Обновлено: 14 августа 2026

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() на стеке:

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:), управление системными индикаторами.
МодификаторМинимальная версияОсновной сценарий
scrollTargetBehavioriOS 17paging и snapping
scrollTargetLayoutiOS 17«якоря» для snapping и scrollPosition
scrollTransitioniOS 17фазовые анимации элементов
scrollPosition(id:)iOS 17чтение и запись текущего id
containerRelativeFrameiOS 17адаптивные карусели и сетки
contentMarginsiOS 17отступы контента без ломки safe area
onScrollGeometryChangeiOS 18реактивное отслеживание offset
onScrollPhaseChangeiOS 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 с несколькими секциями смешанной ориентации.

Ниже, таблица соответствий, которую я держу под рукой при миграции:

Что нужноUIKitSwiftUI (iOS 17+)
Paging по экрануisPagingEnabled = true.scrollTargetBehavior(.paging)
Snapping к ячейкамtargetContentOffset в делегате.scrollTargetBehavior(.viewAligned)
Отслеживание offsetscrollViewDidScroll(_:).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(), иначе стеклянная поверхность обрежется по границам скролла.

Editorial Team
Об авторе Editorial Team

Our team of expert writers and editors.