Created
October 21, 2024 20:33
-
-
Save allenhumphreys/673c7595919a03a2efe6e22507099353 to your computer and use it in GitHub Desktop.
A custom horizontal stack layout that has different apporptioning behavior than HStack.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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