|
// |
|
// FlexBoxLayout.swift |
|
// |
|
// Created by Christoph Kaser on 05.04.24. |
|
// |
|
|
|
import SwiftUI |
|
|
|
struct HFlexBoxLayout: Layout { |
|
|
|
var verticalSpacing: CGFloat = 10.0 |
|
var horizontalSpacing: CGFloat = 10.0 |
|
|
|
enum HorizontalAlignment { |
|
case leading, center, between, trailing |
|
} |
|
|
|
enum InnerRowAlignment { |
|
case top, center, bottom |
|
} |
|
|
|
var horizontalAlignment: HorizontalAlignment = .leading |
|
var innerRowAlignment: InnerRowAlignment = .top |
|
|
|
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { |
|
return layoutCalc(proposal: proposal, subviews: subviews) |
|
} |
|
|
|
static func minWidth(_ view: LayoutSubviews.Element) -> CGFloat { |
|
return view.sizeThatFits(ProposedViewSize(width: 0, height: .infinity)).width |
|
} |
|
|
|
static func idealWidth(_ view: LayoutSubviews.Element) -> CGFloat { |
|
return view.sizeThatFits(.unspecified).width |
|
} |
|
|
|
static func maxWidth(_ view: LayoutSubviews.Element) -> CGFloat { |
|
return view.sizeThatFits(.infinity).width |
|
} |
|
|
|
|
|
func rowCalc(width: CGFloat, currentRowViews views: [LayoutSubviews.Element], bounds: CGRect? = nil) -> CGFloat { |
|
class ViewInfo { |
|
let view: LayoutSubviews.Element |
|
let minWidth: CGFloat |
|
let idealWidth: CGFloat |
|
let maxWidth: CGFloat |
|
var width: CGFloat |
|
|
|
init(view: LayoutSubviews.Element) { |
|
self.view = view |
|
self.minWidth = HFlexBoxLayout.minWidth(view) |
|
self.idealWidth = HFlexBoxLayout.idealWidth(view) |
|
self.maxWidth = HFlexBoxLayout.maxWidth(view) |
|
self.width = self.minWidth |
|
} |
|
} |
|
let viewInfos = views.map { ViewInfo(view: $0) } |
|
|
|
|
|
while true { |
|
let delta: CGFloat = width - viewInfos.map(\.width).sum() as CGFloat - horizontalSpacing * Double(views.count - 1) |
|
if delta <= 0.1 { |
|
break |
|
} |
|
let viewsToResize = viewInfos.filter({$0.width < $0.idealWidth}) |
|
if viewsToResize.isEmpty { |
|
break |
|
} |
|
for view in viewsToResize { |
|
view.width = min(view.idealWidth, view.width + delta / Double(viewsToResize.count)) |
|
} |
|
} |
|
|
|
while true { |
|
let delta: CGFloat = width - viewInfos.map(\.width).sum() as CGFloat - horizontalSpacing * Double(views.count - 1) |
|
if delta <= 0.1 { |
|
break |
|
} |
|
let viewsToResize = viewInfos.filter({$0.width < $0.maxWidth}) |
|
if viewsToResize.isEmpty { |
|
break |
|
} |
|
for view in viewsToResize { |
|
view.width = min(view.maxWidth, view.width + delta / Double(viewsToResize.count)) |
|
} |
|
} |
|
|
|
var height = 0.0 |
|
|
|
for viewInfo in viewInfos { |
|
let size = viewInfo.view.sizeThatFits(ProposedViewSize(width: viewInfo.width, height: .infinity)) |
|
height = max(height, size.height) |
|
} |
|
|
|
if let bounds { |
|
let delta: CGFloat = width - viewInfos.map(\.width).sum() as CGFloat |
|
let spacing: CGFloat |
|
var x: CGFloat |
|
switch horizontalAlignment { |
|
case .leading: |
|
spacing = horizontalSpacing |
|
x = 0.0 |
|
case .center: |
|
spacing = horizontalSpacing |
|
x = (delta - spacing * Double(viewInfos.count - 1)) / 2.0 |
|
case .between: |
|
spacing = delta / Double(viewInfos.count - 1) |
|
x = 0.0 |
|
case .trailing: |
|
spacing = horizontalSpacing |
|
x = delta - spacing * Double(viewInfos.count - 1) |
|
} |
|
|
|
let y: CGFloat |
|
let anchor: UnitPoint |
|
|
|
switch innerRowAlignment { |
|
case .top: |
|
y = bounds.minY |
|
anchor = .topLeading |
|
case .center: |
|
y = bounds.minY + height / 2 |
|
anchor = .leading |
|
case .bottom: |
|
y = bounds.minY + height |
|
anchor = .bottomLeading |
|
} |
|
|
|
for viewInfo in viewInfos { |
|
viewInfo.view.place(at: CGPoint(x: x + bounds.minX, y: y), anchor: anchor, proposal: ProposedViewSize(width: viewInfo.width, height: height)) |
|
x += viewInfo.width |
|
x += spacing |
|
} |
|
} |
|
|
|
return height |
|
} |
|
|
|
|
|
func layoutCalc(proposal: ProposedViewSize, subviews: Subviews, bounds: CGRect? = nil) -> CGSize { |
|
let width = proposal.replacingUnspecifiedDimensions().width |
|
|
|
var height = 0.0 |
|
|
|
var currentRowWidth = 0.0 |
|
|
|
var currentRowViews: [LayoutSubviews.Element] = [] |
|
for subview in subviews { |
|
let idealWidth = Self.idealWidth(subview) |
|
if !currentRowViews.isEmpty && (currentRowWidth + horizontalSpacing + idealWidth) > width { |
|
if height > 0 { |
|
height += verticalSpacing |
|
} |
|
let currentRowHeight = rowCalc(width: width, currentRowViews: currentRowViews, bounds: bounds?.inset(by: UIEdgeInsets(top: height, left: 0, bottom: 0, right: 0))) |
|
height += currentRowHeight |
|
currentRowWidth = 0 |
|
currentRowViews.removeAll() |
|
} |
|
if !currentRowViews.isEmpty { |
|
currentRowWidth += horizontalSpacing |
|
} |
|
currentRowViews.append(subview) |
|
currentRowWidth += idealWidth |
|
} |
|
if !currentRowViews.isEmpty { |
|
if height > 0 { |
|
height += verticalSpacing |
|
} |
|
let currentRowHeight = rowCalc(width: width, currentRowViews: currentRowViews, bounds: bounds?.inset(by: UIEdgeInsets(top: height, left: 0, bottom: 0, right: 0))) |
|
height += currentRowHeight |
|
currentRowWidth = 0 |
|
} |
|
|
|
return CGSize(width: width, height: height) |
|
} |
|
|
|
|
|
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { |
|
let _ = layoutCalc(proposal: proposal, subviews: subviews, bounds: bounds) |
|
} |
|
} |
|
|
|
#Preview { |
|
VStack(spacing: 20) { |
|
HFlexBoxLayout() { |
|
Text("Foo and foo") |
|
.frame(minWidth: 4, maxWidth: .infinity) |
|
.background(.yellow) |
|
|
|
|
|
Text("Foo2") |
|
.background(.orange) |
|
VStack { |
|
Text("Foo3") |
|
Text("Foo4") |
|
} |
|
.background(.green) |
|
} |
|
.frame(width: 100) |
|
.background(.red) |
|
|
|
HFlexBoxLayout(verticalSpacing: 0, horizontalAlignment: .between) { |
|
Text("Foo and foo") |
|
.frame(minWidth: 4, maxWidth: .infinity) |
|
.background(.yellow) |
|
|
|
|
|
Text("Foo2") |
|
.background(.orange) |
|
VStack { |
|
Text("Foo3") |
|
Text("Foo4") |
|
} |
|
.background(.green) |
|
} |
|
.frame(width: 100) |
|
.background(.pink) |
|
|
|
HFlexBoxLayout(horizontalSpacing: 0, horizontalAlignment: .center) { |
|
Text("Foo and foo") |
|
.frame(minWidth: 4, maxWidth: .infinity) |
|
.background(.yellow) |
|
|
|
Text("Foo2") |
|
.background(.orange) |
|
VStack { |
|
Text("Foo3") |
|
Text("Foo4") |
|
} |
|
.background(.green) |
|
} |
|
.padding(5) |
|
.frame(width: 100) |
|
.background(.blue) |
|
|
|
HFlexBoxLayout(horizontalSpacing: 0, horizontalAlignment: .center, innerRowAlignment: .center) { |
|
Text("Foo and foo") |
|
.frame(minWidth: 4, maxWidth: .infinity) |
|
.background(.yellow) |
|
|
|
|
|
Text("Foo2") |
|
.background(.orange) |
|
VStack { |
|
Text("Foo3") |
|
Text("Foo4") |
|
} |
|
.background(.green) |
|
} |
|
.frame(width: 90) |
|
.padding(5) |
|
.background(.red) |
|
|
|
HFlexBoxLayout(horizontalSpacing: 0, horizontalAlignment: .trailing, innerRowAlignment: .bottom) { |
|
Text("Foo and foo") |
|
.frame(minWidth: 4, maxWidth: .infinity) |
|
.background(.yellow) |
|
|
|
|
|
Text("Foo2") |
|
.background(.orange) |
|
VStack { |
|
Text("Foo3") |
|
Text("Foo4") |
|
} |
|
.background(.green) |
|
} |
|
.frame(width: 210) |
|
.background(.cyan) |
|
} |
|
} |