Huge thanks to @CouchDeveloper in the comments above.
Using their example for a wrapping HStack, I was able to modify it to do what I needed. As a bonus I added support for a maxRows
parameter that allows you to control how many rows the wrapping is limited to. For my case I set this to one, and then add the truncation indicator if there's space for it, else remove the last item on the row then add the truncation indicator.
Here's it in action
Code below. It's mostly @CouchDeveloper's code, with my modifications added in.
import SwiftUI
struct WrappingHStack<Content: View, T: Hashable>: View {
private typealias Row = [T]
private typealias Rows = [Row]
private struct Layout: Equatable {
let cellAlignment: VerticalAlignment
let cellSpacing: CGFloat
let width: CGFloat
let maxRows: Int?
}
private let data: [T]
private let truncatedItem: T?
private let content: (T) -> Content
private let layout: Layout
@State private var rows: Rows = Rows()
@State private var sizes: [CGSize] = [CGSize]()
/// Initialises a WrappingHStack instance.
/// - Parameters:
/// - data: An array of elements of type `T` whose elements are used to initialise a "cell" view.
/// - truncatedItem: An item used to indicate truncation when the max number of rows has been displayed but there are other items not displayed.
/// - cellAlignment: An alignment position along the horizontal axis.
/// - cellSpacing: The spacing between the cell views.
/// - width: The width of the container view.
/// - maxRows: The maximum number of rows that will be displayed regardless of how many items there are.
/// - content: Returns a cell view.
init(
data: [T],
truncatedItem: T? = nil,
cellAlignment: VerticalAlignment = .firstTextBaseline,
cellSpacing: CGFloat = 8,
width: CGFloat,
maxRows: Int? = nil,
content: @escaping (T) -> Content
) {
self.data = data
self.truncatedItem = truncatedItem
self.content = content
self.layout = .init(
cellAlignment: cellAlignment,
cellSpacing: cellSpacing,
width: width,
maxRows: maxRows
)
}
var body: some View {
buildView(
rows: rows,
content: content,
layout: layout
)
}
@ViewBuilder
private func buildView(rows: Rows, content: @escaping (T) -> Content, layout: Layout) -> some View {
VStack(alignment: .leading, spacing: 4) {
ForEach(rows, id: \.self) { row in
HStack(alignment: layout.cellAlignment, spacing: layout.cellSpacing) {
ForEach(row, id: \.self) { value in
content(value)
}
}
}
}
.background(
calculateCellSizesAndRows(data: data, content: content) { sizes in
self.sizes = sizes
}
.onChange(of: layout) { layout in
self.rows = calculateRows(layout: layout)
}
)
}
// Populates a HStack with the calculated cell content. The size of each cell
// will be stored through a view preference accessible with key
// `SizeStorePreferenceKey`. Once the cells are layout, the completion
// callback `result` will be called with an array of CGSize
// representing the cell sizes as its argument. This should be used to store
// the size array in some state variable. The function continues to calculate
// the rows based on the cell sizes and the layout.
// Returns the hidden HStack. This HStack will never be rendered on screen.
// Will be called only when data or content changes. This is likely the
// most expensive part, since it requires calculating the size of each
// cell.
private func calculateCellSizesAndRows(
data: [T],
content: @escaping (T) -> Content,
result: @escaping ([CGSize]) -> Void
) -> some View {
// Note: the HStack is required to layout the cells as _siblings_ which
// is required for the SizeStorePreferenceKey's reduce function to be
// invoked.
HStack {
ForEach(data, id: \.self) { element in
content(element)
.calculateSize()
}
}
.onPreferenceChange(SizeStorePreferenceKey.self) { sizes in
result(sizes)
self.rows = calculateRows(layout: layout)
}
.hidden()
}
// Will be called when the layout changes. This happens whenever the
// orientation of the device changes or when the content views changes
// its size. This function is quite inexpensive, since the cell sizes will
// not be calclulated.
private func calculateRows(layout: Layout) -> Rows {
guard layout.width > 10 else {
return []
}
let dataAndSize = zip(data, sizes)
var rows = [[T]]()
var availableSpace = layout.width
var elements = ArraySlice(dataAndSize)
while let (data, size) = elements.first {
var row = [data]
availableSpace -= size.width + layout.cellSpacing
elements = elements.dropFirst()
while let (nextData, nextSize) = elements.first, (nextSize.width + layout.cellSpacing) <= availableSpace {
row.append(nextData)
availableSpace -= nextSize.width + layout.cellSpacing
elements = elements.dropFirst()
}
rows.append(row)
if
let maxRows = layout.maxRows,
maxRows > 0,
rows.count >= maxRows,
!elements.isEmpty
{
if let truncatedItem = truncatedItem {
if availableSpace < 20 { // This hardcoded value is good enough for now, but will need to be calculated like the other cell sizes if a differently-sized truncation item is used.
row = row.dropLast()
}
row.append(truncatedItem)
}
rows = rows.dropLast()
rows.append(row)
break
}
availableSpace = layout.width
}
return rows
}
}
private struct SizeStorePreferenceKey: PreferenceKey {
static var defaultValue: [CGSize] = []
static func reduce(value: inout [CGSize], nextValue: () -> [CGSize]) {
value += nextValue()
}
}
private struct SizeStoreModifier: ViewModifier {
func body(content: Content) -> some View {
content.background(
GeometryReader { geometry in
Color.clear
.preference(
key: SizeStorePreferenceKey.self,
value: [geometry.size]
)
}
)
}
}
private struct RowStorePreferenceKey<T>: PreferenceKey {
typealias Row = [T]
typealias Value = [Row]
static var defaultValue: Value {
[Row]()
}
static func reduce(value: inout Value, nextValue: () -> Value) {
value = nextValue()
}
}
private extension View {
func calculateSize() -> some View {
modifier(SizeStoreModifier())
}
}
calculateRows
, to fit your needs. – CouchDeveloper