I start with the following code:
import SwiftUI
struct ContentView: View {
var body: some View {
HStack(spacing: 20) {
ExtractedView(text: "Energy")
ExtractedView(text: "Breath Control")
}
.padding(.horizontal, 20)
}
}
#Preview {
ContentView()
}
struct ExtractedView: View {
let text: String
var body: some View {
Button {
} label: {
HStack(spacing: 8) {
Image(systemName: "globe")
.imageScale(.large)
.foregroundStyle(.tint)
Text(text)
.lineLimit(1)
.font(.system(size: 18, weight: .bold))
}
.padding(.horizontal, 8)
.padding(.vertical, 8)
.background {
Color.yellow
}
}
}
}
The approximate result I want to achieve:
In other words I need to add equal spacing inside each element after text but I don't know how to do that. Tried different code but the button size becomes equal or iOS adds newline to the second label or tries to shorten the second label even when there is enough space.
One way to solve is to use a custom Layout
:
Here is an example implementation that works this way:
struct PaddedToFill: Layout {
typealias Cache = IdealSizes
let spacing: CGFloat
struct IdealSizes {
let idealWidths: [CGFloat]
let idealMaxHeight: CGFloat
var isEmpty: Bool { idealWidths.isEmpty }
var nWidths: Int { idealWidths.count }
}
func makeCache(subviews: Subviews) -> IdealSizes {
var idealWidths = [CGFloat]()
var idealMaxHeight = CGFloat.zero
for subview in subviews {
let idealViewSize = subview.sizeThatFits(.unspecified)
idealWidths.append(idealViewSize.width)
idealMaxHeight = max(idealMaxHeight, idealViewSize.height)
}
return IdealSizes(idealWidths: idealWidths, idealMaxHeight: idealMaxHeight)
}
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout IdealSizes) -> CGSize {
// Consume all the width available
CGSize(width: proposal.width ?? 10, height: cache.idealMaxHeight)
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout IdealSizes) {
if !cache.isEmpty, subviews.count == cache.nWidths {
let idealContainerWidth = cache.idealWidths.reduce(0) { $0 + $1 } + (CGFloat(cache.nWidths - 1) * spacing)
let excessWidth = max(0, bounds.width - idealContainerWidth)
let paddingPerView = excessWidth / CGFloat(cache.nWidths)
var minX = bounds.minX
for (index, subview) in subviews.enumerated() {
let w = cache.idealWidths[index] + paddingPerView
let viewSize = subview.sizeThatFits(ProposedViewSize(width: w, height: bounds.height))
let h = viewSize.height
let x = minX + ((w - viewSize.width) / 2)
let y = bounds.minY + ((bounds.height - h) / 2)
subview.place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(width: w, height: h))
minX += w + spacing
}
}
}
}
A change is also needed to ExtractedView
, so that is expands to fill the available width before the yellow background is added:
// ExtractedView
Button {
} label: {
HStack(spacing: 8) {
// ...
}
.padding(.horizontal, 8)
.padding(.vertical, 8)
.frame(maxWidth: .infinity) // 👈 added
.background {
Color.yellow
}
}
To use, just replace the HStack
in your original code with PaddedToFill
:
PaddedToFill(spacing: 20) {
ExtractedView(text: "Energy")
ExtractedView(text: "Breath Control")
}
.padding(.horizontal, 20)
In your screenshot of the approximate result, the extra padding was always on the trailing side of each button. To achieve this result, just add an alignment
parameter when setting the maxWidth
in ExtractedView
:
HStack(spacing: 8) {
// ...
}
.padding(.horizontal, 8)
.padding(.vertical, 8)
.frame(maxWidth: .infinity, alignment: .leading) // 👈 + alignment
.background {
Color.yellow
}
EDIT Layout
was introduced in iOS 16. If you still need to support iOS 15 then you will need a fallback solution for this version. One way would be to measure the size of the container using a GeometryReader
, then share the excess width between the buttons.
Here is an example of how it can be solved this way. The easiest way to pad the buttons is to allow the size of the extra padding to be passed as a parameter to ExtractedView
:
struct ExtractedView: View {
let text: String
var extraHorizontalPadding = CGFloat.zero // 👈 added
var body: some View {
Button {
} label: {
HStack(spacing: 8) {
// ...
}
.padding(.horizontal, 8)
.padding(.horizontal, extraHorizontalPadding) // 👈 added
.padding(.vertical, 8)
.frame(maxWidth: .infinity)
.background {
Color.yellow
}
}
}
}
The size of the extra padding is computed in the same way as the custom Layout
was doing it, based on the ideal size of the container.
HStack
to the background and using a GeometryReader
to measure its size.HStack
with another GeometryReader
.struct ContentView: View {
let spacing: CGFloat = 20
@State private var idealContainerSize: CGSize?
@ViewBuilder
private var buttons: some View {
ExtractedView(text: "Energy")
ExtractedView(text: "Breath Control")
}
var body: some View {
if #available(iOS 16.0, *) {
PaddedToFill(spacing: spacing) {
buttons
}
.padding(.horizontal, spacing)
} else {
legacyLayout
}
}
private var legacyLayout: some View {
GeometryReader { outer in
let actualContainerWidth = outer.size.width
let excessWidth: CGFloat = max(0, actualContainerWidth - (idealContainerSize?.width ?? actualContainerWidth))
let paddingPerView = excessWidth / 2
HStack(spacing: spacing) {
ExtractedView(text: "Energy", extraHorizontalPadding: paddingPerView / 2)
.fixedSize()
ExtractedView(text: "Breath Control", extraHorizontalPadding: paddingPerView / 2)
.fixedSize()
}
.frame(maxWidth: .infinity)
.background {
HStack(spacing: spacing) {
buttons
}
.fixedSize()
.hidden()
.background {
GeometryReader { inner in
Color.clear
.onAppear {
idealContainerSize = inner.size
}
}
}
}
}
.frame(maxHeight: idealContainerSize?.height)
.padding(.horizontal, spacing)
}
}
@available(iOS 16.0, *)
struct PaddedToFill: Layout {
// ... as before
}
EDIT 2 The custom Layout
gets more complicated if the text labels should wrap when they don't fit on one line. You could try these changes:
.lineLimit
in ExtractedView
:// ExtractedView
Text(text)
// .lineLimit(1)
.font(.system(size: 18, weight: .bold))
sizeThatFits
to request twice the height if the proposed width is less than the ideal width:func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout IdealSizes) -> CGSize {
let idealContainerWidth = cache.idealWidths.reduce(0) { $0 + $1 } + (CGFloat(cache.nWidths - 1) * spacing)
let proposalWidth = proposal.width ?? 10
return CGSize(
width: proposalWidth,
height: proposalWidth >= idealContainerWidth ? cache.idealMaxHeight : 2 * cache.idealMaxHeight
)
}
This is a fairly rough approximation of the height that will actually be needed and it only allows the text to wrap onto one additional line. A more rigorous implementation would need to call through to the subviews with a reduced width proposal, computed exactly as it is being done in placeSubviews
.
placeSubviews
// let excessWidth = max(0, bounds.width - idealContainerWidth)
let excessWidth = bounds.width - idealContainerWidth