Building a wrapping HStack with the SwiftUI Layout protocol
Tags: swift, tutorial, swiftui, layout
Published: 2022-12-07 07:40:15

The component we are about to make is available as a [Swift Package](https://github.com/ksemianov/WrappingHStack).

## Intro

It's Monday morning and your project manager gives you a task to add a list of tags to the product details page. You say "easy" and whip up the following in 10 minutes.

```swift
HStack {
    ForEach(tags) {
        TagView(text: $0.text)
    }

    Spacer(minLength: .zero)
}.padding(.horizontal)
```

![HStack](https://ksemianov.github.io/images/WrappingHStack/HStack.png)

After the QA team takes a look they report a bug when there are many tags.

![HStack overflow](https://ksemianov.github.io/images/WrappingHStack/HStack-overflow.png)

You go ahead and make it horizontally scrollable.

```swift
ScrollView(.horizontal, showsIndicators: false) {
    LazyHStack {
        ForEach(tags) {
            TagView(text: $0.text)
        }
    }.padding(.horizontal)
}.frame(height: 56)
```

![HStack scrollable](https://ksemianov.github.io/images/WrappingHStack/HStack-scrollable.png)

The disadvantage is that you have to know the height of tag views in advance.

The design team asks to wrap tags on subsequent lines when they do not fit the view width. And now your "easy" turned into "hard". One of your colleagues suggests wrapping a custom `UICollectionView` with a `UIViewRepresentable`. And another to try the new `Layout` protocol. You decide to go with `Layout`...

## Layout protocol

The protocol has 2 requirements:

- `sizeThatFits` controls how much space the view needs
- `placeSubviews` controls the placement of subviews within the available space

Note, that `sizeThatFits` may be called multiple times during the layout process. It will try different size proposals. At the time of writing, on iOS it will normally just try to pass all available space. On macOS it will also try a `.zero` size proposal so that the minimum window size may be computed. Thus, to support macOS, we'll need to compute the minimal size of the view.

## Approach outline

We'll take the minimum size to be the maximum size of subviews given a `.zero` proposal. Whenever the proposal is less than the minimum size, we'll just return the minimum size early.


```swift
func minSize(subviews: Subviews) -> CGSize {
    subviews
        .map { $0.sizeThatFits(.zero) }
        .reduce(CGSize.zero) { CGSize(width: max($0.width, $1.width), height: max($0.height, $1.height)) }
}
```

To compute both size and placements we'll need to arrange the subviews into rows first. The main idea is to iterate over subviews and increment X coordinate by subview's width + horizontal spacing if it will still fit into container width, or go to the next row, otherwise. This will allow us to get X offsets for all subviews. Then we'll iterate over rows and increment Y coordinate by max height subview's height + vertical spacing. This will allow us to get Y offsets for all rows.

Once we have the row arrangements, the width will fill all the available space.

```swift
let width = proposal.width ?? rows.map { $0.width }.reduce(.zero) { max($0, $1) }
```

And the height will be the vertical offset of the last row + its height.

```swift
var height: CGFloat = .zero
if let lastRow = rows.last {
    height = lastRow.yOffset + lastRow.height
}
```

The subviews will be placed at their corresponding offsets + bounds min point.

```swift
for row in rows {
    for element in row.elements {
        let x: CGFloat = element.xOffset
        let y: CGFloat = row.yOffset
        let point = CGPoint(x: x + bounds.minX, y: y + bounds.minY)

        subviews[element.index].place(at: point, anchor: .topLeading, proposal: proposal)
    }
}
```

## Row arrangement

For each row, we'll need to know subview indices, sizes and X offsets. Also, overall row Y offset, row width and height.

```swift
struct Row {
    var elements: [(index: Int, size: CGSize, xOffset: CGFloat)] = []
    var yOffset: CGFloat = .zero
    var width: CGFloat = .zero
    var height: CGFloat = .zero
}

func arrangeRows(proposal: ProposedViewSize,
                         subviews: Subviews,
                         cache: inout ()) -> [Row] {
    let minSize = minSize(subviews: subviews)
    if minSize.width > proposal.width ?? .infinity,
       minSize.height > proposal.height ?? .infinity {
        return []
    }

    let sizes = subviews.map { $0.sizeThatFits(proposal) }

    var currentX = CGFloat.zero
    var currentRow = Row()
    var rows = [Row]()

    for index in subviews.indices {
        var spacing = CGFloat.zero
        if let previousIndex = currentRow.elements.last?.index {
            spacing = horizontalSpacing(subviews[previousIndex], subviews[index])
        }

        let size = sizes[index]

        if currentX + size.width + spacing > proposal.width ?? .infinity,
           !currentRow.elements.isEmpty {
            currentRow.width = currentX
            rows.append(currentRow)
            currentRow = Row()
            spacing = .zero
            currentX = .zero
        }

        currentRow.elements.append((index, sizes[index], currentX + spacing))
        currentX += size.width + spacing
    }

    currentRow.width = currentX
    rows.append(currentRow)

    var currentY = CGFloat.zero
    var previousMaxHeightIndex: Int?

    for index in rows.indices {
        let maxHeightIndex = rows[index].elements
            .max { $0.size.height < $1.size.height }!
            .index

        let size = sizes[maxHeightIndex]

        var spacing = CGFloat.zero
        if let previousMaxHeightIndex {
            spacing = verticalSpacing(subviews[previousMaxHeightIndex], subviews[maxHeightIndex])
        }

        rows[index].yOffset = currentY + spacing
        currentY += size.height + spacing
        rows[index].height = size.height
        previousMaxHeightIndex = maxHeightIndex
    }

    return rows
}
```

## Spacing

We'll allow a horizontal and vertical spacing overrides or use system provided spacings if `nil`. `LayoutSubview` proxy allows to get the system spacing for a pair of subviews.

```swift
func horizontalSpacing(_ lhs: LayoutSubview, _ rhs: LayoutSubview) -> CGFloat {
    if let horizontalSpacing { return horizontalSpacing }

    return lhs.spacing.distance(to: rhs.spacing, along: .horizontal)
}

func verticalSpacing(_ lhs: LayoutSubview, _ rhs: LayoutSubview) -> CGFloat {
    if let verticalSpacing { return verticalSpacing }

    return lhs.spacing.distance(to: rhs.spacing, along: .vertical)
}
```

## Layout properties

The `Layout` protocol has an optional `layoutProperties` parameter that allows controlling `StackOrientation`. It affects the way `Spacer` and `Divider` are treated. For example, with `stackOrientation = .horizontal` the `Spacer` will only expand horizontally. Thus it will allow enforcing a line (row) break in the container. It has a caveat that it will have double spacing between the split rows and the default system spacing will be zero.

```swift
static var layoutProperties: LayoutProperties {
    var properties = LayoutProperties()
    properties.stackOrientation = .horizontal

    return properties
}
```

## Alignment

We'll allow controlling the alignment value inside the container. Except `Layout` protocol doesn't provide an easy way to implement various text baseline alignment values: `.leadingFirstTextBaseline`, `.centerLastTextBaseline`, etc. The rest of the values correspond to `UnitPoint` values.

```swift
extension UnitPoint {
    init(_ alignment: Alignment) {
        switch alignment {
        case .leading:
            self = .leading
        case .topLeading:
            self = .topLeading
        case .top:
            self = .top
        case .topTrailing:
            self = .topTrailing
        case .trailing:
            self = .trailing
        case .bottomTrailing:
            self = .bottomTrailing
        case .bottom:
            self = .bottom
        case .bottomLeading:
            self = .bottomLeading
        default:
            self = .center
        }
    }
}

let anchor = UnitPoint(alignment)
```

We'll need to make a correction in `placeSubviews` with the anchor value.
```swift
let xCorrection = anchor.x * (bounds.width - row.width)
let yCorrection = anchor.y * (row.height - element.size.height)
```

## Caching

We'll cache the minimal size of the container and row arrangements to improve performance. The row arrangement depends on both proposal and subview sizes. Whenever these change, the row arrangement should be recomputed

```swift
struct Cache {
    var minSize: CGSize
    var rows: (Int, [Row])?
}

func makeCache(subviews: Subviews) -> Cache {
    Cache(minSize: minSize(subviews: subviews))
}

func updateCache(_ cache: inout Cache, subviews: Subviews) {
    cache.minSize = minSize(subviews: subviews)
}

func computeHash(proposal: ProposedViewSize, sizes: [CGSize]) -> Int {
    let proposal = proposal.replacingUnspecifiedDimensions(by: .infinity)

    var hasher = Hasher()

    for size in [proposal] + sizes {
        hasher.combine(size.width)
        hasher.combine(size.height)
    }

    return hasher.finalize()
}

// In `arrangeRows` beginning
let hash = computeHash(proposal: proposal, sizes: sizes)
if let (oldHash, oldRows) = cache.rows,
   oldHash == hash {
    return oldRows
}

// In `arrangeRows` end
cache.rows = (hash, rows)
```

## Usage

After all this work we can finally redefine our tag list.

```swift
WrappingHStack(alignment: .leading) {
    ForEach(tags) {
        TagView(text: $0.text)
    }
}.padding()
```

![WrappingHStack](https://ksemianov.github.io/images/WrappingHStack/WrappingHStack.png)


## Limitations

The container, by design, doesn't support subviews that grow infinitely in the vertical axis. How would you even define the height in this case?

## Final thoughts

We've written our wrapping hstack (sometimes also called flow layout) container in a universal way that can handle a wide variety of subviews. It definitely wasn't easy and we can now appreciate the simplicity with which we use standard `HStack` and `VStack`.

See the full code at [https://github.com/ksemianov/WrappingHStack](https://github.com/ksemianov/WrappingHStack)

Originally posted on [https://ksemianov.github.io/articles/wrapping-hstack/](https://ksemianov.github.io/articles/wrapping-hstack/)