I am trying to create an interface with a vertical scroll view at the top level, then a horizontal scroll view (using the new paginated scrolling from iOS 17) to display a number of child views that the user can scroll sideways between. So far it's behaving exactly as I want, except that the height of the first of the views in the horizontal scroll view seems to set the height for each of the other views, even if they have a taller content. To be honest, I'm not sure what behavior I imagine this having, but I was wondering if anyone had solved a similar issue or had designed a similar layout another way.
Here is a minimum reproducible example:
import SwiftUI
struct ContentView: View {
@State private var selectedTab: String? = "Tab 1"
var body: some View {
ScrollView(.vertical) {
LazyVStack {
Image(systemName: "photo.fill")
.resizable()
.aspectRatio(contentMode: .fill)
ScrollView(.horizontal) {
LazyHStack(spacing: 0) {
SampleView(.purple, 5)
.id("Tab 1")
.containerRelativeFrame(.horizontal)
SampleView(.red, 12)
.id("Tab 2")
.containerRelativeFrame(.horizontal)
SampleView(.blue, 20)
.id("Tab 3")
.containerRelativeFrame(.horizontal)
}
.scrollTargetLayout()
}
.scrollPosition(id: $selectedTab)
.scrollTargetBehavior(.paging)
}
}
}
@ViewBuilder
func SampleView(_ color: Color, _ size: Int) -> some View {
LazyVGrid(columns: Array(repeating: GridItem(), count: 2), content: {
ForEach(1...size, id: \.self) { _ in
RoundedRectangle(cornerRadius: 15)
.fill(color.gradient)
.frame(height: 150)
}
})
}
}
As you can see from the example, the height of the horizontal scrollview is locked in at the height of the first child view.
This is happening because of the LazyHStack
being used for the horizontal scrolled content.
LazyVStack
bases its height on the ideal height of the nested (horizontal) ScrollView
.ScrollView
uses the ideal height of the LazyHStack
.LazyHStack
uses the height of the first child, because the other subviews are not visible and so not yet sized.If you change the LazyHStack
to an HStack
then vertical scrolling works to the full height of the tallest child. But then of course, you can also scroll down beyond the first child. You probably want to add .top
alignment too:
ScrollView(.vertical) {
LazyVStack {
Image(systemName: "photo.fill")
// ...
ScrollView(.horizontal) {
HStack(alignment: .top, spacing: 0) { // 👈 HERE
// ...
}
.scrollTargetLayout()
}
.scrollPosition(id: $selectedTab)
.scrollTargetBehavior(.paging)
}
}
If the height of the tallest child is known then an alternative solution would be to apply this as minHeight
to the LazyHStack
:
LazyHStack(alignment: .top, spacing: 0) {
// ...
}
.frame(minHeight: 1000)
.scrollTargetLayout()
However, if you over-estimate the height then vertical scrolling just scrolls into empty space, and if you under-estimate the height then a taller child gets clipped, as you were seeing originally.
If you at least know, which of the subviews will be the tallest, then another workaround would be to use the tallest subview to form a hidden footprint for the first subview. The actual first subview can then be shown as an overlay over this footprint:
LazyHStack(alignment: .top, spacing: 0) {
SampleView(.blue, 20)
.id("Tab 1")
.containerRelativeFrame(.horizontal)
.hidden()
.overlay(alignment: .top) {
SampleView(.purple, 5)
}
SampleView(.red, 12)
.id("Tab 2")
.containerRelativeFrame(.horizontal)
SampleView(.blue, 20)
.id("Tab 3")
.containerRelativeFrame(.horizontal)
}
.scrollTargetLayout()
EDIT Your comment got me thinking. Since you are using scrollTargetLayout
, a change of subview will be reflected by a change to selectedTab
. By using a GeometryReader
in the background of each subview, the minHeight
for the LazyHStack
can be updated to correspond to the height of the selected subview whenever the content changes.
EDIT2 If the first view is longer than the second view then it scrolls into blank space when going from the first to the second view. A workaround is to set maxHeight
, in addition to minHeight
.
@State private var selectedTab: String? = "Tab 1"
@State private var contentHeight: CGFloat?
private func heightReader(target: String) -> some View {
GeometryReader { proxy in
Color.clear
.onChange(of: selectedTab, initial: true) { oldVal, newVal in
if target == newVal {
withAnimation {
contentHeight = proxy.size.height
}
}
}
}
}
var body: some View {
ScrollView(.vertical) {
LazyVStack {
Image(systemName: "photo.fill")
.resizable()
.aspectRatio(contentMode: .fill)
ScrollView(.horizontal) {
LazyHStack(alignment: .top, spacing: 0) {
SampleView(.purple, 5)
.id("Tab 1")
.containerRelativeFrame(.horizontal)
.background { heightReader(target: "Tab 1") }
SampleView(.red, 12)
.id("Tab 2")
.containerRelativeFrame(.horizontal)
.background { heightReader(target: "Tab 2") }
SampleView(.blue, 20)
.id("Tab 3")
.containerRelativeFrame(.horizontal)
.background { heightReader(target: "Tab 3") }
}
.frame(minHeight: contentHeight, maxHeight: contentHeight)
.scrollTargetLayout()
}
.scrollPosition(id: $selectedTab)
.scrollTargetBehavior(.paging)
}
}
}