146 lines
5.4 KiB
Swift
146 lines
5.4 KiB
Swift
|
import SwiftUI
|
||
|
|
||
|
@available(iOS 16.0, *)
|
||
|
struct FlowLayout: Layout {
|
||
|
var alignment: Alignment = .center
|
||
|
var spacing: CGFloat?
|
||
|
|
||
|
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) -> CGSize {
|
||
|
let result = FlowResult(
|
||
|
in: proposal.replacingUnspecifiedDimensions().width,
|
||
|
subviews: subviews,
|
||
|
alignment: alignment,
|
||
|
spacing: spacing
|
||
|
)
|
||
|
return result.bounds
|
||
|
}
|
||
|
|
||
|
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) {
|
||
|
let result = FlowResult(
|
||
|
in: proposal.replacingUnspecifiedDimensions().width,
|
||
|
subviews: subviews,
|
||
|
alignment: alignment,
|
||
|
spacing: spacing
|
||
|
)
|
||
|
for row in result.rows {
|
||
|
let rowXOffset = (bounds.width - row.frame.width) * alignment.horizontal.percent
|
||
|
for index in row.range {
|
||
|
let xPos = rowXOffset + row.frame.minX + row.xOffsets[index - row.range.lowerBound] + bounds.minX
|
||
|
let rowYAlignment = (row.frame.height - subviews[index].sizeThatFits(.unspecified).height) *
|
||
|
alignment.vertical.percent
|
||
|
let yPos = row.frame.minY + rowYAlignment + bounds.minY
|
||
|
subviews[index].place(at: CGPoint(x: xPos, y: yPos), anchor: .topLeading, proposal: .unspecified)
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
struct FlowResult {
|
||
|
var bounds = CGSize.zero
|
||
|
var rows = [Row]()
|
||
|
|
||
|
struct Row {
|
||
|
var range: Range<Int>
|
||
|
var xOffsets: [Double]
|
||
|
var frame: CGRect
|
||
|
}
|
||
|
|
||
|
init(in maxPossibleWidth: Double, subviews: Subviews, alignment: Alignment, spacing: CGFloat?) {
|
||
|
var itemsInRow = 0
|
||
|
var remainingWidth = maxPossibleWidth.isFinite ? maxPossibleWidth : .greatestFiniteMagnitude
|
||
|
var rowMinY = 0.0
|
||
|
var rowHeight = 0.0
|
||
|
var xOffsets: [Double] = []
|
||
|
for (index, subview) in zip(subviews.indices, subviews) {
|
||
|
let idealSize = subview.sizeThatFits(.unspecified)
|
||
|
if index != 0 && widthInRow(index: index, idealWidth: idealSize.width) > remainingWidth {
|
||
|
// Finish the current row without this subview.
|
||
|
finalizeRow(index: max(index - 1, 0), idealSize: idealSize)
|
||
|
}
|
||
|
addToRow(index: index, idealSize: idealSize)
|
||
|
|
||
|
if index == subviews.count - 1 {
|
||
|
// Finish this row; it's either full or we're on the last view anyway.
|
||
|
finalizeRow(index: index, idealSize: idealSize)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
func spacingBefore(index: Int) -> Double {
|
||
|
guard itemsInRow > 0 else { return 0 }
|
||
|
return spacing ?? subviews[index - 1].spacing.distance(to: subviews[index].spacing, along: .horizontal)
|
||
|
}
|
||
|
|
||
|
func widthInRow(index: Int, idealWidth: Double) -> Double {
|
||
|
idealWidth + spacingBefore(index: index)
|
||
|
}
|
||
|
|
||
|
func addToRow(index: Int, idealSize: CGSize) {
|
||
|
let width = widthInRow(index: index, idealWidth: idealSize.width)
|
||
|
|
||
|
xOffsets.append(maxPossibleWidth - remainingWidth + spacingBefore(index: index))
|
||
|
// Allocate width to this item (and spacing).
|
||
|
remainingWidth -= width
|
||
|
// Ensure the row height is as tall as the tallest item.
|
||
|
rowHeight = max(rowHeight, idealSize.height)
|
||
|
// Can fit in this row, add it.
|
||
|
itemsInRow += 1
|
||
|
}
|
||
|
|
||
|
func finalizeRow(index: Int, idealSize: CGSize) {
|
||
|
let rowWidth = maxPossibleWidth - remainingWidth
|
||
|
rows.append(
|
||
|
Row(
|
||
|
range: index - max(itemsInRow - 1, 0) ..< index + 1,
|
||
|
xOffsets: xOffsets,
|
||
|
frame: CGRect(x: 0, y: rowMinY, width: rowWidth, height: rowHeight)
|
||
|
)
|
||
|
)
|
||
|
bounds.width = max(bounds.width, rowWidth)
|
||
|
let ySpacing = spacing ?? ViewSpacing().distance(to: ViewSpacing(), along: .vertical)
|
||
|
bounds.height += rowHeight + (rows.count > 1 ? ySpacing : 0)
|
||
|
rowMinY += rowHeight + ySpacing
|
||
|
itemsInRow = 0
|
||
|
rowHeight = 0
|
||
|
xOffsets.removeAll()
|
||
|
remainingWidth = maxPossibleWidth
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
private extension HorizontalAlignment {
|
||
|
var percent: Double {
|
||
|
switch self {
|
||
|
case .leading: return 0
|
||
|
case .trailing: return 1
|
||
|
default: return 0.5
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
private extension VerticalAlignment {
|
||
|
var percent: Double {
|
||
|
switch self {
|
||
|
case .top: return 0
|
||
|
case .bottom: return 1
|
||
|
default: return 0.5
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
struct FlowLayout_Previews: PreviewProvider {
|
||
|
static var previews: some View {
|
||
|
FlowLayout(alignment: .leading) {
|
||
|
ForEach(["Swift", "C", "C++", "Python"]) { tag in
|
||
|
Text(tag)
|
||
|
.fontWeight(.light)
|
||
|
.padding(.horizontal, 8)
|
||
|
.padding(.vertical, 3)
|
||
|
.background(
|
||
|
RoundedRectangle(cornerRadius: 8)
|
||
|
.fill(Color.gray.opacity(0.1))
|
||
|
)
|
||
|
}
|
||
|
}
|
||
|
.previewLayout(.fixed(width: 200, height: 150))
|
||
|
}
|
||
|
}
|