Issue #944

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 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()
        }
    }
}