Issue #944
SwiftUI in iOS 16 supports Layout protocol to arrange subviews
We need to implement 2 methods
- sizeThatFits(proposal:subviews:cache:) reports the size of the composite layout view.
- placeSubviews(in:proposal:subviews:cache:) assigns positions to the container’s subviews.
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()
}
}
}