I have horizontally scrolled cards, each can be with different height. Problem that LazyHStack takes height of first item, but how to change it if bigger item will be. Changing hight on the moment of scrolling is also good. I know that problem can be solved with using non lazy HStack, but need solution for LazyHStack
let items = ["Short", "Also short", "Short again", "But this is veeeery veeeery looong!"]
ScrollView(.vertical) {
VStack {
ScrollView(.horizontal) {
LazyHStack {
ForEach(0 ..< items.count) { index in
Text(items[index])
.frame(width: 150)
}
}
}
Text("Some static content here")
}
}
One way to approach this problem is to use an array to store the heights of the items. The array can be populated using an .onGeometryChange
handler on the items themselves. However, you probably need to apply a .fixedHeight
modifier to the items, to ensure that they use all the height they need (in particular, to ensure that long text is not truncated).
Then, the height for the content can be determined if the min/max index for the visible items is known.
I was thinking that the modifier onScrollTargetVisibilityChange(idType:threshold:_:)
would be ideal for determining which items are visible (requires iOS 18). However, when I tried it, errors were sometimes reported in the console for ScrollTargetVisibilityChange tried to update multiple times per frame.
So as an alternative approach, the items can update two state variables for firstVisibleIndex
and lastVisibleIndex
, as determined by their scroll offset. A computed property can then deliver the height for the content. The width of the ScrollView
is also needed for this approach. This can be measured using another .onGeometryChange
modifier on the ScrollView
.
Here is the updated example to show it working:
struct ContentView: View {
let items = [
"Short",
"Also short",
"Short again",
"But this is veeeery veeeery looong!",
"The quick",
"brown fox",
"jumps over",
"the lazy dog",
"The quick brown fox jumps over the lazy dog"
]
let spacing: CGFloat = 10
@State private var heights: [CGFloat]
@State private var firstVisibleIndex = 0
@State private var lastVisibleIndex = 0
@State private var scrollViewWidth = CGFloat.zero
init() {
heights = .init(repeating: 0, count: items.count)
}
private var minContentHeight: CGFloat? {
firstVisibleIndex <= lastVisibleIndex
? heights[firstVisibleIndex...lastVisibleIndex].max()
: nil
}
var body: some View {
ScrollView(.vertical) {
VStack {
ScrollView(.horizontal) {
LazyHStack(alignment: .top, spacing: spacing) {
ForEach(Array(items.enumerated()), id: \.offset) { index, item in
Text(item)
.fixedSize(horizontal: false, vertical: true)
.padding()
.frame(width: 150)
.background(Color(hue: Double(index) * (1 / Double(items.count)), saturation: 0.5, brightness: 0.8))
.onGeometryChange(for: CGRect.self) { proxy in
proxy.frame(in: .scrollView)
} action: { frame in
if frame.height > heights[index] {
heights[index] = frame.height
}
if frame.minX <= spacing && frame.maxX > 0 {
firstVisibleIndex = index
}
if frame.minX < scrollViewWidth && frame.maxX >= scrollViewWidth - spacing {
lastVisibleIndex = index
}
}
}
}
.padding(.horizontal, spacing)
.frame(minHeight: minContentHeight)
}
.onGeometryChange(for: CGFloat.self) { proxy in
proxy.size.width
} action: { width in
scrollViewWidth = width
}
Text("Some static content here")
.padding()
.background(.yellow)
}
.animation(.default, value: minContentHeight)
}
}
}