Skip to content

Instantly share code, notes, and snippets.

@ChristophKaser
Last active April 26, 2024 08:08
Show Gist options
  • Save ChristophKaser/495fdb144a86f27eb0adecac788d9eaa to your computer and use it in GitHub Desktop.
Save ChristophKaser/495fdb144a86f27eb0adecac788d9eaa to your computer and use it in GitHub Desktop.
Horizontal Wrapping Flexbox Layout in SwiftUI

Wraps views into a new row if they don't match in the current width.

Respects minWidth, idealWidth and maxWidth.

There is no equivalent to flex-shrink and flex-grow, all views grow the same amount until their max-width is reached. If needed, this can be changed.

//
// 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)
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment