Skip to content

Instantly share code, notes, and snippets.

@cjnevin
Created April 13, 2023 21:01
Show Gist options
  • Save cjnevin/b4f4096a30dd32545a3bfa495b11a3ea to your computer and use it in GitHub Desktop.
Save cjnevin/b4f4096a30dd32545a3bfa495b11a3ea to your computer and use it in GitHub Desktop.
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