A SwiftUI header that collapses as the content below it scrolls. Zero dependencies, SwiftUI and UIKit.
- Put your header and content into a
CollapsibleHeaderScrollView. - Add the modifiers you need, see below.
struct ContentView: View {
var body: some View {
CollapsibleHeaderScrollView { context in
MyHeader(progress: context.progress)
} content: {
LazyVStack {
ForEach(items) { Row($0) }
}
}
.height(min: 64, max: 240)
}
}The header closure receives a CollapseContext on every frame of the collapse
and is sized for you — you do not need to apply .frame(height:) yourself.
This component owns the scrolling, so pass plain content — a VStack, a
LazyVStack, whatever — never a ScrollView, List or TabView.
That is not an arbitrary restriction. The header and the scroll view size each other: collapsing the header makes the scroll view taller, which reduces the scroll distance driving the collapse. The component measures your real content, solves for the scroll it needs, and pads the bottom so the collapse can always finish. Hand a consumer only the header and that becomes their problem. The equation is in DESIGN.md.
Because it is a real ScrollView underneath, .refreshable, sheets and
notifications all behave normally.
header — @ViewBuilder receiving a CollapseContext
content — @ViewBuilder for your content
set the collapsed and expanded header heights, default min = 64 and max = 240
.height(min: CGFloat, max: CGFloat)set every tuning value at once
.metrics(CollapseMetrics(minHeaderHeight: 64, maxHeaderHeight: 240))tune how the collapse is paced — startOffset is the dead zone before the header
reacts, distance is how far you scroll to finish it, slack is extra padding
certainty
.collapseTuning(startOffset: 120, distance: 200, minDistance: 120, slack: 64)adopt an existing CollapsibleHeaderLayout type
.layout(MyHeaderLayout())passes current collapse progress into a binding: 0 for not collapsed at all, 1 for fully collapsed
.collapseProgress(_ progress: Binding<CGFloat>)passes the current scroll offset in points into a binding
.scrollOffset(_ offset: Binding<CGFloat>)callback for the scroll view reaching the bottom; threshold fires it early, which is what you want for paging
.scrollViewDidReachBottom(threshold: CGFloat = 0, perform: @escaping () -> Void)pull-to-refresh; if not set, no refresh indicator is ever shown
.pullToRefresh(isActive: Bool = true, perform: () async -> Void)pull-to-load-more, past the bottom edge (iOS only)
.pullToLoadMore(isActive: Bool = true, threshold: CGFloat = 120, perform: () async -> Void)scroll back to the top; set the binding to true, it resets itself
.scrollToTop(resetScroll: Binding<Bool>)a background for the scroll area only, drawn behind the content and above the component's own background
.scrollBackground { Color.white }round the scroll area's corners. Do this rather than styling the background view — the parameter clips the content too, where rounding only the background leaves content visible in the corners
.scrollCorners(radius: 24, corners: .top)hide the scroll indicators (hidden by default)
.hideScrollIndicators(_ hide: Bool = true)where the header sits inside its own height as it shrinks, default .top
.headerAlignment(_ alignment: Alignment)clip the header to its current height, default true. Turn it off to let a header draw outside its bounds
.headerIsClipped(_ isClipped: Bool = true)let the header grow past max when the scroll is pulled beyond the top; context.stretch reports the overscroll in points
.allowsHeaderStretch(_ allows: Bool = true)snap the header to the nearest resting position instead of leaving it half collapsed (iOS only). Modes are .disabled (default), .immediately — once you lift your finger — and .afterDeceleration
.snapMode(.immediately)the positions snapping is allowed to choose, in 0...1. Default [0, 1]
.headerSnappingPositions([0, 0.5, 1])To move something from a position in the expanded header to a position in the collapsed bar, mark both ends. Both are measured in one coordinate space, so interpolating between them is meaningful.
Color.clear.frame(width: 240, height: 52).collapseAnchor(.expanded)
Color.clear.frame(width: 170, height: 30).collapseAnchor(.collapsed)
// then, in your header:
context.interpolatedAnchorOrigin // leading-edge travel
context.interpolatedAnchorCenter // centre-to-centre travel
context.lerp(from, to) // any other value
context.hasAnchors // both measured yet?Keep both anchors stationary. The header's height shrinks as it collapses, so anything laid out relative to its bottom moves while you drag — which moves the interpolation endpoints and republishes the anchor on nearly every frame. Pin each end inside a fixed-height, top-aligned layer and let the header clip.
| Member | Meaning |
|---|---|
progress |
0 expanded → 1 collapsed |
headerHeight |
the height the header is being given |
stretch |
points pulled past the top, with .allowsHeaderStretch() |
isExpanded / isCollapsed |
the endpoints |
lerp(_:_:) |
interpolate any pair of values by progress |
expandedAnchor / collapsedAnchor |
measured anchor rects |
interpolatedAnchorOrigin / interpolatedAnchorCenter |
the travel |
context.progress already tracks the finger. Easing anything derived from it
opens a feedback loop: the header keeps moving after the finger stops, which
changes the viewport, which re-clamps the offset, which produces a new
offset — ringing. Animate discrete threshold flips instead:
private var isCompact: Bool { context.progress > 0.6 }
Text("Explore")
.opacity(isCompact ? 1 : 0)
.animation(.easeInOut(duration: 0.2), value: isCompact) // a Bool, not progressCollapsibleHeaderController is a UIViewController container. Give it a header
view and a content view, and update the header from onCollapse.
let controller = CollapsibleHeaderController(
headerView: MyHeaderView(),
contentView: MyContentView()
)
controller.metrics = CollapseMetrics(minHeaderHeight: 64, maxHeaderHeight: 220)
controller.scrollCornerRadius = 26
controller.scrollCorners = .top
controller.onCollapse = { [weak header] context in
header?.titleLabel.alpha = 1 - context.progress
}The content view sizes itself with Auto Layout and — same rule as SwiftUI — must
not be a scroll view. snapMode, onReachBottom, onRefresh, onLoadMore and
scrollToTop() are all available. There is also init(header:content:) taking
two child view controllers.
To go the other way and put a SwiftUI header into UIKit, or embed the whole
thing, use UIHostingController as normal.
To try the CollapsibleHeader examples:
- Open
Examples/CollapsibleHeaderDemo/CollapsibleHeaderDemo.xcodeprojin Xcode - Try it!
Four screens from a travel app: a destination name travelling into the collapsed bar, a large title over a pinned search field, a stepper for the short-content path, and the UIKit controller.
dependencies: [
.package(url: "https://github.com/RavanSA/CollapsibleHeader.git", from: "1.0.0")
]Or in Xcode: File → Add Package Dependencies… and paste the URL.
- iOS 17+ / macOS 14+
- Swift 5.10, Xcode 15+
- No dependencies
Snapping and pull-to-load-more need UIKit and are no-ops on macOS. Everything else, including the collapse itself, is pure SwiftUI.
swift test
27 tests covering the layout arithmetic, the driver's progress mapping and the
context's interpolation. They run on macOS — no simulator needed. The example
project additionally carries a UI test that drives real drags on a simulator,
for the composition and physics swift test cannot reach.
fastlane ci
| Lane | What it does |
|---|---|
unit_tests |
swift test on macOS, no simulator |
build_ios |
Builds for iOS, which compiles the UIKit paths macOS skips |
build_example |
Builds the example app |
ui_tests |
The example's UI tests on a simulator — the collapse itself |
ci |
All four |
release |
fastlane release version:1.0.0 — checks the tree, runs the tests, tags and publishes |
Pick a simulator with CH_DEVICE="iPhone 16" fastlane ui_tests. The lanes use
the system fastlane; a Gemfile is included if you would rather pin it with
bundle exec.
DESIGN.md has the equation the component is built on and the list of
load-bearing code that looks wrong but isn't. Read it before changing
CollapsibleHeaderScrollView or CollapsibleHeaderKeys.




