非常感谢上面评论中的@CouchDeveloper。
将他们的示例用于包装 HStack,我能够对其进行修改以执行我需要的操作。作为奖励,我添加了对maxRows
允许您控制包装限制为多少行的参数的支持。对于我的情况,我将其设置为 1,如果有空间,则添加截断指示符,否则删除行中的最后一项,然后添加截断指示符。
这是它在行动
代码如下。它主要是 @CouchDeveloper 的代码,并添加了我的修改。
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())
}
}