Skip to content

Instantly share code, notes, and snippets.

@allenhumphreys
Created October 21, 2024 20:33
Show Gist options
  • Save allenhumphreys/673c7595919a03a2efe6e22507099353 to your computer and use it in GitHub Desktop.
Save allenhumphreys/673c7595919a03a2efe6e22507099353 to your computer and use it in GitHub Desktop.
A custom horizontal stack layout that has different apporptioning behavior than HStack.
import SwiftUI
struct ContentView: View {
var body: some View {
VStack(alignment: .leading, spacing: 10) {
Text("Plain HStack")
HStack(alignment: .lastTextBaseline, spacing: 0) {
Group {
Label {
Text("Filtersaf afdsa aallll.")
} icon: {
Image(systemName: "globe")
.imageScale(.large)
.foregroundStyle(.tint)
}
Text("Hello, world! fa a asdf adsf ")
Image(systemName: "globe")
.imageScale(.large)
.foregroundStyle(.tint)
}
.border(.blue)
}
.background {
GeometryReader { geometry in
Color.clear
.onChange(of: geometry.size.height, initial: true) { oldValue, newValue in
print("Standard HStack: \(newValue)")
}
}
}
.border(.red)
Text("Toolbar layout (prioritizes center centering")
ToolbarLayout(alignment: .top, spacing: 0) {
Group {
Label {
Text("Filtersaf afdsa aallll.")
} icon: {
Image(systemName: "globe")
.imageScale(.large)
.foregroundStyle(.tint)
}
Text("Hello, world! fa a asdf adsf ")
Image(systemName: "globe")
.imageScale(.large)
.foregroundStyle(.tint)
}
.border(.blue)
}
.border(.red)
Text("Evenly Divided")
EvenlyDividedHStack(alignment: .lastTextBaseline, spacing: 8) {
Group {
Label {
Text("Filtersaf afdsa ")
} icon: {
Image(systemName: "globe")
.imageScale(.large)
.foregroundStyle(.tint)
}
Text("Hello, world! fa a asdf adsf ")
Image(systemName: "globe")
.imageScale(.large)
.foregroundStyle(.tint)
.frame(maxWidth: .infinity, alignment: .trailing)
}
.border(.blue)
}
.background {
GeometryReader { geometry in
Color.clear
.onChange(of: geometry.size.height, initial: true) { oldValue, newValue in
print("EvenlyDividedHStack \(newValue)")
}
}
}
.border(.red)
}
.padding()
}
}
struct EvenlyDividedHStack: Layout {
let alignment: VerticalAlignment
let spacing: CGFloat
init(alignment: VerticalAlignment = .center, spacing: CGFloat = 8) {
self.alignment = alignment
self.spacing = spacing
}
func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout Void
) -> CGSize {
let proposedArea = proposal.replacingUnspecifiedDimensions()
let totalSpacing = spacing * CGFloat(subviews.count - 1)
let oneOverNWidth = ((proposedArea.width - totalSpacing) / CGFloat(subviews.count)).rounded(.down)
var oneOverNProposal = proposal
oneOverNProposal.width = oneOverNWidth
var largestHeight: CGFloat = 0.0
for subview in subviews {
let subviewSize = subview.sizeThatFits(oneOverNProposal)
largestHeight = max(largestHeight, subviewSize.height)
}
// this will always take up the proposed width. In order to not do that,
// you'd first have to do additional calculations with the subviews
return .init(width: proposedArea.width, height: largestHeight)
}
func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout Void
) {
let totalSpacing = spacing * CGFloat(subviews.count - 1)
let oneOverNWidth = ((bounds.width - totalSpacing) / CGFloat(subviews.count)).rounded(.down)
var oneOverNProposal = proposal
oneOverNProposal.width = oneOverNWidth
var shift: CGFloat?
var currentX = bounds.minX
for subview in subviews {
let dimensions = subview.dimensions(in: oneOverNProposal)
// the Y value within the bounds
let verticalAlignmentGuide = dimensions[alignment]
print("Guide: \(verticalAlignmentGuide) Height: \(dimensions.height)")
let baseY: CGFloat
switch alignment {
case .bottom:
baseY = bounds.maxY
case .top:
baseY = bounds.minY
case .center:
baseY = bounds.midY
case .firstTextBaseline:
baseY = bounds.midY
case .lastTextBaseline:
baseY = bounds.midY
default:
fatalError("Vertical alignment \(alignment) not supported yet")
}
if shift == nil {
// this prob definitely not right in all case, you need to find the largest
// offset upfront I bet
let midY = dimensions.height / 2
shift = verticalAlignmentGuide - midY
}
subview.place(
at: CGPoint(x: currentX, y: baseY - verticalAlignmentGuide + (shift ?? 0)),
anchor: .topLeading,
proposal: oneOverNProposal
)
currentX += (oneOverNWidth + spacing)
}
}
}
struct ToolbarLayout: Layout {
let alignment: VerticalAlignment
let spacing: CGFloat
init(alignment: VerticalAlignment = .center, spacing: CGFloat = 8) {
self.alignment = alignment
self.spacing = spacing
}
struct Cache {
var size: CGSize
}
func makeCache(subviews: Subviews) -> Cache {
Cache(size: .zero)
}
func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout Cache
) -> CGSize {
assert(subviews.count == 3, "expects 3 subviews")
// Calculates the worst case height: when all 3 views take up their equal width allotment
let proposedArea = proposal.replacingUnspecifiedDimensions()
let totalSpacing = spacing * CGFloat(subviews.count - 1)
let oneOverNWidth = ((proposedArea.width - totalSpacing) / CGFloat(subviews.count)).rounded(.down)
var oneOverNProposal = proposal
oneOverNProposal.width = oneOverNWidth
var largestHeight: CGFloat = 0.0
for subview in subviews {
let subviewSize = subview.sizeThatFits(oneOverNProposal)
largestHeight = max(largestHeight, subviewSize.height)
}
// this will always take up the proposed width. In order to not do that,
// you'd first have to do additional calculations with the subviews
return .init(width: proposedArea.width, height: largestHeight)
}
func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout Cache
) {
assert(subviews.count == 3, "expects 3 subviews")
let leading = subviews[0]
let center = subviews[1]
let trailing = subviews[2]
var leadingTrailingProposal = proposal
let oneThirdWidth = (bounds.width / 3) - spacing / 2
leadingTrailingProposal.width = oneThirdWidth
let leadingEqualWidth = leading.sizeThatFits(leadingTrailingProposal).width + spacing / 2
let trailingEqualWidth = trailing.sizeThatFits(leadingTrailingProposal).width + spacing / 2
// the center element gets half spacing on both sides
var centerProposal = leadingTrailingProposal
let remainingWidth = bounds.width - (max(leadingEqualWidth, trailingEqualWidth) * 2)
centerProposal.width = remainingWidth - spacing / 2
let leadingDimensions = leading.dimensions(in: leadingTrailingProposal)
let leadingAlignment = leadingDimensions[alignment]
let baseY = bounds.y(for: alignment)
leading.place(
at: CGPoint(x: bounds.minX, y: baseY - leadingAlignment),
anchor: .topLeading,
proposal: leadingTrailingProposal
)
let centerDimensions = center.dimensions(in: centerProposal)
let centerAlignment = centerDimensions[alignment]
center.place(
at: CGPoint(x: bounds.midX, y: baseY - centerAlignment),
anchor: .top,
proposal: centerProposal
)
let trailingDimensions = trailing.dimensions(in: leadingTrailingProposal)
let trailingAlignment = trailingDimensions[alignment]
trailing.place(
at: CGPoint(x: bounds.maxX, y: baseY - trailingAlignment),
anchor: .topTrailing,
proposal: leadingTrailingProposal
)
}
}
private extension CGRect {
func y(for alignment: VerticalAlignment) -> CGFloat {
return switch alignment {
case .bottom: maxY
case .top: minY
case .center: midY
default:
fatalError("Vertical alignment \(alignment) not supported yet")
}
}
}
#Preview {
ContentView()
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment