Articles about Swift development, by Konstantin Semianov

Building a wrapping HStack with the SwiftUI Layout protocol

Animation

The component we are about to make is available as a Swift Package.

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.

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

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

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

HStack overflow

You go ahead and make it horizontally scrollable.

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

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.

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.

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.

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.

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.

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.

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.

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.

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.

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

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.

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

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