← all posts

Sticky headers in SwiftUI that hide, stack, and survive

LazyVStack with pinnedViews gets you 30% of the way there. Here's how I built the other 70%.

The inspiration was the iOS Calendar app. Open it in week view and watch the headers: the month label sits at the top, the current day's header slides in under it, and when you scroll into a new month the new month's label slides on top of the old one in one continuous motion. Multiple sticky layers, ordered by what's currently on screen, choreographed so nothing ever overlaps or pops. That's the behaviour I wanted.

The technical jumping-off point was Swift Talk episode #333 — Sticky Headers for Scroll Views. Chris and Florian build the foundation: a GeometryReader inside a named coordinateSpace, a PreferenceKey that broadcasts every header's frame up to the ScrollView, an isSticking check that's just frame.minY < 0, and an offset that pushes a header up when the next one approaches. One sticky layer at a time. Watch it first if you want the gentle introduction — it's 25 minutes well spent.

This post is about the next layer up: making multiple headers stack, letting some hide on scroll while others stay pinned, getting the push-off math right when there's already a stack of pinned headers above, and handing all of it through the view tree without the caller having to pass arrays of CGRects around.

Calendar demo: a filter bar that hides on scroll up, fixed month headers stacking at the top, and weekly section headers pushing each other off.

All three modes together: filter bar (reverse), month headers (fixed, stacking), weeks (regular, pushing each other off).

The full source for the modifier and the calendar demo lives on GitHub: Olya-Yer/sticky-headers-swiftui.

Why not LazyVStack?

SwiftUI ships with LazyVStack(pinnedViews: [.sectionHeaders]), and on day one it feels like the answer. By day three you've usually outgrown it:

I needed all four. So I wrote my own — about 250 lines of view modifier — that supports four modes per header:

The API first

The caller writes two things. At the ScrollView root:

ScrollView(.vertical, showsIndicators: false) {
    VStack(spacing: 0) {
        // content
    }
}
.useStickyHeaders()

And on each header that should stick:

monthBar
    .background(Color.white)
    .sticky(lastItemY: lastItemY, isFixed: true)
    .zIndex(100)

No coordinator, no view model, no UIScrollViewDelegate. The zIndex at the call site is how the caller controls which sticky renders on top when two overlap — more on that further down.

How the pieces talk to each other

The hard part of multi-header sticking is that every header needs to know about every other header. Header A can't compute its own offset without knowing whether Header B is currently pinned above it — and Header B is two levels up the view tree.

In the Swift Talk episode the array of frames is collected at the ScrollView root and passed back into .sticky([CGRect]) explicitly by the caller. That works for a demo. It gets ugly fast when the headers are generated inside a ForEach deep in some child view, or when the caller shouldn't need to know that frames exist at all.

So I closed the loop with Environment. PreferenceKey moves frames up the tree exactly as before; Environment moves the merged dictionary back down. The caller writes .sticky(...) and .useStickyHeaders() and never touches the frame array.

each header  ──preference──►  ScrollView root  ──environment──►  each header
       │                                                                │
       │                                                                ▼
       │                                                    reads neighbours,
       │                                                    computes own offset
       ▼
   broadcasts its
   frame + flags

The preference key carries a dictionary keyed by Namespace.ID, so every header has a stable identity without the caller assigning IDs:

struct FramePreference: PreferenceKey {
    static var defaultValue: [Namespace.ID: StickyRect] = [:]

    static func reduce(value: inout Value, nextValue: () -> Value) {
        value.merge(nextValue()) { $1 }
    }
}

The reduce step is the quiet hero. As SwiftUI walks up the view tree, every header contributes its single-entry dictionary, and merge glues them all into one. By the time the dictionary reaches the root, it contains the live frame and config of every sticky header on screen.

The root then pushes the merged dictionary back down through Environment:

struct UseStickyHeaders: ViewModifier {
    static let container = "stickyContainer"
    @State var frames: [Namespace.ID: StickyRect] = [:]

    func body(content: Content) -> some View {
        content
            .onPreferenceChange(FramePreference.self) { newValue in
                DispatchQueue.main.async {
                    frames = newValue
                }
            }
            .coordinateSpace(name: UseStickyHeaders.container)
            .environment(\.stickyRects, frames)
    }
}

Two details here:

  1. The DispatchQueue.main.async hop is not cosmetic. Writing to @State from inside a preference callback during the same render pass triggers SwiftUI's "modifying state during view update" warning — and on iOS 17+, sometimes a crash. Punt the write to the next runloop tick.
  2. The coordinateSpace(name:) is what lets each header read its frame relative to the scroll container, not the screen.

Detecting scroll direction

Once a header has its own frame and the dictionary of everyone else's frame, direction comes almost for free:

.onChange(of: f) { newFrame in
    isScrollingUp = newFrame.minY > frame.minY
    scrollAmount = newFrame.minY - frame.minY
    frame = newFrame
}

If newFrame.minY is greater than the last frame's minY, the view moved down visually, which means the user scrolled up. That single boolean drives the hide/show animation for reverse headers.

No UIScrollView.contentOffset, no delegate, no GeometryReader chasing the scroll view itself. Each header just watches its own frame.

Pushing each other off

This is the bit I want to spend the most time on because it's where the Swift Talk formula starts to bend once you have more than one sticky layer.

In the episode, regular stickies sit at y = 0 and the push reads as:

if let other = stickyRects.first(where: {
    $0.minY > frame.minY && $0.minY < frame.height
}) {
    o -= frame.height - other.minY
}

The condition says another header has scrolled to within my own height of the container top — i.e., it's about to overlap me. The amount says push me up by however much of my height the next header has already eaten into. As the next header arrives at minY = 0, the push reaches frame.height and I'm fully gone.

That formula carries an invisible assumption: my sticky position is y = 0. The whole calculation is measured against the container top. The moment you put a row of fixed pinned headers above me — a year header, a month header, a filter bar — my actual sticky position is no longer the container top. It's fixedHeadersHeight below the container top. And then the formula fires far too late.

Concretely: if Feb and March are both pinned above the regular weeks, fixedHeadersHeight = 90. My weekly headers actually stick at y = 87. For the next week to push me out, its top needs to enter the band from y = 87 to y = 117 (top of my stick position to its bottom). The original condition other.minY < frame.height only fires below y = 30 — by which point the next header is already sitting on top of mine with no push having happened.

The fix is to read the formula in terms of my stick region, not the container's top:

let stickRegionBottom = fixedHeadersHeight + frame.height
if let other = stickyRects.first(where: { (key, value) in
    key != id
    && value.rect.minY > frame.minY
    && value.rect.minY < stickRegionBottom
}) {
    o -= stickRegionBottom - other.value.rect.minY
}

Same shape, just measured against where I actually live on screen. When fixedHeadersHeight = 0 this collapses to the original — single-layer scenes behave identically. With a fixed stack above, the next header's top stays glued to my bottom from the moment it enters my stick region until I'm fully pushed off.

Stacking fixed headers

For the fixed mode, the question is who came first. Two pinned headers both need to live at the top, but the second one needs to stick to the bottom of the first, not to zero. So isSticking becomes:

var isSticking: Bool {
    frame.minY < fixedHeadersHeight
}

And fixedHeadersHeight figures itself out from the live dictionary. The order is implied by which neighbours are currently sticking, not by the static view hierarchy — so a header can correctly slot under whatever's already pinned even when the view tree gives no ordering hints:

let previousFixedHeaders = stickyRects?
    .filter { item in
        guard item.key != id && item.value.isFixed else { return false }

        let selfMinY  = frame.minY
        let otherMinY = item.value.rect.minY

        if selfMinY < 0 && otherMinY < 0 {
            // both currently stuck: more negative minY = stuck longer = came first
            return otherMinY < selfMinY
        } else if otherMinY < 0 && selfMinY >= 0 {
            return true   // other is stuck, self isn't — other came first
        } else if selfMinY < 0 && otherMinY >= 0 {
            return false  // self is stuck, other isn't — self came first
        } else {
            return otherMinY < selfMinY  // neither stuck — compare natural positions
        }
    }

