Chris Dellinger
Chris Dellinger

Reputation: 2352

Implementing a tag list in SwiftUI

I am trying to implement a tag list in SwiftUI but I'm unsure how to get it to wrap the tags to additional lines if the list overflows horizontally. I started with a string array called tags and within SwiftUI I loop through the array and create buttons as follows:

HStack{
    ForEach(tags, id: \.self){tag in
        Button(action: {}) {
            HStack {
                Text(tag)
                Image(systemName: "xmark.circle")
            }
        }
        .padding()
        .foregroundColor(.white)
        .background(Color.orange)
        .cornerRadius(.infinity)
        .lineLimit(1)
    }
}

If the tags array is small it renders as follows: enter image description here

However, if the array has more values it does this:

enter image description here

The behavior I am looking for is for the last tag (yellow) to wrap to the second line. I realize it is in an HStack, I was hoping I could add a call to lineLimit with a value of greater than one but it doesn't seem to change the behavior. If I change the outer HStack to a VStack, it puts each Button on a separate line, so still not quite the behavior I am trying create. Any guidance would be greatly appreciated.

Upvotes: 29

Views: 16847

Answers (6)

asamoylenko
asamoylenko

Reputation: 2455

Here's an implementation using SwiftUI custom layouts if you target iOS 16+

Flow layout:

struct FlowHStack: Layout {
    var horizontalSpacing: CGFloat = 8
    var verticalSpacing: CGFloat = 8

    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
        let subviewSizes = subviews.map { $0.sizeThatFits(proposal) }
        let maxSubviewHeight = subviewSizes.map { $0.height }.max() ?? .zero
        var currentRowWidth: CGFloat = .zero
        var totalHeight: CGFloat = maxSubviewHeight
        var totalWidth: CGFloat = .zero

        for size in subviewSizes {
            let requestedRowWidth = currentRowWidth + horizontalSpacing + size.width
            let availableRowWidth = proposal.width ?? .zero
            let willOverflow = requestedRowWidth > availableRowWidth

            if willOverflow {
                totalHeight += verticalSpacing + maxSubviewHeight
                currentRowWidth = size.width
            } else {
                currentRowWidth = requestedRowWidth
            }

            totalWidth = max(totalWidth, currentRowWidth)
        }

        return CGSize(width: totalWidth, height: totalHeight)
    }

    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
        let subviewSizes = subviews.map { $0.sizeThatFits(proposal) }
        let maxSubviewHeight = subviewSizes.map { $0.height }.max() ?? .zero
        var point = CGPoint(x: bounds.minX, y: bounds.minY)

        for index in subviews.indices {
            let requestedWidth = point.x + subviewSizes[index].width
            let availableWidth = bounds.maxX
            let willOverflow = requestedWidth > availableWidth

            if willOverflow {
                point.x = bounds.minX
                point.y += maxSubviewHeight + verticalSpacing
            }

            subviews[index].place(at: point, proposal: ProposedViewSize(subviewSizes[index]))
            point.x += subviewSizes[index].width + horizontalSpacing
        }
    }
}

Example of usage:

    let example = "Lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore et dolore magna aliqua".components(separatedBy: " ")

    FlowHStack {
        ForEach(example, id: \.self) { tag in
            MyTag(text: tag)
        }
    }

where MyTag is any view that you want to put in there.

Result:

Result

Upvotes: 4

Shourob Datta
Shourob Datta

Reputation: 2072

We are using a VStack. Under the VStack there is a HStack. Due to screen width, we are determining how many items fit on each HStack. Preview screenshots

import SwiftUI

