Created
April 14, 2025 16:11
-
-
Save trenskow/25f29969d41ec15e0e19a7e9efbff8eb to your computer and use it in GitHub Desktop.
Flow layout SwiftUI
This file contains hidden or 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 Flow: Layout { | |
struct PartialRect { | |
let subview: LayoutSubview | |
let x: CGFloat | |
let size: CGSize | |
} | |
typealias Cache = [[CGRect]] | |
let lineAlignment: Alignment | |
let spacing: CGSize | |
init( | |
lineAlignment alignment: Alignment = .topLeading, | |
spacing: CGSize | |
) { | |
self.lineAlignment = alignment | |
self.spacing = spacing | |
} | |
func makeCache( | |
subviews: Subviews | |
) -> Cache { | |
return Cache() | |
} | |
func sizeThatFits( | |
proposal: ProposedViewSize, | |
subviews: Subviews, | |
cache: inout Cache | |
) -> CGSize { | |
cache = [] | |
let width = proposal.width ?? subviews | |
.reduce(0) { max($0, $1.sizeThatFits(proposal).width) } | |
var offsetX: CGFloat = 0 | |
var offsetY: CGFloat = 0 | |
var currentLine: [PartialRect] = [] | |
for subview in subviews { | |
let subviewWidth = min(width, subview.sizeThatFits(proposal).width) | |
if offsetX + subviewWidth > width { | |
offsetY += self.commit( | |
proposal: proposal, | |
line: currentLine, | |
offsetY: offsetY, | |
width: width, | |
cache: &cache) | |
offsetX = 0 | |
currentLine = [] | |
} | |
let viewWidth = min(width, subview.sizeThatFits(proposal).width) | |
let viewHeight = subview.sizeThatFits(proposal).height | |
currentLine.append( | |
PartialRect( | |
subview: subview, | |
x: offsetX, | |
size: CGSize( | |
width: viewWidth, | |
height: viewHeight))) | |
offsetX += viewWidth + self.spacing.width | |
} | |
if !currentLine.isEmpty { | |
self.commit( | |
proposal: proposal, | |
line: currentLine, | |
offsetY: offsetY, | |
width: width, | |
cache: &cache) | |
} | |
return CGSize( | |
width: cache.reduce([], +) | |
.reduce(0) { width, line in | |
return max(width, line.maxX) | |
}, | |
height: cache.reduce([], +) | |
.reduce(0) { height, line in | |
return max(height, line.maxY) | |
}) | |
} | |
func placeSubviews( | |
in bounds: CGRect, | |
proposal: ProposedViewSize, | |
subviews: Subviews, | |
cache: inout Cache | |
) { | |
let allCache = cache.reduce([], +) | |
for idx in 0..<subviews.count { | |
let subview = subviews[idx] | |
let rect = allCache[idx] | |
subview.place( | |
at: CGPoint( | |
x: bounds.origin.x + rect.origin.x, | |
y: bounds.origin.y + rect.origin.y), | |
proposal: .init( | |
rect.size)) | |
} | |
} | |
@discardableResult | |
private func commit( | |
proposal: ProposedViewSize, | |
line: [PartialRect], | |
offsetY: CGFloat, | |
width: CGFloat, | |
cache: inout Cache | |
) -> CGFloat { | |
let height = line | |
.reduce(0) { max($0, $1.size.height) } | |
let diffX = width - line | |
.reduce(0) { max($0, $1.x + $1.size.width) } | |
var offsetX: CGFloat | |
switch self.lineAlignment.horizontal { | |
case .trailing, .listRowSeparatorTrailing: | |
offsetX = diffX | |
case .center: | |
offsetX = diffX / 2 | |
default: | |
offsetX = 0 | |
} | |
var cacheLine: [CGRect] = [] | |
for partialRect in line { | |
let lineDiffY = height - partialRect.size.height | |
var lineOffsetY: CGFloat | |
switch self.lineAlignment.vertical { | |
case .bottom: | |
lineOffsetY = lineDiffY | |
case .center: | |
lineOffsetY = lineDiffY / 2 | |
default: | |
lineOffsetY = 0 | |
} | |
cacheLine.append( | |
CGRect( | |
x: partialRect.x + offsetX, | |
y: offsetY + lineOffsetY, | |
width: partialRect.size.width, | |
height: partialRect.size.height)) | |
} | |
cache.append( | |
cacheLine) | |
return height + self.spacing.height | |
} | |
} | |
extension Flow { | |
init( | |
lineAlignment alignment: Alignment = .topLeading, | |
spacingHorizontal: CGFloat = 0, | |
spacingVertical: CGFloat = 0 | |
) { | |
self.init( | |
lineAlignment: alignment, | |
spacing: CGSize( | |
width: spacingHorizontal, | |
height: spacingVertical)) | |
} | |
init( | |
lineAlignment alignment: Alignment = .topLeading, | |
spacing: CGFloat | |
) { | |
self.init( | |
lineAlignment: alignment, | |
spacing: CGSize( | |
width: spacing, | |
height: spacing)) | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment