swiftswiftuiswiftui-listswiftui-scrollview

SwiftUI - Allow List to Show All Rows in ScrollView


In the app I'm working on I have a details view that uses ScrollView to display a bunch of information. I want to use a List inside that ScrollView to display some specific data. However, you can't use a List inside a ScrollView unless you give use a frame modifier. I want the List to display all of its rows without the list having to scroll, which means I need to know how big the frame must be to display all the rows. Say I give the List a height of 200 (using frame) and there are too many rows to show, then the List would scroll. But since the List is inside a ScrollView I want it to show all of the rows and let the ScrollView make it scrollable.

The only way I can think to do this is to calculate the height of each row and use the total height as the List's height. I've been able to do this using the environment variable defaultMinListRowHeight, but that only works if the height of the row is no larger than the default. In the particular scenario I've mentioned above the rows are very likely to have a height greater than the default.

Is there any way I can calculate exactly what the height of the List must be to display each row?

This is the code I've written to size the List based off the defaultMinListRowHeight:

struct DynamicList<SelectionValue: Identifiable, Content: View>: View {
    @Environment(\.defaultMinListRowHeight) private var minRowHeight
    
    var values: [SelectionValue]
    @ViewBuilder var content: (SelectionValue) -> Content
    
    var body: some View {
        List(values) { value in
            content(value)
                .lineLimit(1)
        }
        .listStyle(.plain)
        .scrollDisabled(true)
        .frame(minHeight: minRowHeight * CGFloat(values.count))
    }
}

Solution

  • After messing around for a bit I was able to find the answer I was looking for. Here is some sample code to illustrate the solution:

    struct DynamicallySizedList: View {
        @State private var rowHeights: [Int: CGFloat] = [:]
        
        let data = [
            "Sample Data",
            "Sample Data Sample Data Sample Data Sample Data Sample Data Sample Data Sample Data Sample Data",
            "Sample Data Sample Data Sample Data Sample Data Sample Data Sample Data Sample Data Sample Data Sample Data Sample Data Sample Data Sample Data Sample Data Sample Data Sample Data Sample Data",
            "Sample Data",
            "Sample Data",
            "Sample Data",
            "Sample Data"
        ]
        
        var totalHeight: CGFloat {
            rowHeights.values.reduce(1, +)
        }
        
        var body: some View {
            ScrollView {
                Text("Total Height: \(totalHeight, specifier: "%.0f")")
                    .padding()
                
                List(data.indices, id: \.self) { index in
                    MeasurableRow(index: index) { height, rowIndex in
                        rowHeights[rowIndex] = height
                    } content: {
                        Text(data[index])
                            .frame(maxHeight: .infinity)
                    }
                }
                .scrollDisabled(true)
                .listStyle(.plain)
                .frame(height: totalHeight)
                .background(.blue)
                
                Text("Stuff Below")
            }
        }
    }
    
    struct MeasurableRow<Content: View>: View {
        var index: Int
        var heightChanged: (CGFloat, Int) -> Void
        @ViewBuilder var content: () -> Content
        
        var body: some View {
            content()
                .background(GeometryReader { geo in
                    Color.red
                        .onAppear {
                            heightChanged(geo.size.height + 8, index) //8 is to compensate for internal row padding/styling
                        }
                        .onChange(of: geo.size.height) { oldHeight, newHeight in
                            heightChanged(newHeight + 8, index)
                        }
                })
        }
    }
    

    enter image description here

    I tried messing around with GeometryReader a bit but didn't have any success because I was trying to use it to get the height of the List or using it inside the List to get the height a ForEach. Neither method worked for multiple reasons, but it leaves only one option: measure each row as it gets rendered. If you wrap each row in a GeometryReader it makes the formatting of the content weird and it's a pain to deal with, especially if you want to abstract the implementation and use a ViewBuilder to provide the View content for the rows. But if you use GeometryReader in the background of the row then you can measure the height without it interfering with the styling.

    As you can see from the code, I create a wrapper View called MeasurableRow which takes in the index of the List item, a closure to save the height, and the content for it to wrap. The body then adds the GeometryReader to the content and makes the calls to the closure to update its height value when it appears/changes. The closure we pass to the MeasurableRow View is just going to save the new height value to a dictionary, using the index as a key.

    Lastly, I have a computed property to add up all of the height values from the dictionary, which is the height the List needs to be. I've added some styling to the list to prevent scrolling and make it appear plain (you can have it styled how you want). I added a blue background so you can see how much space the List actually takes up beyond the rows (the magic 8 number can be adjusted to get rid of the sliver of blue, but I think it's close enough).

    Note: If you don't want to use .listStyle(.plain) then you'll have to mess around with the default value for totalHeights to take into account the added padding for the default List styling. From my testing, 70 seems like a good default value.