0

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))
    }
}
7
  • What is your question? Is there something wrong with the code you posted? Commented Jun 23, 2024 at 2:59
  • ...Since the List view is incompatible with ScrollView. A List in a ScrollView seems to work for me in my tests, you just have to give it a .frame(height: xxxx). Commented Jun 23, 2024 at 3:46
  • @workingdogsupportUkraine Yes, if I add a frame modifier then it works, but I don’t know how big the frame needs to be in order to not have the list scroll. Since the ScrollView takes care of the scrolling for the whole view I want the list to display every row. So unless I can calculate what the height of the frame should be I can’t use a List. As you can see from my code, I can calculate the height of a list if each row’s height isn’t bigger than the defaultMinListRowHeight. I have multiple scenarios where each row will have a height greater than that value. Commented Jun 23, 2024 at 3:51
  • you can use a simple GeometryReader to get the size you want. Commented Jun 23, 2024 at 3:58
  • I’ve fiddled with GeometryReader a bit but haven’t had success yet. Perhaps you can submit an answer with some example code? The problem I’ve found is that a GeometryReader around a List doesn’t help me know what the height of the List should be. List inherits its height from its parent (unless you give it a frame) which means GeometryReader’s size doesn’t reflect how big the List should/will be. Commented Jun 23, 2024 at 4:09

2 Answers 2

0

Here is my test code of a List inside a ScrollView using NavigationLink, that can do delete and move as well.

Using a GeometryReader to control the height of the List as desired.

struct Item: Identifiable {
    let id = UUID()
    var name: String
}

struct ContentView: View {
    @State private var items: [Item] = [Item(name: "item-1"),Item(name: "item-2"),Item(name: "item-3")]
    
    var body: some View {
        NavigationStack {
            GeometryReader { geom in
                ScrollView {
                    Text("List in a ScrollView")
                    List {
                        ForEach(items) { item in
                            NavigationLink(destination: Text(item.name)) {
                                Text(item.name)
                            }
                        }
                        .onDelete(perform: doDelete)
                        .onMove(perform: doMove)
                    }
                    .frame(height: geom.size.height) // <-- here, adjust
                }
            }
        }
    }
    
    func doDelete(offsets: IndexSet) {
        items.remove(atOffsets: offsets)
    }
    
    func doMove(from source: IndexSet, to destination: Int) {
        items.move(fromOffsets: source, toOffset: destination)
    }
}
Sign up to request clarification or add additional context in comments.

3 Comments

Keep in mind that I have other views in the ScrollView before and after the List. If the List is that big and it only has 2 items then there’s tons of blank space. Or if there are tons of rows then the list will need to scroll.
Given that you have other views after the List, you must decide the List size (as a proportion of the screen size), eg .frame(height: geom.size.height / 2)
I want the List to only be as big as it needs to be in order to display all of the items. If I can calculate how big each row is going to be then I can add up all of their heights and use the total as the List height.
-1

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.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.