struct TagView: View {

let items = ["Group 1", "Nim Team", "Group 2", "Group 2", "Group 2", "Group", "AB1"]
let spacing: CGFloat = 10

var body: some View {
    GeometryReader { geometry in
        VStack(alignment: .leading, spacing: spacing) {
            let rows = self.organizeItemsIntoRows(in: geometry)
            ForEach(rows.indices, id: \.self) { rowIndex in
                TagRow(items: rows[rowIndex], spacing: spacing)
            }
        }
        .padding()
    }
}

private func organizeItemsIntoRows(in geometry: GeometryProxy) -> [[String]] {
    var totalWidth: CGFloat = 0
    var rows: [[String]] = [[]]
    
    for item in items {
        let itemWidth = self.calculateItemWidth(for: item)
        
        if totalWidth + itemWidth + spacing > geometry.size.width {
            rows.append([item])
            totalWidth = itemWidth
        } else {
            rows[rows.count - 1].append(item)
            totalWidth += itemWidth + spacing
        }
    }
    
    return rows
}

private func calculateItemWidth(for item: String) -> CGFloat {
    let label = UILabel()
    label.text = item
    label.sizeToFit()
    return label.frame.width + 40 // Add padding width
}
}

struct TagRow: View {
let items: [String]
let spacing: CGFloat

var body: some View {
    HStack(spacing: spacing) {
        ForEach(items, id: \.self) { item in
            Text(item)
                .padding(.horizontal)
                .padding(.vertical, 5)
                .background(Capsule().foregroundColor(.gray))
                .lineLimit(1)

        }
    }
}
}

struct TagView_Previews: PreviewProvider {
static var previews: some View {
    TagView()
  }
 }

Upvotes: 0

Sanji930
Sanji930

Reputation: 1

My Solution

import SwiftUI

@available(iOS 16.0, *)
public struct MultipleLineHStack<Content: View>: View{
    let horizontaleSpacing: Double
    let verticalSpacing: Double
    let content: () -> Content

    public init(horizontaleSpacing: Double = 5, verticalSpacing: Double = 5, content: @escaping () -> Content) {
        self.horizontaleSpacing = horizontaleSpacing
        self.verticalSpacing = verticalSpacing
        self.content = content
    }

    public var body: some View {
        MultipleLineHStackLayout(horizontaleSpacing: horizontaleSpacing, verticalSpacing: verticalSpacing) {
            content()
        }
        .padding(5)
    }
}

@available(iOS 16.0, *)
struct MultipleLineHStackLayout: Layout {
    let horizontaleSpacing: Double
    let verticalSpacing: Double

    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
        let nbRows = Double(calculateNumberOrRow(for: subviews, with: proposal.width!))
        let minHeight = subviews.map { $0.sizeThatFits(proposal).height }.reduce(0) { max($0, $1).rounded(.up) }
        let height = nbRows * minHeight + max(nbRows - 1, 0) * verticalSpacing

        return CGSize(width: proposal.width!, height: height + 6)
    }

    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
        let minHeight = subviews.map { $0.sizeThatFits(proposal).height }.reduce(0) { max($0, $1).rounded(.up) }
        var pt = CGPoint(x: bounds.minX, y: bounds.minY + 3)
    
        for subview in subviews.sorted(by: { $0.priority > $1.priority }) {
            let width = subview.sizeThatFits(proposal).width
        
            if (pt.x +  width) > bounds.maxX {
                pt.x = bounds.minX
                pt.y += minHeight + verticalSpacing
            }
        
            subview.place(at: pt, anchor: .topLeading, proposal: proposal)
            pt.x += width + horizontaleSpacing
        }
    }


    func calculateNumberOrRow(for subviews: Subviews, with width: Double) -> Int {
        var nbRows = 0
        var x: Double = 0
    
        for subview in subviews {
            let addedX = subview.sizeThatFits(.unspecified).width + horizontaleSpacing
        
            let isXWillGoBeyondBounds =  x + addedX > width
            if isXWillGoBeyondBounds {
                x = 0
                nbRows += 1
            }
        
            x += addedX
        }
    
        if x > 0 {
            nbRows += 1
        }
    
        return nbRows
    }
}

Upvotes: 0

Jim Bray
Jim Bray

Reputation: 788

I found this gist which once built, looks amazing! It did exactly what I needed for making and deleting tags. Here is a sample I built for a multi platform swift app from the code.

Tag creation and deletion with swift

Tagger View

