We will make a custom layout that arranges elements from top leading and span element to new rows if it can't fit the current row.
Start by defining a FlowLayout struct that conforms to Layout protocol. We will define a helper Arranger to do common arrangement logic.
To normalize the proposal size, we can use replacingUnspecifiedDimensions to default any dimension to default size to avoid infinite value.
In the placeSubviews, we get access to the bounds with size returned from sizeThatFits. Note that our Layout can have .padding so we use bounds.minX and bounds.minY instead of (0, 0)
struct FlowLayout: Layout {
var spacing: CGFloat
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
let arranger = Arranger(
containerSize: proposal.replacingUnspecifiedDimensions(),
subviews: subviews,
spacing: spacing
)
let result = arranger.arrange()
return result.size
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
let arranger = Arranger(
containerSize: proposal.replacingUnspecifiedDimensions(),
subviews: subviews,
spacing: spacing
)
let result = arranger.arrange()
for (index, cell) in result.cells.enumerated() {
let point = CGPoint(
x: bounds.minX + cell.frame.origin.x,
y: bounds.minY + cell.frame.origin.y
)
subviews[index].place(
at: point,
anchor: .topLeading,
proposal: ProposedViewSize(cell.frame.size)
)
}
}
}
Our helper Arranger calls sizeThatFits to get the size of each subview, then based on that we will compare with containerSize to check if we can place the element to the current row, or place it to the next row
struct Arranger {
var containerSize: CGSize
var subviews: Subviews
var spacing: CGFloat
func arrange() -> Result {
var cells: [Cell] = []
var maxY: CGFloat = 0
var previousFrame: CGRect = .zero
for (index, subview) in subviews.enumerated() {
let size = subview.sizeThatFits(ProposedViewSize(containerSize))
var origin: CGPoint
if index == 0 {
origin = .zero
} else if previousFrame.maxX + spacing + size.width > containerSize.width {
origin = CGPoint(x: 0, y: maxY + spacing)
} else {
origin = CGPoint(x: previousFrame.maxX + spacing, y: previousFrame.minY)
}
let frame = CGRect(origin: origin, size: size)
let cell = Cell(frame: frame)
cells.append(cell)
previousFrame = frame
maxY = max(maxY, frame.maxY)
}
let maxWidth = cells.reduce(0, { max($0, $1.frame.maxX) })
return Result(
size: CGSize(width: maxWidth, height: previousFrame.maxY),
cells: cells
)
}
}
struct Result {
var size: CGSize
var cells: [Cell]
}
struct Cell {
var frame: CGRect
}
Here is our FlowLayout in action
struct ContentView: View {
let string = "You can’t connect the dots looking forward; you can only connect them looking backwards. So you have to trust that the dots will somehow connect in your future."
var body: some View {
let tags = string
.components(separatedBy: " ")
.unique
ScrollView {
FlowLayout(spacing: 12) {
ForEach(tags, id: \.self) { string in
Text(string)
.padding(4)
.background(Color.green.opacity(0.5), in: Capsule())
}
}
.padding()
}
}
}
SwiftUI in iOS 16 supports Layout protocol to arrange subviews
We need to implement 2 methods
We will make a custom layout that arranges elements from top leading and span element to new rows if it can't fit the current row.
Start by defining a FlowLayout struct that conforms to Layout protocol. We will define a helper Arranger to do common arrangement logic.
To normalize the proposal size, we can use
replacingUnspecifiedDimensions
to default any dimension to default size to avoid infinite value.In the
placeSubviews
, we get access to thebounds
with size returned fromsizeThatFits
. Note that our Layout can have.padding
so we usebounds.minX
andbounds.minY
instead of (0, 0)Our helper Arranger calls
sizeThatFits
to get the size of each subview, then based on that we will compare withcontainerSize
to check if we can place the element to the current row, or place it to the next rowHere is our FlowLayout in action