Last active
December 18, 2023 22:19
-
-
Save BrentMifsud/f24a7ddded6c03c6cf7f1cd3b28a8d16 to your computer and use it in GitHub Desktop.
A view that renders its children in a flow layout
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 | |
/// 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