struct TaggerView: View {
@State var newTag = ""
@State var tags = ["example","hello world"]
@State var showingError = false
@State var errorString = "x" // Can't start empty or view will pop as size changes

var body: some View {
    VStack(alignment: .leading) {
        ErrorMessage(showingError: $showingError, errorString: $errorString)
        TagEntry(newTag: $newTag, tags: $tags, showingError: $showingError, errorString: $errorString)
        TagList(tags: $tags)
    }
    .padding()
    .onChange(of: showingError, perform: { value in
        if value {
            // Hide the error message after a delay
            DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
                showingError = false
            }
        }
    })
  }
}

ErrorMessage View

struct ErrorMessage: View {

@Binding var showingError: Bool
@Binding var errorString: String

var body: some View {
    HStack {
        Image(systemName: "exclamationmark.triangle.fill")
            .foregroundColor(.orange)
        Text(errorString)
            .foregroundColor(.secondary)
            .padding(.leading, -6)
    }
    .font(.caption)
    .opacity(showingError ? 1 : 0)
    .animation(.easeIn(duration: 0.3), value: showingError)
    }
}

TagEntry View

struct TagEntry: View {
@Binding var newTag: String
@Binding var tags: [String]
@Binding var showingError: Bool
@Binding var errorString: String

var body: some View {
    HStack {
        TextField("Add Tags", text: $newTag, onCommit: {
            addTag(newTag)
        })
        .textFieldStyle(RoundedBorderTextFieldStyle())
        
        Spacer()
        Image(systemName: "plus.circle")
            .foregroundColor(.blue)
            .onTapGesture {
                addTag(newTag)
            }
    }
    .onChange(of: newTag, perform: { value in
        if value.contains(",") {
            // Try to add the tag if user types a comma
            newTag = value.replacingOccurrences(of: ",", with: "")
            addTag(newTag)
        }
    })
}

/// Checks if the entered text is valid as a tag. Sets the error message if it isn't
private func tagIsValid(_ tag: String) -> Bool {
    // Invalid tags:
    // - empty strings
    // - tags already in the tag array
    let lowerTag = tag.lowercased()
    if lowerTag == "" {
        showError(.Empty)
        return false
    } else if tags.contains(lowerTag) {
        showError(.Duplicate)
        return false
    } else {
        return true
    }
}

/// If the tag is valid, it is added to an array, otherwise the error message is shown
private func addTag(_ tag: String) {
    if tagIsValid(tag) {
        tags.append(newTag.lowercased())
        newTag = ""
    }
}

private func showError(_ code: ErrorCode) {
    errorString = code.rawValue
    showingError = true
}

enum ErrorCode: String {
    case Empty = "Tag can't be empty"
    case Duplicate = "Tag can't be a duplicate"
    }
}

TagList View

struct TagList: View {
@Binding var tags: [String]

var body: some View {
    GeometryReader { geo in
        generateTags(in: geo)
            .padding(.top)
    }
}

/// Adds a tag view for each tag in the array. Populates from left to right and then on to new rows when too wide for the screen
private func generateTags(in geo: GeometryProxy) -> some View {
    var width: CGFloat = 0
    var height: CGFloat = 0
    
    return ZStack(alignment: .topLeading) {
        ForEach(tags, id: \.self) { tag in
            Tag(tag: tag, tags: $tags)
                .alignmentGuide(.leading, computeValue: { tagSize in
                    if (abs(width - tagSize.width) > geo.size.width) {
                        width = 0
                        height -= tagSize.height
                    }
                    let offset = width
                    if tag == tags.last ?? "" {
                        width = 0
                    } else {
                        width -= tagSize.width
                    }
                    return offset
                })
                .alignmentGuide(.top, computeValue: { tagSize in
                    let offset = height
                    if tag == tags.last ?? "" {
                        height = 0
                    }
                    return offset
                })
            }
        }
    }
}

Tag View

