How to Implement Pull to Reveal in SwiftUI

iOS 17+ Xcode 16+ Intermediate APIs: ScrollView / GeometryReader Updated: May 11, 2026
TL;DR

Embed a zero-height GeometryReader at the top of your ScrollView content, publish its minY through a PreferenceKey, then toggle a @State flag inside onPreferenceChange when the offset exceeds your reveal threshold.

struct PullToRevealView: View {
    @State private var revealed = false

    var body: some View {
        ScrollView {
            GeometryReader { proxy in
                Color.clear.preference(
                    key: ScrollOffsetKey.self,
                    value: proxy.frame(in: .named("scroll")).minY
                )
            }
            .frame(height: 0)
            if revealed {
                RevealedBanner()
                    .transition(.move(edge: .top).combined(with: .opacity))
            }
            MainContent()
        }
        .coordinateSpace(name: "scroll")
        .onPreferenceChange(ScrollOffsetKey.self) { y in
            withAnimation(.spring(duration: 0.35)) {
                revealed = y > 60
            }
        }
    }
}

Full implementation

The approach is three-layered: a PreferenceKey broadcasts the scroll offset up the view tree, a zero-height GeometryReader sentinel measures it inside the named coordinate space of the outer ScrollView, and onPreferenceChange reacts to changes by toggling a flag with a spring animation. The revealed content is inserted above the main list using a conditional view with a .transition so it slides and fades in smoothly. A second threshold keeps the panel open until the user scrolls back past zero, preventing a flicker at the boundary.

import SwiftUI

// MARK: – PreferenceKey

private struct ScrollOffsetKey: PreferenceKey {
    static let defaultValue: CGFloat = 0
    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
        value = nextValue()
    }
}

// MARK: – Revealed Banner

private struct RevealedBanner: View {
    var body: some View {
        HStack(spacing: 12) {
            Image(systemName: "bell.badge.fill")
                .foregroundStyle(.orange)
                .font(.title2)
            VStack(alignment: .leading, spacing: 2) {
                Text("Notifications")
                    .font(.headline)
                Text("3 unread alerts")
                    .font(.subheadline)
                    .foregroundStyle(.secondary)
            }
            Spacer()
            Button("View all") { }
                .buttonStyle(.bordered)
                .controlSize(.small)
        }
        .padding()
        .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16))
        .padding(.horizontal)
        .padding(.top, 8)
        .accessibilityElement(children: .combine)
        .accessibilityLabel("Notifications panel. 3 unread alerts.")
    }
}

// MARK: – Main View

struct PullToRevealView: View {
    @State private var revealed = false

    private let openThreshold: CGFloat  = 60
    private let closeThreshold: CGFloat = 10

    var body: some View {
        NavigationStack {
            ScrollView {
                // Zero-height sentinel — must be first child
                GeometryReader { proxy in
                    Color.clear.preference(
                        key: ScrollOffsetKey.self,
                        value: proxy.frame(in: .named("scroll")).minY
                    )
                }
                .frame(height: 0)

                LazyVStack(spacing: 0) {
                    // Revealed panel slides in above main content
                    if revealed {
                        RevealedBanner()
                            .transition(
                                .asymmetric(
                                    insertion: .move(edge: .top).combined(with: .opacity),
                                    removal:   .move(edge: .top).combined(with: .opacity)
                                )
                            )
                            .padding(.bottom, 8)
                    }

                    ForEach(1...30, id: \.self) { index in
                        HStack {
                            Image(systemName: "doc.text")
                                .foregroundStyle(.secondary)
                                .frame(width: 36)
                            Text("Row \(index)")
                                .frame(maxWidth: .infinity, alignment: .leading)
                        }
                        .padding()
                        .background(Color(.systemBackground))
                        Divider().padding(.leading, 52)
                    }
                }
            }
            .coordinateSpace(name: "scroll")
            .onPreferenceChange(ScrollOffsetKey.self) { offset in
                withAnimation(.spring(response: 0.4, dampingFraction: 0.8)) {
                    if !revealed, offset > openThreshold {
                        revealed = true
                    } else if revealed, offset < closeThreshold {
                        revealed = false
                    }
                }
            }
            .navigationTitle("Pull to Reveal")
            .navigationBarTitleDisplayMode(.inline)
        }
    }
}

