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)
After the QA team takes a look they report a bug when there are many tags.
You go ahead and make it horizontally scrollable.
ScrollView(.horizontal, showsIndicators: false) {
LazyHStack {
ForEach(tags) {
TagView(text: $0.text)
}
}.padding(.horizontal)
}.frame(height: 56)
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()
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
Originally posted on https://ksemianov.github.io/articles/wrapping-hstack/
Top comments (0)