Search code examples
swiftuiscrollview

Dynamic Type: Use ScrollView instead of Spacers if required


I have content within an HStack that is narrower than the available space and use Spacers to fill the entire width. However, because of dynamic font size, the content can get too large, and a ScrollView is required instead of the Spacers. But Spacers don't work within Scrollviews.

An example with simple Text. The Text should neither be wrapped nor abbreviated.

import SwiftUI

struct ScrollViewSandbox: View {
    var body: some View {
//        ScrollView(.horizontal) {
            HStack {
                Text("Hello, World!")
                Spacer()
                Text("Hello,")
                Spacer()
                Text("World!")
            }.lineLimit(1)
//        }
    }
}

#Preview {
    ScrollViewSandbox()
}

At default font size, everything is fine: enter image description here

At some point, a ScrollView is required instead of Spacers as the content is too wide: enter image description here

A ScrollView makes the Text readable again, but then Spacers don't work anymore, and everything is left aligned: enter image description here

I can use GeometryReader to set HStack minWidth and solve the issue above. However, this causes the content of GeometryReader to shrink to the wrong height when used within a (vertical) ScrollView, causing overlapping.

Example

import SwiftUI

struct ScrollViewSandbox: View {
    var body: some View {
        ScrollView {
            GeometryReader { prox in
                ScrollView(.horizontal) {
                    HStack(spacing: 20) {
                        VStack {
                            Text("Line 1")
                            Text("Line 2")
                        }
                        Spacer()
                        VStack {
                            Text("Line 1")
                            Text("Line 2")
                        }
                        Spacer()
                        VStack {
                            Text("Line 1")
                            Text("Line 2")
                        }
                    }.frame(minWidth: prox.size.width)
                }
            }
            Text("Another line")
        }
    }
}

#Preview {
    ScrollViewSandbox()
}

How it looks like enter image description here

So overall, I want to use the Spacers as long as the content is narrower than the available space and use the ScrollView if the content is larger than the available space. How can I archive that behavior?


Solution

  • The original problem with the horizontal ScrollView was solved by nesting it inside a GeometryReader, as per the answer to How to make HStack fill its parent view's width (Scroll View) (it was my answer).

    The new problem is that the height of the GeometryReader is wrong. Normally, a GeometryReader is greedy and uses all the space available, but because it is nested inside another ScrollView (this time, one that scrolls vertically), it shrinks to a minimum. This is why the last Text is seen to overlap the content of the HStack.

    Here are two workarounds:

    1. Move the outer ScrollView inside the GeometryReader

    If you nest the outer ScrollView inside the GeometryReader, instead of vice versa, then the GeometryReader occupies all of the available space in the usual way.

    However, two additional changes are required with this approach:

    • An additional VStack is required, to define the layout for the views inside the GeometryReader
    • To force the outer ScrollView to adopt its ideal height, instead of its maximum height, .fixedSize(horizontal: false, vertical: true) can be applied.
    var body: some View {
        GeometryReader { prox in
            VStack(spacing: 0) {
                ScrollView {
                    ScrollView(.horizontal) {
                        HStack(spacing: 20) {
                            VStack {
                                Text("Line 1")
                                Text("Line 2")
                            }
                            Spacer()
                            VStack {
                                Text("Line 1")
                                Text("Line 2")
                            }
                            Spacer()
                            VStack {
                                Text("Line 1")
                                Text("Line 2")
                            }
                        }
                        .frame(minWidth: prox.size.width)
                    }
                    .background(.yellow)
                }
                .fixedSize(horizontal: false, vertical: true)
                Text("Another line")
            }
        }
    }
    

    Screenshot

    2. Show the nested GeometryReader in an overlay

    If the original hierarchy is used, where a ScrollView is the top-level container, then the problem with the incorrect height for the GeometryReader can be solved by establishing the footprint for the HStack using hidden content. The GeometryReader can then be shown in an overlay over this hidden content.

    struct ScrollViewSandbox: View {
        
        private var footprint: some View {
            VStack {
                Text(".")
                Text(".")
            }
            .frame(maxWidth: .infinity)
            .hidden()
        }
        
        var body: some View {
            ScrollView {
                footprint
                    .background(.yellow)
                    .overlay {
                        GeometryReader { prox in
                            ScrollView(.horizontal) {
                                HStack(spacing: 20) {
                                    VStack {
                                        Text("Line 1")
                                        Text("Line 2")
                                    }
                                    Spacer()
                                    VStack {
                                        Text("Line 1")
                                        Text("Line 2")
                                    }
                                    Spacer()
                                    VStack {
                                        Text("Line 1")
                                        Text("Line 2")
                                    }
                                }
                                .frame(minWidth: prox.size.width)
                            }
                        }
                    }
                Text("Another line")
            }
        }
    }
    

    The result is the same as before.

    For the example here, the footprint just consists of two lines of dummy text. This is sufficient for determining the height of the main content. If the main content is not so predictable, then you could always build the footprint by using a copy of the main content.