// MARK: – Preview

#Preview {
    PullToRevealView()
}

How it works

  1. ScrollOffsetKey — A custom PreferenceKey with a CGFloat value lets the child GeometryReader broadcast the scroll offset up through the view hierarchy without needing a binding or environment object.
  2. Zero-height sentinel — The GeometryReader is given .frame(height: 0) so it doesn't occupy any visual space. It reads its minY in the named "scroll" coordinate space — when you overscroll, this value climbs above zero, giving you the pull distance.
  3. Hysteresis thresholds — Using two separate thresholds (openThreshold = 60 and closeThreshold = 10) prevents the panel from flickering on and off at the boundary. The panel opens when offset exceeds 60 pt and only closes when you scroll back past 10 pt.
  4. Asymmetric transition.asymmetric(insertion:removal:) lets the panel animate in from the top edge on appearance and exit back up on dismissal, reinforcing the physical metaphor of pulling something out from behind the navigation bar.
  5. Spring animation — Wrapping the state toggle in withAnimation(.spring(response:dampingFraction:)) makes the banner entry feel physical; adjust dampingFraction lower (e.g. 0.6) for a bouncier feel.

Variants

Sticky progress indicator while pulling

Show a live pull-progress arc that fills as the user drags, snapping to fully revealed at the threshold. Clamp the offset to a 0–1 fraction for the gauge.

struct PullProgressIndicator: View {
    var progress: Double  // 0.0 → 1.0

    var body: some View {
        ZStack {
            Circle()
                .stroke(Color.secondary.opacity(0.2), lineWidth: 3)
            Circle()
                .trim(from: 0, to: progress)
                .stroke(Color.accentColor, style: StrokeStyle(lineWidth: 3, lineCap: .round))
                .rotationEffect(.degrees(-90))
                .animation(.linear(duration: 0.05), value: progress)
        }
        .frame(width: 28, height: 28)
        .padding(.top, 12)
        .opacity(progress > 0.05 ? 1 : 0)
    }
}

// In the parent ScrollView, inject above the sentinel:
// let fraction = min(max(offset / openThreshold, 0), 1)
// PullProgressIndicator(progress: fraction)

Pull to reveal a floating search bar

Instead of a banner, reveal a TextField wrapped in a .searchable-style container. Pass @FocusState into the revealed view and set it to true inside a .onChange(of: revealed) modifier so the keyboard opens automatically when the panel appears — a common pattern in social feed apps.

Common pitfalls

Prompt this with Claude Code

When using Soarias or Claude Code directly to implement this:

Implement pull to reveal in SwiftUI for iOS 17+.
Use ScrollView/GeometryReader with a named coordinateSpace
and a PreferenceKey to track scroll offset.
Reveal a banner view when offset exceeds 60 pt,
hide it when it falls below 10 pt (hysteresis).
Make it accessible (VoiceOver labels on the revealed panel).
Add a #Preview with realistic sample data.

During the Build phase in Soarias, paste this prompt into the Claude Code panel after your screen scaffold is generated — it drops the pull-to-reveal feature into the correct scroll context without touching your navigation or data layers.

Related

FAQ

Does this work on iOS 16?

The PreferenceKey + GeometryReader scroll-offset technique works back to iOS 14. The only iOS 17-specific call here is scrollBounceBehavior(_:) mentioned in Pitfalls — simply omit that modifier on iOS 16. The #Preview macro requires Xcode 15+, but you can swap it for a PreviewProvider if targeting Xcode 14.

How do I prevent the pull-to-reveal from conflicting with pull-to-refresh?

Don't use .refreshable on the same ScrollView. SwiftUI's pull-to-refresh consumes the overscroll gesture before your PreferenceKey can read a meaningful positive offset — both features compete for the same gesture space. Choose one or the other, or place the refresh control in a different interaction surface (e.g. a toolbar button).

What's the UIKit equivalent?

In UIKit you'd subclass UIScrollViewDelegate and implement scrollViewDidScroll(_:), reading scrollView.contentOffset.y to detect overscroll. The reveal view would sit above the UITableView's contentInset, with its height animated via UIView.animate. The SwiftUI approach via PreferenceKey is roughly equivalent in complexity but keeps everything declarative and avoids manual frame management.

Last reviewed: 2026-05-11 by the Soarias team.