swiftswiftuilazyhstack

How to make LazyHStack height equal to highest item


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")
    }
}

Solution

  • 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)
            }
        }
    }
    

    Animation