struct Tag: View {
var tag: String
@Binding var tags: [String]
@State var fontSize: CGFloat = 20.0
@State var iconSize: CGFloat = 20.0

var body: some View {
    HStack {
        Text(tag.lowercased())
            .font(.system(size: fontSize, weight: .regular, design: .rounded))
            .padding(.leading, 2)
        Image(systemName: "xmark.circle.fill")
            .symbolRenderingMode(.palette)
            .foregroundStyle(.red, .blue, .white)
            .font(.system(size: iconSize, weight: .black, design: .rounded))
            .opacity(0.7)
            .padding(.leading, -5)
    }
    .foregroundColor(.white)
    .font(.caption2)
    .padding(4)
    .background(Color.blue.cornerRadius(5))
    .padding(4)
    .onTapGesture {
        tags = tags.filter({ $0 != tag })
        }
    }
}

And finally…

Context View

import SwiftUI

struct ContentView: View {
    var body: some View {
    TaggerView()
    }
}

I can’t take any credit for the code but let me send a huge thanks to Alex Hay for creating and posting this.

Link to the gist code on GitHub

I hope this helps someone.

Upvotes: 3

ricardopereira
ricardopereira

Reputation: 11683

Federico Zanetello shared a nice solution in his blog: Flexible layouts in SwiftUI.

The solution is a custom view called FlexibleView which computes the necessary Row's and HStack's to lay down the given elements and wrap them into multiple rows if needed.

struct _FlexibleView<Data: Collection, Content: View>: View where Data.Element: Hashable {
  let availableWidth: CGFloat
  let data: Data
  let spacing: CGFloat
  let alignment: HorizontalAlignment
  let content: (Data.Element) -> Content
  @State var elementsSize: [Data.Element: CGSize] = [:]

  var body : some View {
    VStack(alignment: alignment, spacing: spacing) {
      ForEach(computeRows(), id: \.self) { rowElements in
        HStack(spacing: spacing) {
          ForEach(rowElements, id: \.self) { element in
            content(element)
              .fixedSize()
              .readSize { size in
                elementsSize[element] = size
              }
          }
        }
      }
    }
  }

  func computeRows() -> [[Data.Element]] {
    var rows: [[Data.Element]] = [[]]
    var currentRow = 0
    var remainingWidth = availableWidth

    for element in data {
      let elementSize = elementsSize[element, default: CGSize(width: availableWidth, height: 1)]

      if remainingWidth - (elementSize.width + spacing) >= 0 {
        rows[currentRow].append(element)
      } else {
        currentRow = currentRow + 1
        rows.append([element])
        remainingWidth = availableWidth
      }

      remainingWidth = remainingWidth - (elementSize.width + spacing)
    }

    return rows
  }
}

Usage:

FlexibleView(
    data: [
    "Here’s", "to", "the", "crazy", "ones", "the", "misfits", "the", "rebels", "the", "troublemakers", "the", "round", "pegs", "in", "the", "square", "holes", "the", "ones", "who", "see", "things", "differently", "they’re", "not", "fond", "of", "rules"
  ],
    spacing: 15,
    alignment: .leading
  ) { item in
    Text(verbatim: item)
      .padding(8)
      .background(
        RoundedRectangle(cornerRadius: 8)
          .fill(Color.gray.opacity(0.2))
       )
  }
  .padding(.horizontal, model.padding)
}

Full code available at https://github.com/zntfdr/FiveStarsCodeSamples.

flexible view example

Upvotes: 47

sndrsn
sndrsn

Reputation: 91

Ok, this is my first answer on this site, so bear with me if I commit some kind of stack overflow faux pas.

I'll post my solution, which works for a model where the tags are either present in a selectedTags set or not, and all available tags are present in an allTags set. In my solution, these are set as bindings, so they can be injected from elsewhere in the app. Also, my solution has the tags ordered alphabetically because that was easiest. If you want them ordered a different way, you'll probably need to use a different model than two independent sets.

This definitely won't work for everyone's use case, but since I couldn't find my own answer for this out there, and your question was the only place I could find mentioning the idea, I decided I would try to build something that would work for me and share it with you. Hope it helps:

struct TagList: View {

    @Binding var allTags: Set<String>
    @Binding var selectedTags: Set<String>

    private var orderedTags: [String] { allTags.sorted() }

    private func rowCounts(_ geometry: GeometryProxy) -> [Int] { TagList.rowCounts(tags: orderedTags, padding: 26, parentWidth: geometry.size.width) }

    private func tag(rowCounts: [Int], rowIndex: Int, itemIndex: Int) -> String {
        let sumOfPreviousRows = rowCounts.enumerated().reduce(0) { total, next in
            if next.offset < rowIndex {
                return total + next.element
            } else {
                return total
            }
        }
        let orderedTagsIndex = sumOfPreviousRows + itemIndex
        guard orderedTags.count > orderedTagsIndex else { return "[Unknown]" }
        return orderedTags[orderedTagsIndex]
    }

    var body: some View {
        GeometryReader { geometry in
            VStack(alignment: .leading) {
                ForEach(0 ..< self.rowCounts(geometry).count, id: \.self) { rowIndex in
                    HStack {
                        ForEach(0 ..< self.rowCounts(geometry)[rowIndex], id: \.self) { itemIndex in
                            TagButton(title: self.tag(rowCounts: self.rowCounts(geometry), rowIndex: rowIndex, itemIndex: itemIndex), selectedTags: self.$selectedTags)
                        }
                        Spacer()
                    }.padding(.vertical, 4)
                }
                Spacer()
            }
        }
    }
}

struct TagList_Previews: PreviewProvider {
    static var previews: some View {
        TagList(allTags: .constant(["one", "two", "three"]), selectedTags: .constant(["two"]))
    }
}

extension String {

    func widthOfString(usingFont font: UIFont) -> CGFloat {
        let fontAttributes = [NSAttributedString.Key.font: font]
        let size = self.size(withAttributes: fontAttributes)
        return size.width
    }

}

extension TagList {
    static func rowCounts(tags: [String], padding: CGFloat, parentWidth: CGFloat) -> [Int] {
        let tagWidths = tags.map{$0.widthOfString(usingFont: UIFont.preferredFont(forTextStyle: .headline))}

        var currentLineTotal: CGFloat = 0
        var currentRowCount: Int = 0
        var result: [Int] = []

        for tagWidth in tagWidths {
            let effectiveWidth = tagWidth + (2 * padding)
            if currentLineTotal + effectiveWidth <= parentWidth {
                currentLineTotal += effectiveWidth
                currentRowCount += 1
                guard result.count != 0 else { result.append(1); continue }
                result[result.count - 1] = currentRowCount
            } else {
                currentLineTotal = effectiveWidth
                currentRowCount = 1
                result.append(1)
            }
        }

        return result
    }
}

struct TagButton: View {

    let title: String
    @Binding var selectedTags: Set<String>

    private let vPad: CGFloat = 13
    private let hPad: CGFloat = 22
    private let radius: CGFloat = 24

    var body: some View {
        Button(action: {
            if self.selectedTags.contains(self.title) {
                self.selectedTags.remove(self.title)
            } else {
                self.selectedTags.insert(self.title)
            }
        }) {
            if self.selectedTags.contains(self.title) {
                HStack {
                    Text(title)
                        .font(.headline)
                }
                .padding(.vertical, vPad)
                .padding(.horizontal, hPad)
                .foregroundColor(.white)
                .background(Color.blue)
                .cornerRadius(radius)
                .overlay(
                    RoundedRectangle(cornerRadius: radius)
                        .stroke(Color(UIColor.systemBackground), lineWidth: 1)
                )

            } else {
                HStack {
                    Text(title)
                        .font(.headline)
                        .fontWeight(.light)
                }
                .padding(.vertical, vPad)
                .padding(.horizontal, hPad)
                .foregroundColor(.gray)
                .overlay(
                    RoundedRectangle(cornerRadius: radius)
                        .stroke(Color.gray, lineWidth: 1)
                )
            }
        }
    }
}

Upvotes: 9

Related Questions