Skip to content

Instantly share code, notes, and snippets.

Last active August 21, 2023 22:53
  • Star 23 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
Star You must be signed in to star a gist
What would you like to do?
A content view which renders a collapsable header that adapts to the current scroll position. Based on OffsetObservingScrollView from
import SwiftUI
/// View that observes its position within a given coordinate space,
/// and assigns that position to the specified Binding.
struct PositionObservingView<Content: View>: View {
var coordinateSpace: CoordinateSpace
@Binding var position: CGPoint
@ViewBuilder var content: () -> Content
var body: some View {
.background(GeometryReader { geometry in
key: PreferenceKey.self,
value: geometry.frame(in: coordinateSpace).origin
.onPreferenceChange(PreferenceKey.self) { position in
self.position = position
private extension PositionObservingView {
enum PreferenceKey: SwiftUI.PreferenceKey {
static var defaultValue: CGPoint { .zero }
static func reduce(value: inout CGPoint, nextValue: () -> CGPoint) {
// No-op
/// Specialized scroll view that observes its content offset (scroll position)
/// and assigns it to the specified Binding.
struct OffsetObservingScrollView<Content: View>: View {
var axes: Axis.Set = [.vertical]
var showsIndicators = true
@Binding var offset: CGPoint
@ViewBuilder var content: () -> Content
private let coordinateSpaceName = UUID()
var body: some View {
ScrollView(axes, showsIndicators: showsIndicators) {
coordinateSpace: .named(coordinateSpaceName),
position: Binding(
get: { offset },
set: { newOffset in
offset = CGPoint(
x: -newOffset.x,
y: -newOffset.y
content: content
.coordinateSpace(name: coordinateSpaceName)
/// View that renders scrollable content beneath a header that
/// automatically collapses when the user scrolls down.
struct ContentView<Content: View>: View {
var title: String
var headerGradient: Gradient
@ViewBuilder var content: () -> Content
private let headerHeight = (collapsed: 50.0, expanded: 150.0)
@State private var scrollOffset = CGPoint()
var body: some View {
GeometryReader { geometry in
OffsetObservingScrollView(offset: $scrollOffset) {
VStack(spacing: 0) {
makeHeaderText(collapsed: false)
.overlay(alignment: .top) {
makeHeaderText(collapsed: true)
.background(alignment: .top) {
.background(alignment: .top) {
// We attach the expanded header's background to the scroll
// view itself, so that we can make it expand into both the
// safe area, as well as any negative scroll offset area:
.frame(height: max(0, headerHeight.expanded - scrollOffset.y) +
private extension ContentView {
var collapsedHeaderOpacity: CGFloat {
let minOpacityOffset = headerHeight.expanded / 2
let maxOpacityOffset = headerHeight.expanded - headerHeight.collapsed
guard scrollOffset.y > minOpacityOffset else { return 0 }
guard scrollOffset.y < maxOpacityOffset else { return 1 }
let opacityOffsetRange = maxOpacityOffset - minOpacityOffset
return (scrollOffset.y - minOpacityOffset) / opacityOffsetRange
var headerLinearGradient: LinearGradient {
gradient: headerGradient,
startPoint: .top,
endPoint: .bottom
func makeHeaderText(collapsed: Bool) -> some View {
.font(collapsed ? .body : .title)
.frame(height: collapsed ? headerHeight.collapsed : headerHeight.expanded)
.frame(maxWidth: .infinity)
Copy link

Katkov commented Mar 20, 2023

Awesome example and very good article. Thanks John

Copy link

Thanks for this!
I tried this out, and it makes the whole scroll view jittery in my testing. I'm guessing that's because it needs to push a lot of state changes. Have you observed this too by any chance?

Copy link

Same issue as @paulgessinger mentioned. This solution re-evaluates body upon offset changes, the only way to fix that is to change PositionObservingView so it receives an already built content, not the builder.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment