Last active
August 16, 2024 05:19
-
-
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.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | |
} | |
} |
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?
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
Awesome example and very good article. Thanks John