Skip to content

Instantly share code, notes, and snippets.

@JohnSundell
Last active January 2, 2025 18:40
Show Gist options
  • Save JohnSundell/341f5855f4ede71a7741e99881c74daf to your computer and use it in GitHub Desktop.
Save JohnSundell/341f5855f4ede71a7741e99881c74daf to your computer and use it in GitHub Desktop.
A content view which renders a collapsable header that adapts to the current scroll position. Based on OffsetObservingScrollView from https://swiftbysundell.com/articles/observing-swiftui-scrollview-content-offset.
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 {
content()
.background(GeometryReader { geometry in
Color.clear.preference(
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) {
PositionObservingView(
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)
content()
}
}
.overlay(alignment: .top) {
makeHeaderText(collapsed: true)
.background(alignment: .top) {
headerLinearGradient.ignoresSafeArea()
}
.opacity(collapsedHeaderOpacity)
}
.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:
headerLinearGradient
.frame(height: max(0, headerHeight.expanded - scrollOffset.y) + geometry.safeAreaInsets.top)
.ignoresSafeArea()
}
}
}
}
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 {
LinearGradient(
gradient: headerGradient,
startPoint: .top,
endPoint: .bottom
)
}
func makeHeaderText(collapsed: Bool) -> some View {
Text(title)
.font(collapsed ? .body : .title)
.lineLimit(1)
.padding()
.frame(height: collapsed ? headerHeight.collapsed : headerHeight.expanded)
.frame(maxWidth: .infinity)
.foregroundColor(.white)
.accessibilityHeading(.h1)
.accessibilityHidden(collapsed)
}
}
@Katkov
Copy link

Katkov commented Mar 20, 2023

Awesome example and very good article. Thanks John

@paulgessinger
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?

@masliukivskyi
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.

@seyoung-hyun
Copy link

@JohnSundell
Thanks your code :)
Can I use your code in my commercial app?

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