DEV Community

Sebastien Lato
Sebastien Lato

Posted on

ScrollView & Coordinate Spaces in SwiftUI

Scroll-based UI is everywhere in modern apps:

  • collapsing headers
  • parallax effects
  • sticky toolbars
  • section pinning
  • scroll-driven animations
  • pull-to-refresh logic
  • infinite lists

Yet most SwiftUI developers treat ScrollView as a black box — which leads to:

  • jumpy animations
  • incorrect offsets
  • broken geometry math
  • magic numbers
  • fragile hacks

The missing piece is coordinate spaces.

This post explains how scrolling actually works in SwiftUI, how coordinate spaces interact, and how to build reliable, production-grade scroll-driven UI.


🧠 The Mental Model: Scrolling Is Just Geometry

SwiftUI scrolling is not special.

It’s just:

Views moving through coordinate spaces over time.

Once you understand where a view thinks it is, scroll effects become predictable.


📐 1. What Is a Coordinate Space?

A coordinate space defines where (0,0) is.

SwiftUI gives you three main types:

1️⃣ Local

Relative to the view itself.

geo.frame(in: .local)
Enter fullscreen mode Exit fullscreen mode

2️⃣ Global

Relative to the entire screen/window.

geo.frame(in: .global)
Enter fullscreen mode Exit fullscreen mode

3️⃣ Named

A custom reference you define.

.coordinateSpace(name: "scroll")
geo.frame(in: .named("scroll"))
Enter fullscreen mode Exit fullscreen mode

Most scroll bugs come from using the wrong one.


🧱 2. ScrollView Creates a Moving Coordinate System

Inside a ScrollView:

  • content moves
  • geometry values change
  • coordinate origins shift

Example:

ScrollView {
    GeometryReader { geo in
        Text("Offset: \(geo.frame(in: .global).minY)")
    }
    .frame(height: 40)
}
Enter fullscreen mode Exit fullscreen mode

As you scroll, minY updates continuously.

That’s your scroll offset.


🧭 3. Why Named Coordinate Spaces Matter

Global space works — until it doesn’t.

Problems with .global:

  • breaks in sheets
  • breaks in navigation stacks
  • breaks in split views
  • breaks on macOS/iPad

Correct pattern:

ScrollView {
    content
}
.coordinateSpace(name: "scroll")
Enter fullscreen mode Exit fullscreen mode

Then read geometry relative to it:

geo.frame(in: .named("scroll")).minY
Enter fullscreen mode Exit fullscreen mode

This keeps your math stable everywhere.


📦 4. The Clean Scroll Offset Pattern (Production-Grade)

Use a zero-height GeometryReader:

ScrollView {
    GeometryReader { geo in
        Color.clear
            .preference(
                key: ScrollOffsetKey.self,
                value: geo.frame(in: .named("scroll")).minY
            )
    }
    .frame(height: 0)

    content
}
.coordinateSpace(name: "scroll")
.onPreferenceChange(ScrollOffsetKey.self) { offset in
    scrollOffset = offset
}
Enter fullscreen mode Exit fullscreen mode

Preference key:

struct ScrollOffsetKey: PreferenceKey {
    static var defaultValue: CGFloat = 0
    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
        value = nextValue()
    }
}
Enter fullscreen mode Exit fullscreen mode

This pattern is:

  • stable
  • reusable
  • animation-friendly
  • production safe

🧩 5. Building Scroll-Driven Effects

Once you have scrollOffset, everything becomes math.

Collapsing Header

let progress = max(0, min(1, 1 - scrollOffset / 120))
Enter fullscreen mode Exit fullscreen mode

Parallax

.offset(y: -scrollOffset * 0.3)
Enter fullscreen mode Exit fullscreen mode

Fade Out

.opacity(1 - min(1, scrollOffset / 80))
Enter fullscreen mode Exit fullscreen mode

Scale

.scaleEffect(max(0.8, 1 - scrollOffset / 400))
Enter fullscreen mode Exit fullscreen mode

Scroll effects should be:

  • clamped
  • monotonic
  • predictable

📌 6. Sticky Headers Without Hacks

SwiftUI already gives you pinned headers:

LazyVStack(pinnedViews: [.sectionHeaders]) {
    Section(header: HeaderView()) {
        rows
    }
}
Enter fullscreen mode Exit fullscreen mode

Use geometry only when:

  • the header animates
  • the header morphs
  • the header fades or scales

Don’t reinvent pinning logic.


⚠️ 7. GeometryReader Pitfalls in ScrollView

Common mistakes:
❌ GeometryReader wrapping entire scroll content
❌ GeometryReader inside every row
❌ Heavy layout work during scrolling
❌ Multiple nested GeometryReaders

Rules:

  • keep GeometryReader small
  • read geometry once
  • propagate values upward
  • never do expensive work per frame

📱 8. ScrollView + Lists + Performance

Lists already virtualize content.

If you need geometry in a list:

  • avoid per-row GeometryReader
  • read scroll offset once
  • derive row effects from global state

Scroll performance depends on:

  • layout stability
  • minimal invalidations
  • cheap math

🧠 9. Coordinate Spaces in Complex Layouts

Use named spaces to disambiguate:

.coordinateSpace(name: "root")
.coordinateSpace(name: "scroll")
.coordinateSpace(name: "card")
Enter fullscreen mode Exit fullscreen mode

Then measure intentionally:

geo.frame(in: .named("card"))
Enter fullscreen mode Exit fullscreen mode

This avoids fragile assumptions.


🔁 10. Scroll Restoration & State

Scroll position is not preserved automatically.

If needed:

  • store offset in AppState
  • restore using ScrollViewReader
  • avoid restoring during animations
ScrollViewReader { proxy in
    proxy.scrollTo(id, anchor: .top)
}
Enter fullscreen mode Exit fullscreen mode

Use sparingly.


🧠 Mental Model Cheat Sheet

Ask yourself:

  1. Which coordinate space am I in?
  2. Where is (0,0)?
  3. What moves during scrolling?
  4. What stays fixed?
  5. Am I measuring the right thing?

If geometry feels “random”, one of these is wrong.


🚀 Final Thoughts

Scroll-driven UI in SwiftUI is not magic.

It’s:

  • geometry
  • coordinate spaces
  • stable math
  • intentional measurement

Once you understand this, you can build:

  • collapsing headers
  • parallax effects
  • scroll-based animations
  • adaptive layouts
  • polished, Apple-quality UI

Top comments (0)