swiftui

How do I make a SwiftUI Scroll view shrink-to-content?


I have a SwiftUI GUI with two lists stacked on top of each other. Each list has a variable number of items. I'd like both lists to shrink to fit their contents.

If I use VStack around ForEach, the simple case works (see example below). There is a spacer near the bottom, so the lists shift to the top of the window like I expect.

Now, sometimes the lists are large and I want a Scroll view and a maximum height, but I still want the list to shrink when it has fewer items. But as soon as I add the Scroll view, the Scroll view starts to take up all the space it can. If I assign it a maximum, then it doesn't shrink to fit it's contents anymore.

In the example below (as written) I get the resizing behavior I want (without the max size). If I uncomment the Scroll view, then it consumes all the space, if I uncomment the frame modifier, it works, but the size is fixed.

struct ContentView: View {
    @State var List1: [String] = [  ]
    @State var List2: [String] = [  ]

    var body: some View {
        VStack {
            Button("1-5") {
                List1=[ "1" ]
                List2=[ "a", "b", "c", "d", "e" ]
            }
            Button("3-3") {
                List1=[ "1", "2", "3" ]
                List2=[ "a", "b", "c" ]
            }
            Button("5-1") {
                List1=[ "1", "2", "3", "4", "5" ]
                List2=[ "a" ]
            }
            //ScrollView {
                VStack {
                    ForEach(List1.indices, id: \.self) { idx in
                        Text(List1[idx])
                    }
                }
            //}
            //.frame(maxHeight: 40)
            Text("middle")
            VStack {
                ForEach(List2.indices, id: \.self) { idx in
                    Text(List2[idx])
                }
            }
            Spacer()
            Text("last")
        }
    }
}


Solution

  • You need PreferenceKey to calculate the size of your ScrollView content. Here a getSize function that can help you :

    struct SizePreferenceKey: PreferenceKey {
        static var defaultValue: CGSize = .zero
    
        static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
            value = nextValue()
        }
    }
    
    struct SizeModifier: ViewModifier {
        private var sizeView: some View {
            GeometryReader { geometry in
                Color.clear.preference(key: SizePreferenceKey.self, value: geometry.size)
            }
        }
    
        func body(content: Content) -> some View {
            content.overlay(sizeView)
        }
    }
    
    extension View {
        func getSize(perform: @escaping (CGSize) -> ()) -> some View {
            self
                .modifier(SizeModifier())
                .onPreferenceChange(SizePreferenceKey.self) {
                    perform($0)
                }
        }
    }
    

    You have to compare the height of your content (with getSize) and the height of the ScrollView (with GeometryReader), and set the frame accordingly :

    struct SwiftUIView12: View {
        @State private var items: [String] = ["One", "Two", "Three"]
        @State private var scrollViewSize: CGSize = .zero
        var body: some View {
            GeometryReader { proxy in
                ScrollView {
                    ForEach(items, id: \.self) { item in
                        Text(item)
                            .padding()
                    }
                    .frame(maxWidth: .infinity)
                    .getSize {scrollViewSize = $0}
                }
                .frame(height: scrollViewSize.height < proxy.size.height ? scrollViewSize.height : .none )
                .background(Color.blue.opacity(0.2))
            }
            .navigationTitle("Test")
            .toolbar {
                Button("Many items") {
                    items = (1 ... 30).map { _ in String.random(length: 10) }
                }
            }
        }
    }
    

    enter image description here