根据最大宽度限制 SwiftUI HStack 中的视图数量
Posted
技术标签:
【中文标题】根据最大宽度限制 SwiftUI HStack 中的视图数量【英文标题】:Limit the number of views in a SwiftUI HStack based on a max width 【发布时间】:2021-11-11 08:42:03 【问题描述】:我需要在水平视图中显示标签。标签应该只显示一行,但是如果标签的数量超出了给定的宽度,它应该尽可能多地显示,并在末尾加上一个截断指示符。当所有标签都有足够的空间时,它们应该是前导对齐的。
最大宽度定义为其包含视图的宽度。作为一个实际的例子,这可能是设备的整个宽度,或者是列表的宽度
如何在 SwiftUI 中实现这一点?
我从 HStack 开始,但我找不到任何方法来限制基于宽度的视图数量...
我尝试调整this question about wrapping items in an HStack 中的答案(这是一个类似的问题,但与我的不完全相同)。我无法到达我需要的地方。包装工作,但它似乎没有将其结果高度传达给父视图,导致包含视图中的重叠和布局问题......
【问题讨论】:
感谢您对要求调试详细信息或代码以重现问题的密切投票...问题是我没有任何工作代码可发布——我在问如何开始在 SwiftUI 中解决这个问题。我唯一的代码已经链接在我提到的问题中。 我担心,不幸的是,任何方法都非常详尽。您基本上在布局阶段收集单元格的大小(使用视图首选项),然后通过添加“哨兵单元格”来调整您的单元格集合,然后绘制它。链接的答案不太适合您的问题(恕我直言),因为它们在解决稍微不同的问题的实现中没有单独的关注点,但也努力以最短的路径解决它,这会导致代码难以根据您的具体问题量身定制。 我刚才用我的方法创建了一个gist。为您的问题定制此代码库应该更容易。您需要做的:1. 将“哨兵”数据添加到要显示的单元格数组中,2. 更改函数calculateRows
,以满足您的需求。
@CouchDeveloper 太棒了,谢谢。我去看看。
@Jasarien:您可以将 ForEach 与 HStack 和 geo 一起使用。应该是难点。
【参考方案1】:
非常感谢上面 cmets 中的@CouchDeveloper。
将他们的示例用于包装 HStack,我能够对其进行修改以执行我需要的操作。作为奖励,我添加了对 maxRows
参数的支持,该参数允许您控制包装限制为多少行。对于我的情况,我将其设置为 1,如果有空间,则添加截断指示符,否则删除行中的最后一项,然后添加截断指示符。
Here's it in action
代码如下。主要是@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())
【讨论】:
很高兴,您找到了解决方案 :) 我还更新了要点并进行了一些改进,消除了不必要的计算,也改进了分离。看一看! ;)(在第 4 版中修复:间距设置为 nil 时的错误) 严格来说,在截断的情况下,您需要根据需要删除尽可能多的最后一个单元格以适应 truncatedItem。否则,在某些(边缘)情况下,释放的空间可能太小。以上是关于根据最大宽度限制 SwiftUI HStack 中的视图数量的主要内容,如果未能解决你的问题,请参考以下文章
如何在 SwiftUI 中嵌入 ForEach 的 HStack 中设置相对宽度?
在 SwiftUI 中,Picker 扩展为占据 HStack 内的整个空间