Last active
April 13, 2023 18:16
-
-
Save cjnevin/d295e04df769b976770b2c356d1094c3 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 Constants { | |
static let backgroundHeight: CGFloat = 500 | |
static let headerHeight: CGFloat = 150 | |
static let contentHeight: CGFloat = 350 | |
} | |
struct ColoredView: View { | |
let color: Color | |
var body: some View { | |
ZStack { | |
Rectangle().fill(color) | |
Text("Center") | |
} | |
} | |
} | |
struct Header: Identifiable { | |
let id: String | |
var items: [HeaderItem] | |
} | |
struct HeaderItem: Identifiable { | |
let id: String | |
let text: String | |
var selected: Bool | |
} | |
struct HeaderItemView: View { | |
@Binding var item: HeaderItem | |
let onTap: () -> Void | |
var body: some View { | |
Button(item.text) { | |
onTap() | |
} | |
.padding() | |
.background(item.selected ? .blue : .init(white: 0.1)) | |
.foregroundColor(item.selected ? .init(white: 0.1) : .blue) | |
.cornerRadius(10) | |
} | |
} | |
struct HeaderView: View { | |
@Binding var header: Header | |
var body: some View { | |
ScrollView(.horizontal) { | |
LazyHStack(spacing: 10) { | |
ForEach($header.items) { item in | |
HeaderItemView(item: item) { | |
var mutableHeader = header | |
for index in mutableHeader.items.indices { | |
mutableHeader.items[index].selected = mutableHeader.items[index].id == item.id | |
} | |
header = mutableHeader | |
} | |
} | |
} | |
} | |
.padding() | |
.background(Color.black) | |
} | |
} | |
struct PinModifier: ViewModifier { | |
enum Behaviour { | |
case below | |
case none | |
case above | |
} | |
struct Item { | |
let behaviour: Behaviour | |
let height: CGFloat | |
} | |
let index: Int | |
let items: [Item] | |
func opacity(reader: GeometryProxy) -> CGFloat { | |
guard items[index].behaviour == .below else { | |
return 1.0 | |
} | |
let scrollPosition = reader.frame(in: .global).minY | |
let y = items[0..<index].reduce(0) { $0 + $1.height } | |
let itemHeight = items[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) | |
} | |
return 1.0 | |
} | |
func offset(reader: GeometryProxy) -> CGFloat { | |
let scrollPosition = reader.frame(in: .global).minY | |
let absolute = abs(scrollPosition) | |
let y = items[0..<index].reduce(0) { $0 + $1.height } | |
switch items[index].behaviour { | |
case .below: | |
// Only change offset of current item | |
if scrollPosition < 0, absolute > y && absolute < y + items[index].height { | |
return absolute | |
} | |
return y | |
case .none: | |
return y | |
case .above: | |
if scrollPosition < 0, absolute > y { | |
return absolute | |
} | |
return y | |
} | |
} | |
func zIndex() -> Double { | |
switch items[index].behaviour { | |
case .below, .none: return Double(index) | |
case .above: return Double(index + 100) | |
} | |
} | |
func body(content: Content) -> some View { | |
GeometryReader { reader in | |
content | |
.opacity(opacity(reader: reader)) | |
.offset(y: offset(reader: reader)) | |
} | |
.zIndex(zIndex()) | |
.frame(height: items[index].height) | |
} | |
} | |
struct PinningScrollView: View { | |
struct BackgroundItem: Identifiable { | |
let id: String | |
let color: Color | |
} | |
struct ContentItem: Identifiable { | |
let id: String | |
let color: Color | |
} | |
enum Item: Identifiable { | |
case background(BackgroundItem) | |
case content(ContentItem) | |
case header(Header) | |
var id: String { | |
switch self { | |
case .background(let item): return item.id | |
case .content(let item): return item.id | |
case .header(let item): return item.id | |
} | |
} | |
var pinItem: PinModifier.Item { | |
switch self { | |
case .background: return .init(behaviour: .below, height: Constants.backgroundHeight) | |
case .content: return .init(behaviour: .none, height: Constants.contentHeight) | |
case .header: return .init(behaviour: .above, height: Constants.headerHeight) | |
} | |
} | |
} | |
@State var items: [Item] = [ | |
.background(.init(id: "1", color: .red)), | |
.background(.init(id: "2", color: .blue)), | |
.header(.init(id: "3", items: [ | |
.init(id: "1", text: "Item 1", selected: true), | |
.init(id: "2", text: "Item 2", selected: false), | |
.init(id: "3", text: "Item 3", selected: false), | |
.init(id: "4", text: "Item 4", selected: false), | |
])), | |
.content(.init(id: "4", color: .yellow)), | |
.background(.init(id: "6", color: .purple)), | |
.content(.init(id: "7", color: .pink)) | |
] | |
var body: some View { | |
ScrollView { | |
GeometryReader { reader in | |
ForEach(Array(items.enumerated()), id: \.element.id) { index, item in | |
ZStack { | |
switch item { | |
case .background(let backgroundItem): | |
ColoredView(color: backgroundItem.color) | |
case .content(let contentItem): | |
ColoredView(color: contentItem.color) | |
case .header(let header): | |
HeaderView(header: .init(get: { | |
header | |
}, set: { newHeader in | |
items[index] = .header(newHeader) | |
print(newHeader.items.map(\.selected)) | |
})) | |
} | |
} | |
.pin( | |
at: index, | |
in: items.map(\.pinItem) | |
) | |
} | |
} | |
.frame(height: items.map(\.pinItem.height).reduce(0, +)) | |
} | |
.scrollIndicators(.hidden) | |
.ignoresSafeArea() | |
.background(Color.black) | |
} | |
} | |
struct ContentView_Previews: PreviewProvider { | |
static var previews: some View { | |
PinningScrollView() | |
} | |
} | |
extension View { | |
func pin(at index: Int, in items: [PinModifier.Item]) -> some View { | |
modifier(PinModifier(index: index, items: items)) | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment