SwiftUI ScrollView does not center content when content fits scrollview bounds
Asked Answered
G

4

9

I'm trying to have the content inside a ScrollView be centered when that content is small enough to not require scrolling, but instead it aligns to the top. Is this a bug or I'm missing adding something? Using Xcode 11.4 (11E146)

    @State private var count : Int = 100

    var body : some View {
//        VStack {
            ScrollView {
                VStack {
                    Button(action: {
                        if self.count > 99 {
                            self.count = 5
                        } else {
                            self.count = 100
                        }
                    }) {
                        Text("CLICK")
                    }
                    ForEach(0...count, id: \.self) { no in
                        Text("entry: \(no)")
                    }
                }
                .padding(8)
                .border(Color.red)
                .frame(alignment: .center)
            }
            .border(Color.blue)
            .padding(8)
//        }
    }

enter image description here

Glutinous answered 13/4, 2020 at 7:58 Comment(0)
D
6

The frame(alignment: .center) modifier you’ve added doesn’t work since what it does is wrapping your view in a new view of exactly the same size. Because of that the alignment doesn’t do anything as there is no additional room for the view do be repositioned.

One potential solution for your problem would be to wrap the whole ScrollView in a GeometryReader to read available height. Then use that height to specify that the children should not be smaller than it. This will then make your view centered inside of ScrollView.

struct ContentView: View {
    @State private var count : Int = 100

    var body : some View {
        GeometryReader { geometry in
            ScrollView {
                VStack {
                    Button(action: {
                        if self.count > 99 {
                            self.count = 5
                        } else {
                            self.count = 100
                        }
                    }) {
                        Text("CLICK")
                    }
                    ForEach(0...self.count, id: \.self) { no in
                        Text("entry: \(no)")
                    }
                }
                .padding(8)
                .border(Color.red)
                .frame(minHeight: geometry.size.height) // Here we are setting minimum height for the content
            }
            .border(Color.blue)
        }
    }
}
Denishadenison answered 13/4, 2020 at 10:15 Comment(0)
V
11

Credit goes to @Thaniel for finding the solution. My intention here is to more fully explain what is happening behind the scenes to demystify SwiftUI and explain why the solution works.

Solution

Wrap the ScrollView inside a GeometryReader so that you can set the minimum height (or width if the scroll view is horizontal) of the scrollable content to match the height of the ScrollView. This will make it so that the dimensions of the scrollable area are never smaller than the dimensions of the ScrollView. You can also declare a static dimension and use it to set the height of both the ScrollView and its content.

Dynamic Height

@State private var count : Int = 5

var body: some View {

    // use GeometryReader to dynamically get the ScrollView height
    GeometryReader { geometry in
        ScrollView {
            VStack(alignment: .leading) {
                ForEach(0...self.count, id: \.self) { num in
                    Text("entry: \(num)")
                }
            }
            .padding(10)
            // border is drawn before the height is changed
            .border(Color.red)
            // match the content height with the ScrollView height and let the VStack center the content
            .frame(minHeight: geometry.size.height)
        }
        .border(Color.blue)
    }

}

Static Height

@State private var count : Int = 5
// set a static height
private let scrollViewHeight: CGFloat = 800

var body: some View {

    ScrollView {
        VStack(alignment: .leading) {
            ForEach(0...self.count, id: \.self) { num in
                Text("entry: \(num)")
            }
        }
        .padding(10)
        // border is drawn before the height is changed
        .border(Color.red)
        // match the content height with the ScrollView height and let the VStack center the content
        .frame(minHeight: scrollViewHeight)
    }
    .border(Color.blue)

}

ScrollView Solution Image

The bounds of the content appear to be smaller than the ScrollView as shown by the red border. This happens because the frame is set after the border is drawn. It also illustrates the fact that the default size of the content is smaller than the ScrollView.

Why Does it Work?

ScrollView

First, let's understand how SwiftUI's ScrollView works.

  • ScrollView wraps it's content in a child element called ScrollViewContentContainer.
  • ScrollViewContentContainer is always aligned to the top or leading edge of the ScrollView depending on whether it is scrollable along the vertical or horizontal axis or both.
  • ScrollViewContentContainer sizes itself according to the ScrollView content.
  • When the content is smaller than the ScrollView, ScrollViewContentContainer pushes it to the top or leading edge.

Center Align

Here's why the content gets centered.

  • The solution relies on forcing the ScrollViewContentContainer to have the same width and height as its parent ScrollView.
  • GeometryReader can be used to dynamically get the height of the ScrollView or a static dimension can be declared so that both the ScrollView and its content can use the same parameter to set their horizontal or vertical dimension.
  • Using the .frame(minWidth:,minHeight:) method on the ScrollView content ensures that it is never smaller than the ScrollView.
  • Using a VStack or HStack allows the content to be centered.
  • Because only the minimum height is set, the content can still grow larger than the ScrollView if needed, and ScrollViewContentContainer retains its default behavior of aligning to the top or leading edge.
Vendace answered 20/10, 2020 at 23:35 Comment(0)
M
6

You observe just normal ScrollView behaviour. Here is a demo of possible approach to achieve your goal.

demo

// view pref to detect internal content height
struct ViewHeightKey: PreferenceKey {
    typealias Value = CGFloat
    static var defaultValue: CGFloat { 0 }
    static func reduce(value: inout Value, nextValue: () -> Value) {
        value = value + nextValue()
    }
}

// extension for modifier to detect view height
extension ViewHeightKey: ViewModifier {
    func body(content: Content) -> some View {
        return content.background(GeometryReader { proxy in
            Color.clear.preference(key: Self.self, value: proxy.size.height)
        })
    }
}

// Modified your view for demo
struct TestAdjustedScrollView: View {
    @State private var count : Int = 100

    @State private var myHeight: CGFloat? = nil
    var body : some View {
        GeometryReader { gp in
            ScrollView {
                VStack {
                    Button(action: {
                        if self.count > 99 {
                            self.count = 5
                        } else {
                            self.count = 100
                        }
                    }) {
                        Text("CLICK")
                    }
                    ForEach(0...self.count, id: \.self) { no in
                        Text("entry: \(no)")
                    }
                }
                .padding(8)
                .border(Color.red)
                .frame(alignment: .center)
                .modifier(ViewHeightKey())   // read content view height !!
            }
            .onPreferenceChange(ViewHeightKey.self) {
                // handle content view height
                self.myHeight = $0 < gp.size.height ? $0 : gp.size.height
            }
            .frame(height: self.myHeight) // align own height with content
            .border(Color.blue)
            .padding(8)
        }
    }
}
Moureaux answered 13/4, 2020 at 10:8 Comment(0)
D
6

The frame(alignment: .center) modifier you’ve added doesn’t work since what it does is wrapping your view in a new view of exactly the same size. Because of that the alignment doesn’t do anything as there is no additional room for the view do be repositioned.

One potential solution for your problem would be to wrap the whole ScrollView in a GeometryReader to read available height. Then use that height to specify that the children should not be smaller than it. This will then make your view centered inside of ScrollView.

struct ContentView: View {
    @State private var count : Int = 100

    var body : some View {
        GeometryReader { geometry in
            ScrollView {
                VStack {
                    Button(action: {
                        if self.count > 99 {
                            self.count = 5
                        } else {
                            self.count = 100
                        }
                    }) {
                        Text("CLICK")
                    }
                    ForEach(0...self.count, id: \.self) { no in
                        Text("entry: \(no)")
                    }
                }
                .padding(8)
                .border(Color.red)
                .frame(minHeight: geometry.size.height) // Here we are setting minimum height for the content
            }
            .border(Color.blue)
        }
    }
}
Denishadenison answered 13/4, 2020 at 10:15 Comment(0)
V
0

For me, GeometryReader aligned things to the top no matter what. I solved it with adding two extra Spacers (my code is based on this answer):

GeometryReader { metrics in
    ScrollView {
        VStack(spacing: 0) {
            Spacer()
            // your content goes here
            Spacer()
        }
        .frame(minHeight: metrics.size.height)
    }
}
Villagomez answered 2/6, 2022 at 17:12 Comment(0)

© 2022 - 2025 — McMap. All rights reserved.