How to Implement Pull to Reveal in SwiftUI
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
-
ScrollOffsetKey — A custom
PreferenceKeywith aCGFloatvalue lets the childGeometryReaderbroadcast the scroll offset up through the view hierarchy without needing a binding or environment object. -
Zero-height sentinel — The
GeometryReaderis given.frame(height: 0)so it doesn't occupy any visual space. It reads itsminYin the named"scroll"coordinate space — when you overscroll, this value climbs above zero, giving you the pull distance. -
Hysteresis thresholds — Using two separate thresholds (
openThreshold = 60andcloseThreshold = 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. -
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. -
Spring animation — Wrapping the state toggle in
withAnimation(.spring(response:dampingFraction:))makes the banner entry feel physical; adjustdampingFractionlower (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
-
iOS 17 bouncing is on by default. Prior to iOS 16.4, scroll views didn't bounce on non-overflowing content. On iOS 17 the default changed — if your list is shorter than the screen, pulling still fires the offset change. Guard with
scrollBounceBehavior(.basedOnSize)if you only want pull-to-reveal on tall lists. -
Don't read geometry inside List.
Listwraps aUITableViewinternally and its coordinate space is not reliably named. UseLazyVStackinside a plainScrollViewfor this pattern, or you'll get erratic offset readings. -
Accessibility: hidden content must not be reachable until revealed. If the banner is conditionally included with
if revealed, VoiceOver automatically excludes it. Never use.opacity(0)to hide it — the view stays in the accessibility tree and confuses screen-reader users. - PreferenceKey reduce is called on every frame. Keep the reduce function trivial (a simple assignment). Avoid allocating objects or doing string formatting inside it, as it fires at display-link frequency during a scroll gesture.
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.