SwiftUI ScrollView Patterns Reference
SwiftUI ScrollView Patterns Reference
Section titled “SwiftUI ScrollView Patterns Reference”Table of Contents
Section titled “Table of Contents”- ScrollViewReader for Programmatic Scrolling
- Scroll Position Tracking
- Scroll Transitions and Effects
- Scroll Target Behavior
- Summary Checklist
ScrollViewReader for Programmatic Scrolling
Section titled “ScrollViewReader for Programmatic Scrolling”Use ScrollViewReader for scroll-to-top, scroll-to-bottom, and anchor-based jumps.
struct ChatView: View { @State private var messages: [Message] = [] private let bottomID = "bottom"
var body: some View { ScrollViewReader { proxy in ScrollView { LazyVStack { ForEach(messages) { message in MessageRow(message: message) .id(message.id) } Color.clear .frame(height: 1) .id(bottomID) } } .onChange(of: messages.count) { _, _ in withAnimation { proxy.scrollTo(bottomID, anchor: .bottom) } } .onAppear { proxy.scrollTo(bottomID, anchor: .bottom) } } }}Scroll-to-Top Pattern
Section titled “Scroll-to-Top Pattern”struct FeedView: View { @State private var items: [Item] = [] @State private var scrollToTop = false private let topID = "top"
var body: some View { ScrollViewReader { proxy in ScrollView { LazyVStack { Color.clear .frame(height: 1) .id(topID)
ForEach(items) { item in ItemRow(item: item) } } } .onChange(of: scrollToTop) { _, shouldScroll in if shouldScroll { withAnimation { proxy.scrollTo(topID, anchor: .top) } scrollToTop = false } } } }}Why: ScrollViewReader provides programmatic scroll control with stable anchors. Always use stable IDs and explicit animations.
Scroll Position Tracking
Section titled “Scroll Position Tracking”Basic Scroll Position
Section titled “Basic Scroll Position”Avoid - Storing scroll position directly triggers view updates on every scroll frame:
// ❌ Bad Practice - causes unnecessary re-rendersstruct ContentView: View { @State private var scrollPosition: CGFloat = 0
var body: some View { ScrollView { content .background( GeometryReader { geometry in Color.clear .preference( key: ScrollOffsetPreferenceKey.self, value: geometry.frame(in: .named("scroll")).minY ) } ) } .coordinateSpace(name: "scroll") .onPreferenceChange(ScrollOffsetPreferenceKey.self) { value in scrollPosition = value } }}Preferred - Check scroll position and update a flag based on thresholds for smoother, more efficient scrolling:
// ✅ Good Practice - only updates state when crossing thresholdstruct ContentView: View { @State private var startAnimation: Bool = false
var body: some View { ScrollView { content .background( GeometryReader { geometry in Color.clear .preference( key: ScrollOffsetPreferenceKey.self, value: geometry.frame(in: .named("scroll")).minY ) } ) } .coordinateSpace(name: "scroll") .onPreferenceChange(ScrollOffsetPreferenceKey.self) { value in if value < -100 { startAnimation = true } else { startAnimation = false } } }}
struct ScrollOffsetPreferenceKey: PreferenceKey { static var defaultValue: CGFloat = 0 static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value = nextValue() }}Scroll-Based Header Visibility
Section titled “Scroll-Based Header Visibility”struct ContentView: View { @State private var showHeader = true
var body: some View { VStack(spacing: 0) { if showHeader { HeaderView() .transition(.move(edge: .top)) }
ScrollView { content .background( GeometryReader { geometry in Color.clear .preference( key: ScrollOffsetPreferenceKey.self, value: geometry.frame(in: .named("scroll")).minY ) } ) } .coordinateSpace(name: "scroll") .onPreferenceChange(ScrollOffsetPreferenceKey.self) { offset in if offset < -50 { // Scrolling down withAnimation { showHeader = false } } else if offset > 50 { // Scrolling up withAnimation { showHeader = true } } } } }}Scroll Transitions and Effects
Section titled “Scroll Transitions and Effects”iOS 17+: All APIs in this section require iOS 17 or later.
Scroll-Based Opacity
Section titled “Scroll-Based Opacity”struct ParallaxView: View { var body: some View { ScrollView { LazyVStack(spacing: 20) { ForEach(items) { item in ItemCard(item: item) .visualEffect { content, geometry in let frame = geometry.frame(in: .scrollView) let distance = min(0, frame.minY) return content .opacity(1 + distance / 200) } } } } }}Parallax Effect
Section titled “Parallax Effect”struct ParallaxHeader: View { var body: some View { ScrollView { VStack(spacing: 0) { Image("hero") .resizable() .aspectRatio(contentMode: .fill) .frame(height: 300) .visualEffect { content, geometry in let offset = geometry.frame(in: .scrollView).minY return content .offset(y: offset > 0 ? -offset * 0.5 : 0) } .clipped()
ContentView() } } }}Scroll Target Behavior
Section titled “Scroll Target Behavior”iOS 17+: All APIs in this section require iOS 17 or later.
Paging ScrollView
Section titled “Paging ScrollView”struct PagingView: View { var body: some View { ScrollView(.horizontal) { LazyHStack(spacing: 0) { ForEach(pages) { page in PageView(page: page) .containerRelativeFrame(.horizontal) } } .scrollTargetLayout() } .scrollTargetBehavior(.paging) }}Snap to Items
Section titled “Snap to Items”struct SnapScrollView: View { var body: some View { ScrollView(.horizontal) { LazyHStack(spacing: 16) { ForEach(items) { item in ItemCard(item: item) .frame(width: 280) } } .scrollTargetLayout() } .scrollTargetBehavior(.viewAligned) .contentMargins(.horizontal, 20) }}Summary Checklist
Section titled “Summary Checklist”- Use
ScrollViewReaderwith stable IDs for programmatic scrolling - Always use explicit animations with
scrollTo() - Use
.visualEffectfor scroll-based visual changes - Use
.scrollTargetBehavior(.paging)for paging behavior - Use
.scrollTargetBehavior(.viewAligned)for snap-to-item behavior - Gate frequent scroll position updates by thresholds
- Use preference keys for custom scroll position tracking