How can I give a sequence of Text views in a horizontal ScrollView equally sized square backgrounds in SwiftUI?

126 views Asked by At

I'm working on a layout for a push-up counter app. That is my code and the result so far:

struct ContentView: View {
    
let sets = [1,3,6,8,12,16,23,43,56,76,100,101,125]
    
var body: some View {
    VStack {
            
        ScrollView(.horizontal, showsIndicators: false) {
            HStack {
                ForEach(sets, id: \.self) { set in
                    Text("\(set)")
                        .padding(8)
                        .background(Color.blue)
                }
            }
        }
            
        Color.green
            .overlay {
                Text("56")
                    .font(.largeTitle.weight(.bold))
            }
        }
    }
}

enter image description here

The numbers above are sets the user has to perform. I want all the numbers to be in equally sized perfect squares rather than rectangles and the spacing between those squares to be equal. This layout has to dynamically scale and adapt to all font size variants and screen sizes. How can I achieve that in a declarative manner? With no hacks and hard coding.

Conceptually, I want the end result to look like this:

enter image description here

So far, I've tried geometry reader, fixed size modifier, and setting aspect ratio in various places, but everything falls apart.

You can download a sample project here. I appreciate your help!

1

There are 1 answers

4
Benzy Neez On

This requirement can be achieved with a custom Layout implementation:

struct HStackEqualSquares: Layout {
    var spacing: CGFloat = 10

    private func preferredSize(subviews: Subviews, proposedWidth: CGFloat?) -> CGSize {
        let subviewDimensions = subviews.map { $0.dimensions(in: .unspecified) }
        let maxWidth: CGFloat = subviewDimensions.reduce(.zero) { currentMax, subviewSize in
            max(currentMax, subviewSize.width)
        }
        let nSubviews = CGFloat(subviews.count)
        let totalWidth = (nSubviews * maxWidth) + ((nSubviews - 1) * spacing)
        return CGSize(width: totalWidth, height: maxWidth)
    }

    func sizeThatFits(
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout Void
    ) -> CGSize {
        return preferredSize(subviews: subviews, proposedWidth: proposal.width)
    }

    func placeSubviews(
        in bounds: CGRect,
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout Void
    ) {
        let height = preferredSize(subviews: subviews, proposedWidth: bounds.width).height
        var point = bounds.origin
        for subview in subviews {
            let placementProposal = ProposedViewSize(width: height, height: height)
            subview.place(at: point, proposal: placementProposal)
            point = CGPoint(x: point.x + height + spacing, y: point.y)
        }
    }
}

In order that the items actually expand to fill the sizes they are given, .frame(maxWidth: .infinity, maxHeight: .infinity) needs to be set on the text items. Here is how it comes together:

struct ContentView: View {

    let sets = [1,3,6,8,12,16,23,43,56,76,100,101,125]

    var body: some View {
        VStack {
            ScrollView(.horizontal, showsIndicators: false) {
                HStackEqualSquares {
                    ForEach(sets, id: \.self) { set in
                        Text("\(set)")
                            .padding(8)
                            .frame(maxWidth: .infinity, maxHeight: .infinity)
                            .background(.blue)
                    }
                }
            }

            Color.green
                .overlay {
                    Text("56")
                        .font(.largeTitle.weight(.bold))
                }
        }
    }
}

Screenshot


EDIT Following up on your comment in which you asked about a solution for older iOS versions, it would be possible to get near to the same solution by going back to my original solution (using GeometryReader) and using an HStack instead of the Layout implementation. However, you would have to guess the height that is added to the row. This height would need to be applied to the HStack as bottom padding. It means the solution doesn't quite fulfil the requirement of no hard-coded values, but maybe it is adequate for the purpose of supporting older iOS versions. The size of the padding could at least be defined as a ScaledMetric so that it adapts to changes to the text size:

@ScaledMetric(relativeTo: .body) private var bottomPadding: CGFloat = 10

Then it is used like this:

private var simpleFootprint: some View {
    Text("888")
        .hidden()
}

private var compositeFootprint: some View {
    ZStack {
        ForEach(sets, id: \.self) { set in
            Text("\(set)")
        }
    }
    .hidden()
}
ScrollView(.horizontal, showsIndicators: false) {
    HStack(spacing: 10) { // instead of HStackEqualSquares
        ForEach(sets, id: \.self) { set in
            compositeFootprint // or simpleFootprint, if sufficient
                .padding(8)
                .overlay {
                    GeometryReader { proxy in
                        let width = proxy.size.width
                        Text("\(set)")
                            .frame(width: width, height: width)
                            .background(.blue)
                    }
                }
        }
    }
    .padding(.bottom, bottomPadding)
}