One of my favorite UI patterns in modern iOS apps is the collapsible header — the kind that smoothly shrinks, fades, and moves as you scroll. It makes an app feel dynamic without being distracting.
After implementing multiple versions across my projects, here’s a clean and reusable way to build it in SwiftUI.
🎯 What We'll Build
A header that:
- Stays large at the top
- Shrinks smoothly as you scroll
- Moves upward and slightly fades
- Expands back when you scroll down
No hacks, no UIKit bridging — just pure SwiftUI.
🧠 Key Idea
We track the scroll offset using a GeometryReader and use that value to compute:
- height compression
- opacity
- vertical offset
- optional scaling
This gives us a lightweight, fully reactive header animation.
📦 Collapsible Header Component
struct CollapsibleHeader<Content: View>: View {
let height: CGFloat
@ViewBuilder var content: () -> Content
@Binding var scrollOffset: CGFloat
var body: some View {
let progress = max(0, min(1, 1 - scrollOffset / 120))
content()
.frame(height: height * progress)
.opacity(progress)
.offset(y: -scrollOffset * 0.4)
.animation(.easeOut(duration: 0.2), value: progress)
}
}
struct ExampleView: View {
@State private var scrollOffset: CGFloat = 0
var body: some View {
ScrollView {
GeometryReader { geo in
Color.clear
.preference(
key: OffsetKey.self,
value: geo.frame(in: .named("scroll")).minY
)
}
.frame(height: 0)
VStack(spacing: 16) {
ForEach(0..<30) { i in
Text("Row \(i)")
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(.ultraThinMaterial)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}
.padding()
}
.coordinateSpace(name: "scroll")
.onPreferenceChange(OffsetKey.self) { value in
scrollOffset = value
}
.overlay(alignment: .top) {
CollapsibleHeader(height: 140, scrollOffset: $scrollOffset) {
VStack {
Text("Dashboard")
.font(.largeTitle.bold())
Text("Smooth collapsible header")
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity)
}
}
}
}
struct OffsetKey: PreferenceKey {
static var defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
}
✔️ Final Result
You now have a reusable, modern collapsible header that feels native and smooth — perfect for:
- dashboards
- profile screens
- lists
- content-heavy apps
It’s clean, lightweight, and uses only pure SwiftUI.
Top comments (0)