Created
April 13, 2023 21:01
-
-
Save cjnevin/b4f4096a30dd32545a3bfa495b11a3ea to your computer and use it in GitHub Desktop.
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 | |
enum Behaviour { | |
case pinnedBelow | |
case normal | |
case pinnedAbove | |
} | |
struct NonStickyView<Content: View>: View { | |
let id: String | |
let height: CGFloat | |
@ViewBuilder var content: () -> Content | |
var body: some View { | |
content() | |
.frame(height: height) | |
} | |
} | |
struct StickyBackgroundView<Content: View>: View { | |
let id: String | |
let height: CGFloat | |
@ViewBuilder var content: () -> Content | |
var body: some View { | |
content() | |
.frame(height: height) | |
} | |
} | |
struct StickyHeaderView<Content: View>: View { | |
let id: String | |
let height: CGFloat | |
@ViewBuilder var content: () -> Content | |
var body: some View { | |
content() | |
.frame(height: height) | |
} | |
} | |
struct LayeredViewConfig { | |
let id: String | |
let behaviour: Behaviour | |
let height: CGFloat | |
let view: AnyView | |
} | |
struct LayeredScrollView: View { | |
@State var offset: CGPoint = .zero | |
@State var configs: [LayeredViewConfig] | |
private func calculateOpacity(at index: Int) -> CGFloat { | |
guard configs[index].behaviour == .pinnedBelow else { | |
return 1.0 | |
} | |
let scrollPosition = offset.y | |
let y = configs[0..<index].reduce(0) { $0 + $1.height } | |
let itemHeight = configs[index].height | |
let absolute = abs(scrollPosition) | |
// Only fade current item | |
if scrollPosition > 0, absolute > y && absolute < y + itemHeight { | |
let relativeOffset = absolute - y | |
return 1.0 - (relativeOffset / itemHeight) | |
} | |
if absolute >= y + itemHeight { | |
return 0.0 | |
} | |
return 1.0 | |
} | |
private func calculateOffset(at index: Int) -> CGFloat { | |
let scrollPosition = offset.y | |
let absolute = abs(scrollPosition) | |
let y = configs[0..<index].reduce(0) { $0 + $1.height } | |
switch configs[index].behaviour { | |
case .pinnedBelow: | |
// Only change offset of current item | |
if scrollPosition > 0, absolute > y && absolute < y + configs[index].height { | |
return absolute | |
} | |
return y | |
case .normal: | |
return y | |
case .pinnedAbove: | |
if scrollPosition > 0, absolute > y { | |
return absolute | |
} | |
return y | |
} | |
} | |
var body: some View { | |
OffsetObservingScrollView(offset: $offset) { | |
GeometryReader { _ in | |
ForEach(Array(configs.enumerated()), id: \.element.id) { index, config in | |
config.view | |
.id(config.id) | |
.zIndex(Double(index + (config.behaviour == .pinnedAbove ? 100 : 0))) | |
.offset(y: calculateOffset(at: index)) | |
.opacity(calculateOpacity(at: index)) | |
.frame(height: config.height) | |
} | |
} | |
.frame(height: configs.reduce(0, { value, config in value + config.height })) | |
} | |
.scrollIndicators(.hidden) | |
} | |
} | |
struct LayeredView<Content: View>: View { | |
@LayeredViewBuilder var content: () -> Content | |
var body: some View { | |
content() | |
} | |
} | |
@resultBuilder | |
struct LayeredViewBuilder { | |
static func buildBlock(_ components: [LayeredViewConfig]...) -> [LayeredViewConfig] { | |
components.flatMap { $0 } | |
} | |
static func buildArray(_ components: [[LayeredViewConfig]]) -> [LayeredViewConfig] { | |
components.flatMap { $0 } | |
} | |
static func buildExpression<Content: View>(_ expression: NonStickyView<Content>) -> [LayeredViewConfig] { | |
[ | |
.init(id: expression.id, behaviour: .normal, height: expression.height, view: AnyView(expression)) | |
] | |
} | |
static func buildExpression<Content: View>(_ expression: StickyBackgroundView<Content>) -> [LayeredViewConfig] { | |
[ | |
.init(id: expression.id, behaviour: .pinnedBelow, height: expression.height, view: AnyView(expression)) | |
] | |
} | |
static func buildExpression<Content: View>(_ expression: StickyHeaderView<Content>) -> [LayeredViewConfig] { | |
[ | |
.init(id: expression.id, behaviour: .pinnedAbove, height: expression.height, view: AnyView(expression)) | |
] | |
} | |
static func buildFinalResult(_ component: [LayeredViewConfig]) -> some View { | |
LayeredScrollView(configs: component) | |
} | |
} | |
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 { | |
struct PreferenceKey: SwiftUI.PreferenceKey { | |
static var defaultValue: CGPoint { .zero } | |
static func reduce(value: inout CGPoint, nextValue: () -> CGPoint) { | |
// No-op | |
} | |
} | |
} | |
struct OffsetObservingScrollView<Content: View>: View { | |
var axes: Axis.Set = [.vertical] | |
var showsIndicators = true | |
@Binding var offset: CGPoint | |
@ViewBuilder var content: () -> Content | |
// The name of our coordinate space doesn't have to be | |
// stable between view updates (it just needs to be | |
// consistent within this view), so we'll simply use a | |
// plain UUID for it: | |
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) | |
} | |
} | |
struct ContentView: View { | |
@State var offset: CGPoint = .zero | |
var body: some View { | |
NavigationView { | |
LayeredView { | |
StickyBackgroundView(id: "sb1", height: 500) { | |
ZStack { | |
Rectangle().fill(.green) | |
Text("Background") | |
} | |
} | |
StickyBackgroundView(id: "sb2", height: 500) { | |
ZStack { | |
Rectangle().fill(.blue) | |
Text("Background") | |
} | |
} | |
StickyHeaderView(id: "sh1", height: 80) { | |
ZStack { | |
Rectangle().fill(.red) | |
Text("Header") | |
} | |
} | |
StickyBackgroundView(id: "sb3", height: 350) { | |
ZStack { | |
Rectangle().fill(.yellow) | |
Text("Background") | |
} | |
} | |
NonStickyView(id: "ns1", height: 350) { | |
ZStack { | |
Rectangle().fill(.brown) | |
Text("Content") | |
} | |
} | |
StickyHeaderView(id: "sh2", height: 80) { | |
ZStack { | |
Rectangle().fill(.purple) | |
Text("Header") | |
} | |
} | |
NonStickyView(id: "ns2", height: 700) { | |
ZStack { | |
Rectangle().fill(.indigo) | |
Text("Content") | |
} | |
} | |
} | |
.background(Color.black) | |
.navigationTitle("Test") | |
.navigationBarTitleDisplayMode(.inline) | |
} | |
} | |
} | |
struct ContentView_Previews: PreviewProvider { | |
static var previews: some View { | |
ContentView() | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment