0

I'm trying to create a SwiftUI view that includes a List, and I want that list to automatically adjust its height based on its content — without manually calculating something like:

.frame(height: items.count * rowHeight + padding)

My goal is to use the native List (not a VStack or LazyVStack), so that I can preserve the native appearance (e.g. separators, swipe actions, selection, editing). But I want to embed the list in a scrollable parent view, and avoid internal scrolling. In other words:

  • Keep native List styling
  • No internal scroll — only outer ScrollView scrolls
  • Self-sizing height based on content
  • No manual height calculation
  • No ForEach-only solution — I'd like to define rows freely if possible

Here's a simplified structure of what I'm doing:

ScrollView {
    VStack {
        Text("Above the list")

        AutoSizingList {
            Text("Row 1")
            Text("Row 2 — possibly multi-line text that grows")
            HStack {
                Text("Row 3")
                Spacer()
                Image(systemName: "chevron.right")
            }
        }

        Text("Below the list")
    }
}

And the custom list component I tried (among many versions):

struct AutoSizingList<Content: View>: View {
    @ViewBuilder let content: () -> Content
    @State private var height: CGFloat = 0

    var body: some View {
        List {
            content()
                .background(
                    GeometryReader { geo in
                        Color.clear
                            .preference(key: HeightPreferenceKey.self, value: geo.size.height)
                    }
                )
        }
        .scrollDisabled(true)
        .frame(height: height)
        .onPreferenceChange(HeightPreferenceKey.self) { height = $0 }
    }
}

private struct HeightPreferenceKey: PreferenceKey {
    static var defaultValue: CGFloat = 0
    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
        value = max(value, nextValue())
    }
}

The result: nothing appears. The List seems to collapse entirely, or SwiftUI fails to lay it out at all. I’ve tried many variations: using ForEach, measuring individual rows, storing their heights in a dictionary, using AnyView, etc.

How can I create a SwiftUI List that:

  • renders normally (with default styling);
  • auto-sizes to its content height;
  • does not scroll internally; and
  • works inside a parent ScrollView?

2 Answers 2

1

Working around the issues

The reason why the list is collapsing entirely is because the height of views inside a vertical ScrollView is based on their idealHeight and the ideal height of a List is very small.

A way to apply a measured height is to set a large idealHeight on the List. This needs to be large enough for showing the list content in full. Then, once the height is measured, you can override this with maxHeight. Something like:

private var listHeight: CGFloat?
List {
    // ...
}
.scrollDisabled(true)
.frame(idealHeight: 10000)
.frame(maxHeight: listHeight)

One way to measure the height would be to show the content of the List in a Section. You could then add a header and footer to the section and measure their positions in the global coordinate space. The difference in their y-positions gives you a reasonable basis for the height of the list, you would just need to adjust the measured height for margins.

I tried this and it works, but it seems like a messy solution to me. Some reasons why I don't like it:

  • The idealHeight part is a cludge.

  • As mentioned, the positions of the header and footer need to be measured in the global coordinate space. This is because it doesn't work to measure them in the coordinate space of the ScrollView or the VStack. So the positions move when the list is scrolled and the height is constantly being re-computed.

  • If the content of the list is dynamic then you will need to reset the measured height somehow, to allow it to be re-measured. It might be possible to use an .onChange handler here.

If you really want to go down this route then a basic implementation that shows it working can be found in the first revision of this answer.


An alternative approach

As an alternative and perhaps simpler approach, I would suggest not nesting the List inside a ScrollView. Instead, show the main content of the list as a Section and then show the content above and below the list as header and footer to this section. This way, the content above and below the list will automatically scroll with the list and there is no need to measure the height of the list content.

Doing it this way, you just need to override the styling that gets applied to the header and footer. You might like to create a ViewModifier to encapsulate the plain styling:

private struct PlainStyling: ViewModifier {
    func body(content: Content) -> some View {
        content
            .font(.body)
            .textCase(.none)
            .foregroundStyle(.primary)
            .listRowInsets(.init(top: 10, leading: 0, bottom: 10, trailing: 0))
    }
}

private extension View {
    func plainStyling() -> some View {
        modifier(PlainStyling())
    }
}

The body of your view then simplifies to the following. I added some extra styling to the content below the list, to show how it can be done:

var body: some View {
    List {
        Section {
            Text("Row 1")
            Text("Row 2 — possibly multi-line text that grows")
            HStack {
                Text("Row 3")
                Spacer()
                Image(systemName: "chevron.right")
            }
        } header: {
            Text("Above the list")
                .plainStyling()
        } footer: {
            Text("Below the list")
                .padding()
                .frame(maxWidth: .infinity, alignment: .leading)
                .background(.yellow, in: .rect(cornerRadius: 10))
                .plainStyling()
        }
    }
}

Animation

If the content above and below the list is more elaborate than just simple text, you could also consider showing it in separate list sections. In other words, you could add more sections to the List.

Sign up to request clarification or add additional context in comments.

Comments

0

(Assuming this is for iOS)

In iOS 18+, you can just use onScrollGeometryChange to find the content size of the list.

Before iOS 18, you can use SwiftUI-Introspect to work with the UIScrollView directly.

Putting both into a single modifier:

struct ScrollContentHeightReaderModifier: ViewModifier {
    @Binding var height: CGFloat
    
    func body(content: Content) -> some View {
        if #available(iOS 18, *) {
            content
                .onScrollGeometryChange(for: CGFloat.self, of: \.contentSize.height) {
                    if $1 > 0 {
                        height = $1
                    }
                }
        } else {
            content
                // list the versions you want to support here...
                .introspect(.scrollView, on: .iOS(.v16, .v17)) { list in
                    DispatchQueue.main.async {
                        height = list.contentSize.height
                    }
                }
        }
    }
}

struct AutoSizingList<Content: View>: View {
    let content: Content
    @State private var height: CGFloat = 10

    init(@ViewBuilder content: () -> Content) {
        self.content = content()
    }
    
    var body: some View {
        List {
            content
        }
        .modifier(ScrollContentHeightReaderModifier(height: $height))
        .scrollDisabled(true)
        .frame(height: height)
    }
}

Notes:

  • the content size will not be computed unless some part of the List is visible. This is why I have set the initial value of height to be 10 instead of 0.
  • the first value you get from onScrollGeometryChange is always (0, 0) - we want to skip this initial value, otherwise the list will become completely invisible again.
  • the introspect closure gets called during a view update, so we must wait after the view update has finished to change height, hence the use of DispatchQueue.async. @States cannot be changed during a view update.

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.