0
votes

I have a requirement to show tags in a horizontal view. The tags should display only one line, but if there are more tags than will fit in the given width, it should show as many as it can, with a truncation indicator the end. When there is enough space that all the tags fit, they should be leading-aligned.

Trunctated HStack

The max width is defined as the width of its containing view. As a practical example, this maybe be the full width of the device, or the width of a List

How can I achieve this in SwiftUI?

I started with an HStack, but I can't find any way to limit the number of views based on width...

I tried adapting the answers in this question about wrapping items in an HStack (which is a similar problem, but not exactly the same as mine). I was unable to get where I needed it to be. The wrapping works, but it doesn't seem to communicate its resulting height to the parent views, causing overlapping and layout issues in the containing views...

1
Thanks for the close vote about requiring debugging details or code to reproduce the issue... The problem is I don't have any working code to post -- I'm asking about how I can start to approach this problem in SwiftUI. The only code I have is already linked in the question I mentioned.Jasarien
I fear, any approach is quite elaborated, unfortunately. You basically collect the sizes of the cells in a layout phase (using View Preferences), then adjust your collection of cells by adding the "sentinel cell", then draw this. The linked answers are not very well suited for your problem (IMHO) since they do no separate concerns in the implementation which solves a slightly different problem, but also strive to solve it in the shortest path, which then leads to code which is difficult to tailor to your specific problem.CouchDeveloper
I created a gist, just now, with my approach. It should be more easier to tailor this code base for your problem. What you have to do: 1. Add the "sentinel" data to the array of cells you want to show, 2. Change function calculateRows, to fit your needs.CouchDeveloper
@CouchDeveloper amazing, thank you. I will have a look.Jasarien
@Jasarien: You can use a ForEach with HStack and geo. It should dot be difficult one.swiftPunk

1 Answers

0
votes

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())
    }
}