Skip to content

Instantly share code, notes, and snippets.

@BrentMifsud
Last active December 18, 2023 22:19
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save BrentMifsud/f24a7ddded6c03c6cf7f1cd3b28a8d16 to your computer and use it in GitHub Desktop.
Save BrentMifsud/f24a7ddded6c03c6cf7f1cd3b28a8d16 to your computer and use it in GitHub Desktop.
A view that renders its children in a flow layout
import SwiftUI
/// A layout that presents its children in a flow layout
/// Thanks to [objc.io](https://talk.objc.io/episodes/S01E308-the-layout-protocol?t=489) for the starting point
/// Added some additional changes here to support view spacing and resizing of subviews that are larger than the container.
@available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *)
public struct FlowLayout: Layout {
private let spacing: CGFloat
public init(spacing: CGFloat = 8) {
self.spacing = spacing
}
public func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) -> CGSize {
let containerProposal = proposal.replacingUnspecifiedDimensions()
let sizes = subviewSizes(
containerSize: CGSize(width: containerProposal.width, height: containerProposal.height),
subviews: subviews
)
let layoutSizes = layout(sizes: sizes, spacing: spacing, containerWidth: containerProposal.width)
return layoutSizes.size
}
public func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) {
let sizes = subviewSizes(
containerSize: CGSize(width: bounds.width, height: bounds.height),
subviews: subviews
)
let offsets = layout(sizes: sizes, spacing: spacing, containerWidth: bounds.width).offsets
for (offset, subview) in zip(offsets, subviews) {
subview.place(
at: CGPoint(x: offset.x + bounds.minX, y: offset.y + bounds.minY),
proposal: .init(width: bounds.width, height: .infinity)
)
}
}
private func subviewSizes(containerSize: CGSize, subviews: Subviews) -> [CGSize] {
subviews.map {
let dimensions = $0.sizeThatFits(.init(width: containerSize.width, height: .infinity))
return CGSize(
width: min(dimensions.width, containerSize.width),
height: dimensions.height
)
}
}
private func layout(
sizes: [CGSize],
spacing: CGFloat = 10,
containerWidth: CGFloat
) -> (offsets: [CGPoint], size: CGSize) {
var result: [CGPoint] = []
var currentPosition: CGPoint = .zero
var lineHeight: CGFloat = 0
var maxX: CGFloat = 0
for size in sizes {
if currentPosition.x + size.width > containerWidth {
currentPosition.x = 0
currentPosition.y += lineHeight + spacing
lineHeight = 0
}
result.append(currentPosition)
currentPosition.x += size.width
maxX = max(maxX, currentPosition.x)
currentPosition.x += spacing
lineHeight = max(lineHeight, size.height)
}
return (
result,
CGSize(width: maxX, height: currentPosition.y + lineHeight)
)
}
}
/// A view that presents its children in a flow layout
/// Thanks to [objc.io](https://talk.objc.io/episodes/S01E253-flow-layout-revisited) for this
@available(iOS 15.0, deprecated: 16.0, description: "Use FlowLayout Instead")
@available(tvOS 15.0, deprecated: 16.0, description: "Use FlowLayout Instead")
@available(watchOS 8.0, deprecated: 9.0, description: "Use FlowLayout Instead")
@available(macOS 12.0, deprecated: 13.0, description: "Use FlowLayout Instead")
public struct Flow<Element: Identifiable, Cell: View>: View {
@Binding private var items: [Element]
private var spacing: Double
private var cell: (Binding<Element>) -> Cell
@State private var sizes: [CGSize] = []
@State private var containerWidth: Double = 0
public init(items: Binding<[Element]>, spacing: Double = 8, cell: @escaping (Binding<Element>) -> Cell) {
self._items = items
self.spacing = spacing
self.cell = cell
}
public init(items: [Element], spacing: Double = 8, cell: @escaping (Element) -> Cell) {
self.init(items: .constant(items), spacing: spacing) { itemBinding in
cell(itemBinding.wrappedValue)
}
}
public var body: some View {
let laidout = layout(
sizes: sizes,
spacing: CGSize(width: spacing, height: spacing),
containerWidth: containerWidth
)
VStack(alignment: .leading, spacing: 0) {
GeometryReader { proxy in
Color.clear.preference(key: SizeKey.self, value: [proxy.size])
}
.onPreferenceChange(SizeKey.self) { value in
self.containerWidth = value[0].width
}
.frame(height: 0)
ZStack(alignment: .topLeading) {
ForEach(Array(zip($items, items.indices)), id: \.0.id) { item, index in
cell(item)
.fixedSize()
.background {
GeometryReader { proxy in
Color.clear.preference(key: SizeKey.self, value: [proxy.size])
}
}
.alignmentGuide(.leading) { _ in
guard !laidout.isEmpty else {
return 0
}
return -laidout[index].x
}
.alignmentGuide(.top) { _ in
guard !laidout.isEmpty else {
return 0
}
return -laidout[index].y
}
}
}
.onPreferenceChange(SizeKey.self) { value in
self.sizes = value
}
.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
}
}
func layout(sizes: [CGSize], spacing: CGSize, containerWidth: CGFloat) -> [CGPoint] {
var currentPoint: CGPoint = .zero
var result: [CGPoint] = []
var lineHeight: CGFloat = 0
for size in sizes {
if currentPoint.x + size.width > containerWidth {
currentPoint.x = 0
currentPoint.y += lineHeight + spacing.height
}
result.append(currentPoint)
currentPoint.x += size.width + spacing.width
lineHeight = max(lineHeight, size.height)
}
return result
}
}
private struct SizeKey: PreferenceKey {
static let defaultValue: [CGSize] = []
static func reduce(value: inout Value, nextValue: () -> Value) {
value.append(contentsOf: nextValue())
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment