Reputation: 1014
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))
}
}
}
}
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:
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!
Upvotes: 0
Views: 129
Reputation: 21730
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))
}
}
}
}
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)
}
Upvotes: 1