Sum the heights of those headers and that's the y-offset to slot in below them. The effect is a clean stack: each new fixed header arrives, the previous ones slide up and pin, the new one slots in underneath.

A subtle transitional phase

There's a window — between "this fixed header has hit its sticky position" and "this fixed header's geometry minY has crossed zero" — where a fixed header is being held in place by its offset but its raw frame is still positive. In that window the simple minY < 0 check would say "this header isn't sticking yet," and any regular sticky downstream computes its fixedHeadersHeight as if the fixed header doesn't exist. Visually, the regular sticky pops up behind the held fixed bar and disappears for a frame.

The fix is to count a fixed header as "occupying the top" whenever it has a non-zero currentOffset, not only when its geometry says it has been scrolled past:

.compactMap { header -> CGFloat? in
    if header.value.rect.minY < 0 || header.value.currentOffset != 0 {
        return header.value.rect.height
    } else {
        return nil
    }
}

Both checks are needed: minY < 0 covers the long-tail case once the geometry has caught up; currentOffset != 0 covers the transitional moment when the offset is doing the visual work and the geometry hasn't yet.

Z-index: the caller's job

There's one .zIndex(isSticking ? .infinity : 0) inside the modifier, but it only lifts every sticking header above the scrolling content. It doesn't order them relative to each other — all of them get .infinity, and view-tree declaration order ends up deciding who covers whom. Which is wrong: a regular weekly header declared after the fixed month header would render over it during its exit animation.

So z-ordering is the caller's responsibility, applied after .sticky(...):

filterBar
    .sticky(lastItemY: lastItemY, isRevers: true)
    .zIndex(150)

monthBar
    .sticky(lastItemY: lastItemY, isFixed: true)
    .zIndex(100)

weekHeader
    .sticky(lastItemY: lastItemY)
    .zIndex(50)

Reverse on top, fixed below, regular below that. With the push-off fix, the regular sticky slides under the fixed bars on its way out, the way iOS Calendar does it. Without the explicit .zIndex(...), it slides over them and the illusion breaks.

The reverse-bar pattern

The "filter bar that hides when you scroll up and slides back in when you scroll down" is everywhere on iOS — Mail's search bar, Safari's tab bar, every news app's nav. It's the gnarliest mode in the modifier:

if isRevers, let reversView {
    let minY   = frame.minY
    let height = frame.height
    guard minY < 0 else { return 0 }

    if isScrollingUp {
        if minY < -height && reversView.currentOffset == 0 {
            return -minY - height
        } else {
            let newOff = reversView.currentOffset + scrollAmount
            return max(min(-minY, newOff), -minY - height)
        }
    } else {
        let off = reversView.currentOffset + scrollAmount
        return max(off, 0)
    }
}

The key idea: the reverse header doesn't snap. It moves with the scroll amount, clamped to its own height. That gives you the rubbery, finger-tracking feel of native iOS bars instead of the binary jump you get with simpler implementations.

And because other headers can read the reverse header's currentOffset through the environment dictionary, fixed headers automatically push down or pull up in sync. When the reverse bar is fully hidden, the fixed headers below it slide up into the space it left behind.

What I'd change next

A few honest notes after iterating on this:

Why I keep coming back to this pattern

What I like most about the PreferenceKey + Environment loop isn't the sticky headers themselves — it's that the whole system is declarative. Each header says here's my frame, here's how I want to behave, and the resulting layout falls out of pure functions of that shared state. No callbacks, no ordering bugs, no stale references.

It's the kind of small system that, every time I revisit it, I'm surprised how little code it actually takes to do something this expressive in SwiftUI.


Code: Olya-Yer/sticky-headers-swiftui on GitHub — the modifier file, the calendar demo, an Xcode project ready to clone and run.