Skip to content

Repository files navigation

CollapsibleHeader

A SwiftUI header that collapses as the content below it scrolls. Zero dependencies, SwiftUI and UIKit.

CI License: MIT

Usage

  1. Put your header and content into a CollapsibleHeaderScrollView.
  2. 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.

Content is not a ScrollView

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.

Required parameters

header@ViewBuilder receiving a CollapseContext content@ViewBuilder for your content

Available modifiers, optional

Sizing

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())

Observing

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)

Actions

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>)

Appearance

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)

Behaviour

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])

A travelling element

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.

CollapseContext

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

Never animate progress

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 progress

UIKit

CollapsibleHeaderController 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.

Examples

To try the CollapsibleHeader examples:

  • Open Examples/CollapsibleHeaderDemo/CollapsibleHeaderDemo.xcodeproj in 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.

Every demo screen, expanded and collapsed

Installation

dependencies: [
    .package(url: "https://github.com/RavanSA/CollapsibleHeader.git", from: "1.0.0")
]

Or in Xcode: File → Add Package Dependencies… and paste the URL.

Requirements

  • 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.

Tests

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

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 notes

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.

About

A SwiftUI header that collapses as the content below it scrolls. Zero dependencies, SwiftUI and UIKit. iOS 17+ / macOS 14+.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages