Reputation: 2352
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:
However, if the array has more values it does this:
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
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:
Upvotes: 4
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
.
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
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
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.
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
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.
Upvotes: 47
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