根据最大宽度限制 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 中设置相对宽度?

HStack 中的 SwiftUI 按钮宽度不正确

在 SwiftUI 中,Picker 扩展为占据 HStack 内的整个空间

SwiftUI iOS 14 |在 HStack 中自动展开文本框和内容

SwiftUI:如何限制图像大小?

SwiftUI选取器选择指标不尊重框架宽度