Here's a full code example of an attempt at a double sticky header view:
struct ContentView: View {
var body: some View {
ScrollView(showsIndicators: false) {
LazyVStack(spacing: 0, pinnedViews: .sectionHeaders) {
Section {
SecondView()
} header: {
HeaderView()
}
}
}
}
}
struct HeaderView: View {
var body: some View {
Rectangle()
.fill(.green)
.frame(height: 50)
}
}
struct SecondView: View {
var body: some View {
LazyVStack(spacing: 0, pinnedViews: .sectionHeaders) {
Section {
VStack {
ForEach(0..<20) { index in
ItemView(index: index)
}
}
} header: {
SecondHeaderView()
}
}
}
}
struct SecondHeaderView: View {
var body: some View {
Rectangle()
.fill(.red)
.frame(height: 60)
}
}
struct ItemView: View {
let index: Int
var body: some View {
VStack {
Text("Item \(index)")
.padding()
}
.frame(height: 80)
.background(.gray)
}
}
#Preview {
ContentView()
}
Note that initially, the red SecondHeaderView
is correctly positioned below the green HeaderView
. However, when scrolling, the red header scrolls beneath the green header until it sticks at the same y position as the green header.
I need the red header to stay anchored below the green header. Note that SecondView
may not always be embedded in a ContentView
. It could live by itself and still need its own single sticky header to work, so I think both views need the LazyVStack
.
I've messed with various GeometryReader view offsets, trying to get the red header to position correctly but I can't get it right.
Here's a full code example of an attempt at a double sticky header view:
struct ContentView: View {
var body: some View {
ScrollView(showsIndicators: false) {
LazyVStack(spacing: 0, pinnedViews: .sectionHeaders) {
Section {
SecondView()
} header: {
HeaderView()
}
}
}
}
}
struct HeaderView: View {
var body: some View {
Rectangle()
.fill(.green)
.frame(height: 50)
}
}
struct SecondView: View {
var body: some View {
LazyVStack(spacing: 0, pinnedViews: .sectionHeaders) {
Section {
VStack {
ForEach(0..<20) { index in
ItemView(index: index)
}
}
} header: {
SecondHeaderView()
}
}
}
}
struct SecondHeaderView: View {
var body: some View {
Rectangle()
.fill(.red)
.frame(height: 60)
}
}
struct ItemView: View {
let index: Int
var body: some View {
VStack {
Text("Item \(index)")
.padding()
}
.frame(height: 80)
.background(.gray)
}
}
#Preview {
ContentView()
}
Note that initially, the red SecondHeaderView
is correctly positioned below the green HeaderView
. However, when scrolling, the red header scrolls beneath the green header until it sticks at the same y position as the green header.
I need the red header to stay anchored below the green header. Note that SecondView
may not always be embedded in a ContentView
. It could live by itself and still need its own single sticky header to work, so I think both views need the LazyVStack
.
I've messed with various GeometryReader view offsets, trying to get the red header to position correctly but I can't get it right.
Share Improve this question asked Feb 7 at 18:04 soleilsoleil 13.1k33 gold badges117 silver badges194 bronze badges1 Answer
Reset to default 1Here are two possible ways to solve
1. Combine the headers together
An easy way to solve is to combine the two headers together. If it is possible that SecondView
may be shown in isolation then you can pass a flag, to indicate whether the header should be shown or not:
// ContentView
ScrollView(showsIndicators: false) {
LazyVStack(spacing: 0, pinnedViews: .sectionHeaders) {
Section {
SecondView()
} header: {
VStack(spacing: 0) {
HeaderView()
SecondHeaderView()
}
}
}
}
struct SecondView: View {
var showHeader = false
var body: some View {
LazyVStack(spacing: 0, pinnedViews: .sectionHeaders) {
Section {
// ...
} header: {
if showHeader {
SecondHeaderView()
}
}
}
}
}
To show SecondView
in isolation:
// ContentView
ScrollView(showsIndicators: false) {
SecondView(showHeader: true)
}
2. Apply padding to the second header
Alternatively, if it can be assumed that the second header will be in the correct position when initially shown then this position can be measured in .onAppear
. Then, to keep it in the same position, top padding can be applied to compensate for any scroll movement.
The same padding must be applied as negative top padding to the scrolled content, otherwise the content doesn't move until the drag movement reaches the height of the first header.
I found that the header was making tiny movements when scrolling was happening, which was causing errors in the console about "action tried to update multiple times per frame". These errors can be prevented by checking that the adjustment differs from the previous amount by a threshold amount, 0.1 works fine.
struct SecondView: View {
@State private var initialOffset = CGFloat.zero
@State private var topPadding = CGFloat.zero
var body: some View {
LazyVStack(spacing: 0, pinnedViews: .sectionHeaders) {
Section {
VStack {
// ...
}
.padding(.top, -topPadding)
} header: {
SecondHeaderView()
.padding(.top, topPadding)
.background {
GeometryReader { proxy in
let minY = proxy.frame(in: .scrollView).minY
Color.clear
.onAppear {
initialOffset = minY
}
.onChange(of: minY) { oldVal, newVal in
let adjustment = max(0, initialOffset - newVal)
if abs(topPadding - adjustment) > 0.1 {
topPadding = adjustment
}
}
}
}
}
}
}
}
With this approach, SecondView
can be shown in isolation without needing to pass a flag.
If it can't be assumed that the second header will be in the correct position at initial show then the height of the parent header can be passed as a parameter instead. This is perhaps a safer way of solving when using the padding approach:
struct ContentView: View {
@State private var headerHeight = CGFloat.zero
var body: some View {
ScrollView(showsIndicators: false) {
LazyVStack(spacing: 0, pinnedViews: .sectionHeaders) {
Section {
SecondView(parentHeaderHeight: headerHeight)
} header: {
HeaderView()
.onGeometryChange(for: CGFloat.self) { proxy in
proxy.size.height
} action: { height in
headerHeight = height
}
}
}
}
}
}
struct SecondView: View {
var parentHeaderHeight = CGFloat.zero
@State private var topPadding = CGFloat.zero
var body: some View {
LazyVStack(spacing: 0, pinnedViews: .sectionHeaders) {
Section {
VStack {
// ...
}
.padding(.top, -topPadding)
} header: {
SecondHeaderView()
.padding(.top, topPadding)
.onGeometryChange(for: CGFloat.self) { proxy in
proxy.frame(in: .scrollView).minY
} action: { minY in
let adjustment = max(0, parentHeaderHeight - minY)
if abs(topPadding - adjustment) > 0.1 {
topPadding = adjustment
}
}
}
}
}
}
Both approaches work the same. This is how it looks for the case of the double header: