HStack with Wrap
Asked Answered
I

11

80

Is it possible that the blue tags (which are currently truncated) are displayed completely and then it automatically makes a line break?

NavigationLink(destination: GameListView()) {
  VStack(alignment: .leading, spacing: 5){
    // Name der Sammlung:
    Text(collection.name)
      .font(.headline)

    // Optional: Für welche Konsolen bzw. Plattformen:
    HStack(alignment: .top, spacing: 10){
      ForEach(collection.platforms, id: \.self) { platform in
        Text(platform)
          .padding(.all, 5)
          .font(.caption)
          .background(Color.blue)
          .foregroundColor(Color.white)
          .cornerRadius(5)
          .lineLimit(1)
      }
    }
  }
  .padding(.vertical, 10)
}

enter image description here

Also, there should be no line breaks with in the blue tags:

enter image description here

That's how it should look in the end:

enter image description here

Immunochemistry answered 13/11, 2019 at 17:23 Comment(3)
So you need line breaking instead of truncation?Crake
Yes, so that the blue tags that no longer fit into the row will then be set into the second or third rowImmunochemistry
Have you figured out how to achieve that result?Keavy
C
76

Here is some approach of how this could be done using alignmentGuide(s). It is simplified to avoid many code post, but hope it is useful.

Update: There is also updated & improved variant of below solution in my answer for SwiftUI HStack with wrap and dynamic height

This is the result:

swiftui wrapped layout

And here is full demo code (orientation is supported automatically):

import SwiftUI

struct TestWrappedLayout: View {
    @State var platforms = ["Ninetendo", "XBox", "PlayStation", "PlayStation 2", "PlayStation 3", "PlayStation 4"]

    var body: some View {
        GeometryReader { geometry in
            self.generateContent(in: geometry)
        }
    }

    private func generateContent(in g: GeometryProxy) -> some View {
        var width = CGFloat.zero
        var height = CGFloat.zero

        return ZStack(alignment: .topLeading) {
            ForEach(self.platforms, id: \.self) { platform in
                self.item(for: platform)
                    .padding([.horizontal, .vertical], 4)
                    .alignmentGuide(.leading, computeValue: { d in
                        if (abs(width - d.width) > g.size.width)
                        {
                            width = 0
                            height -= d.height
                        }
                        let result = width
                        if platform == self.platforms.last! {
                            width = 0 //last item
                        } else {
                            width -= d.width
                        }
                        return result
                    })
                    .alignmentGuide(.top, computeValue: {d in
                        let result = height
                        if platform == self.platforms.last! {
                            height = 0 // last item
                        }
                        return result
                    })
            }
        }
    }

    func item(for text: String) -> some View {
        Text(text)
            .padding(.all, 5)
            .font(.body)
            .background(Color.blue)
            .foregroundColor(Color.white)
            .cornerRadius(5)
    }
}

struct TestWrappedLayout_Previews: PreviewProvider {
    static var previews: some View {
        TestWrappedLayout()
    }
}
Canikin answered 15/11, 2019 at 12:6 Comment(8)
Thank you very much. Unfortunately there are still a few display errors. Do you have any idea how to fix this? Screenshot: awwfood.de/tags_issue01.pngImmunochemistry
@MichałZiobro It won't work with duplicate text items as presented. Submitted an edit to fix. Maybe that was the problem?Bourgeois
This is great, thank you. Hadn't known about alignmentGuides before this. Adapted it into a SwiftUI tagging interface in case that's useful for others looking for something similarSteiermark
Next Level right here. I have some clients that do not want to update to ios14, and this works perfectly in ios13.Srinagar
it is not working if we add more textAnnuity
The problem with this solution is that the Geometry Reader takes up the entire space below which breaks the view if you try to add more things below itAmused
#76406181Follow
@SaadRehman, did you found any solution, I suffer the same: Geometry Reader takes up the entire spaceFollow
S
16

For me, none of the answers worked. Either because I had different types of elements or because elements around were not being positioned correctly. Therefore, I ended up implementing my own WrappingHStack which can be used in a very similar way to HStack. You can find it at GitHub: WrappingHStack.

Here is an example:

enter image description here

Code:

WrappingHStack {
    Text("WrappingHStack")
        .padding()
        .font(.title)
        .border(Color.black)
    
    Text("can handle different element types")
    
    Image(systemName: "scribble")
        .font(.title)
        .frame(width: 200, height: 20)
        .background(Color.purple)
    
    Text("and loop")
        .bold()
    
    WrappingHStack(1...20, id:\.self) {
        Text("Item: \($0)")
            .padding(3)
            .background(Rectangle().stroke())
    }.frame(minWidth: 250)
}
.padding()
.border(Color.black)
Sewel answered 21/3, 2021 at 18:13 Comment(0)
I
11

Here is an improvement on Timmy's answer. It reserves all available horizontal space fixing a bug where if the wrapping container had less than a full line of elements, it would reserve such an amount of space that lead to misalignment of the UI, and also fixes a bug that leads to content overflowing in some edge cases:

private struct WrappingHStack: Layout {
    // inspired by: https://stackoverflow.com/a/75672314
    private var horizontalSpacing: CGFloat
    private var verticalSpacing: CGFloat
    public init(horizontalSpacing: CGFloat, verticalSpacing: CGFloat? = nil) {
        self.horizontalSpacing = horizontalSpacing
        self.verticalSpacing = verticalSpacing ?? horizontalSpacing
    }

    public func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache _: inout ()) -> CGSize {
        guard !subviews.isEmpty else { return .zero }

        let height = subviews.map { $0.sizeThatFits(proposal).height }.max() ?? 0

        var rowWidths = [CGFloat]()
        var currentRowWidth: CGFloat = 0
        subviews.forEach { subview in
            if currentRowWidth + horizontalSpacing + subview.sizeThatFits(proposal).width >= proposal.width ?? 0 {
                rowWidths.append(currentRowWidth)
                currentRowWidth = subview.sizeThatFits(proposal).width
            } else {
                currentRowWidth += horizontalSpacing + subview.sizeThatFits(proposal).width
            }
        }
        rowWidths.append(currentRowWidth)

        let rowCount = CGFloat(rowWidths.count)
        return CGSize(width: max(rowWidths.max() ?? 0, proposal.width ?? 0), height: rowCount * height + (rowCount - 1) * verticalSpacing)
    }

    public func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
        let height = subviews.map { $0.dimensions(in: proposal).height }.max() ?? 0
        guard !subviews.isEmpty else { return }
        var x = bounds.minX
        var y = height / 2 + bounds.minY
        subviews.forEach { subview in
            x += subview.dimensions(in: proposal).width / 2
            if x + subview.dimensions(in: proposal).width / 2 > bounds.maxX {
                x = bounds.minX + subview.dimensions(in: proposal).width / 2
                y += height + verticalSpacing
            }
            subview.place(
                at: CGPoint(x: x, y: y),
                anchor: .center,
                proposal: ProposedViewSize(
                    width: subview.dimensions(in: proposal).width,
                    height: subview.dimensions(in: proposal).height
                )
            )
            x += subview.dimensions(in: proposal).width / 2 + horizontalSpacing
        }
    }
}

You can use it like this:

struct MyView: View {
  var body: some View {
    WrappingHStack(horizontalSpacing: 5) {
      Text("Hello")
      Text("Hello again")
      Text("Hello")
      Text("Hello again")
      Text("Hello")
      Text("Hello again")
      Text("Hello")
      Text("Hello again")
      Text("Hello")
      Text("Hello again")
      Text("Hello")
      Text("Hello again")
    }
  }
}

And the effect looks like this:

Showcase of the wrapping behaviour

Injure answered 16/3, 2023 at 13:54 Comment(0)
W
5

I've had ago at creating what you need.

Ive used HStack's in a VStack.

You pass in a geometryProxy which is used for determining the maximum row width. I went with passing this in so it would be usable within a scrollView

I wrapped the SwiftUI Views in a UIHostingController to get a size for each child.

I then loop through the views adding them to the row until it reaches the maximum width, in which case I start adding to a new row.

This is just the init and final stage combining and outputting the rows in the VStack

struct WrappedHStack<Content: View>: View {
    
    private let content: [Content]
    private let spacing: CGFloat = 8
    private let geometry: GeometryProxy
    
    init(geometry: GeometryProxy, content: [Content]) {
        self.content = content
        self.geometry = geometry
    }
    
    var body: some View {
        let rowBuilder = RowBuilder(spacing: spacing,
                                    containerWidth: geometry.size.width)
        
        let rowViews = rowBuilder.generateRows(views: content)
        let finalView = ForEach(rowViews.indices) { rowViews[$0] }
        
        VStack(alignment: .center, spacing: 8) {
            finalView
        }.frame(width: geometry.size.width)
    }
}

extension WrappedHStack {
    
    init<Data, ID: Hashable>(geometry: GeometryProxy, @ViewBuilder content: () -> ForEach<Data, ID, Content>) {
        let views = content()
        self.geometry = geometry
        self.content = views.data.map(views.content)
    }

    init(geometry: GeometryProxy, content: () -> [Content]) {
        self.geometry = geometry
        self.content = content()
    }
}

The magic happens in here

extension WrappedHStack {
    struct RowBuilder {
        
        private var spacing: CGFloat
        private var containerWidth: CGFloat
        
        init(spacing: CGFloat, containerWidth: CGFloat) {
            self.spacing = spacing
            self.containerWidth = containerWidth
        }
        
        func generateRows<Content: View>(views: [Content]) -> [AnyView] {
            
            var rows = [AnyView]()
            
            var currentRowViews = [AnyView]()
            var currentRowWidth: CGFloat = 0
            
            for (view) in views {
                let viewWidth = view.getSize().width
                
                if currentRowWidth + viewWidth > containerWidth {
                    rows.append(createRow(for: currentRowViews))
                    currentRowViews = []
                    currentRowWidth = 0
                }
                currentRowViews.append(view.erasedToAnyView())
                currentRowWidth += viewWidth + spacing
            }
            rows.append(createRow(for: currentRowViews))
            return rows
        }
        
        private func createRow(for views: [AnyView]) -> AnyView {
            HStack(alignment: .center, spacing: spacing) {
                ForEach(views.indices) { views[$0] }
            }
            .erasedToAnyView()
        }
    }
}

and here's extensions I used

extension View {
    func erasedToAnyView() -> AnyView {
        AnyView(self)
    }
    
    func getSize() -> CGSize {
        UIHostingController(rootView: self).view.intrinsicContentSize
    }
}

You can see the full code with some examples here: https://gist.github.com/kanesbetas/63e719cb96e644d31bf027194bf4ccdb

Wainscot answered 13/3, 2021 at 22:30 Comment(0)
P
4

Here's a version using SwiftUI's Layout protocol:

public struct OverflowGrid: Layout {
    private var horizontalSpacing: CGFloat
    private var vericalSpacing: CGFloat
    public init(horizontalSpacing: CGFloat, vericalSpacing: CGFloat? = nil) {
        self.horizontalSpacing = horizontalSpacing
        self.vericalSpacing = vericalSpacing ?? horizontalSpacing
    }
    public func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
        let height = subviews.max(by: {$0.dimensions(in: proposal).height > $1.dimensions(in: proposal).height})?.dimensions(in: proposal).height ?? 0
        var rows = [CGFloat]()
        subviews.indices.forEach { index in
            let rowIndex = rows.count - 1
            let subViewWidth = subviews[index].dimensions(in: proposal).width
            guard !rows.isEmpty else {
                rows.append(subViewWidth)
                return
            }
            let newWidth = rows[rowIndex] + subViewWidth + horizontalSpacing
            if newWidth < proposal.width ?? 0 {
                rows[rowIndex] += (rows[rowIndex] > 0 ? horizontalSpacing: 0) + subViewWidth
            }else {
                rows.append(subViewWidth)
            }
        }
        let count = CGFloat(rows.count)
        return CGSize(width: rows.max() ?? 0, height: count * height + (count - 1) * vericalSpacing)
        
    }
    public func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
        let height = subviews.max(by: {$0.dimensions(in: proposal).height > $1.dimensions(in: proposal).height})?.dimensions(in: proposal).height ?? 0
        guard !subviews.isEmpty else {return}
        var x = bounds.minX
        var y = height/2 + bounds.minY
        subviews.indices.forEach { index in
            let subView = subviews[index]
            x += subView.dimensions(in: proposal).width/2
            subviews[index].place(at: CGPoint(x: x, y: y), anchor: .center, proposal: ProposedViewSize(width: subView.dimensions(in: proposal).width, height: subView.dimensions(in: proposal).height))
            x += horizontalSpacing + subView.dimensions(in: proposal).width/2
            if x > bounds.width {
                x = bounds.minX
                y += height + vericalSpacing
            }
        }
    }
}
Prairial answered 8/3, 2023 at 11:17 Comment(0)
S
3

Building on Timmy's implementation I believe I fixed a few (literal) edge cases:

public struct OverflowGrid: Layout {
    private var horizontalSpacing: CGFloat
    private var verticalSpacing: CGFloat
    public init(horizontalSpacing: CGFloat, verticalSpacing: CGFloat? = nil) {
        self.horizontalSpacing = horizontalSpacing
        self.verticalSpacing = verticalSpacing ?? horizontalSpacing
    }

    private struct RowSize {
        let width: CGFloat
        let height: CGFloat

        static let empty = RowSize(width: CGFloat(0), height: CGFloat(0))
    }

    public func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
        let rows = generateRowSizes(subviews: subviews, proposal: proposal)
        let rowCount = CGFloat(rows.count)
        let combinedHeights: CGFloat = rows.reduce(CGFloat(0)) { acc, rowSize in
            return acc + rowSize.height
        }
        let calculatedSize = CGSize(width: rows.max(by: { $0.width < $1.width })?.width ?? 0,
                                    height:  combinedHeights + ((rowCount - 1) * verticalSpacing))
        return calculatedSize
    }

    private func generateRowSizes(subviews: Subviews, proposal: ProposedViewSize) -> [RowSize] {
        var rows = [RowSize.empty]
        for subview in subviews {
            let rowIndex = rows.count - 1
            let currentRowSize = rows.last ?? .empty
            let subviewSize = subview.dimensions(in: proposal)
            let subviewWidth = subviewSize.width
            let subviewHeight = subviewSize.height

            // Prevent creating infinite rows if the proposed width is smaller than any subview width.
            if currentRowSize.width == 0 {
                rows[rowIndex] = RowSize(width: subviewWidth,
                                         height: max(currentRowSize.height, subviewHeight))
            } else {
                let currentRowWidth = currentRowSize.width
                let newWidth = currentRowWidth + horizontalSpacing + subviewWidth
                let viewWillFit = newWidth <= (proposal.width ?? 0)
                if viewWillFit {
                    rows[rowIndex] = RowSize(width: newWidth, height: max(currentRowSize.height, subviewHeight))
                } else {
                    rows.append(RowSize(width: subviewWidth, height: subviewHeight))
                }
            }
        }
        return rows
    }

    public func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
        let rows = generateRowSizes(subviews: subviews, proposal: proposal)
        var rowIndex = 0
        let boundsMinX = bounds.minX
        let boundsEnd = boundsMinX + bounds.width
        var x = boundsMinX
        var y = bounds.minY
        for subview in subviews {
            let subviewSize = subview.dimensions(in: proposal)
            let isFirstElementInRow = x == boundsMinX
            var elementStart = isFirstElementInRow ? x : x + horizontalSpacing
            var elementEnd = elementStart + subviewSize.width
            if elementEnd > boundsEnd && !isFirstElementInRow {
                elementStart = boundsMinX
                elementEnd = elementStart + subviewSize.width
                x = boundsMinX
                y += rows[rowIndex].height + verticalSpacing
                rowIndex += 1
            }
            let subviewCenter = CGPoint(x: elementStart + subviewSize.width / 2, y: y + subviewSize.height / 2)
            subview.place(
                at: subviewCenter,
                anchor: .center,
                proposal: ProposedViewSize(width: subviewSize.width,
                                           height: subviewSize.height))
            x = elementEnd
        }
    }
}
Shillelagh answered 14/7, 2023 at 12:50 Comment(0)
A
1

I have something like this code (rather long). In simple scenarios it works ok, but in deep nesting with geometry readers it doesn't propagate its size well.

It would be nice if this views wraps and flows like Text() extending parent view content, but it seems to have explicitly set its height from parent view.

https://gist.github.com/michzio/a0b23ee43a88cbc95f65277070167e29

Here is the most important part of the code (without preview and test data)

private func flow(in geometry: GeometryProxy) -> some View {
        
        print("Card geometry: \(geometry.size.width) \(geometry.size.height)")
        
        return ZStack(alignment: .topLeading) {
            //Color.clear
            ForEach(data, id: self.dataId) { element in
                self.content(element)
                    .geometryPreference(tag: element\[keyPath: self.dataId\])
                    /*
                    .alignmentGuide(.leading) { d in
                        print("Element: w: \(d.width), h: \(d.height)")
                        if (abs(width - d.width) > geometry.size.width)
                        {
                            width = 0
                            height -= d.height
                        }
                        
                        let result = width
                        
                        if element\[keyPath: self.dataId\] == self.data.last!\[keyPath: self.dataId\] {
                            width = 0 //last item
                        } else {
                            width -= d.width
                        }
                        return result
                    }
                    .alignmentGuide(.top) { d in
                        let result = height
                        if element\[keyPath: self.dataId\] == self.data.last!\[keyPath: self.dataId\] {
                            height = 0 // last item
                        }
                        return result
                    }*/
                    
                    .alignmentGuide(.top) { d in
                        self.alignmentGuides\[element\[keyPath: self.dataId\]\]?.y ?? 0
                    }
                    .alignmentGuide(.leading) { d in
                        self.alignmentGuides\[element\[keyPath: self.dataId\]\]?.x ?? 0
                    }
            }
        }
        .background(Color.pink)
        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
        //.animation(self.loaded ? .linear(duration: 1) : nil)
        
        .onPreferenceChange(_GeometryPreferenceKey.self, perform: { preferences in
        
                DispatchQueue.main.async {
                    let (alignmentGuides, totalHeight) = self.calculateAlignmentGuides(preferences: preferences, geometry: geometry)
                    self.alignmentGuides = alignmentGuides
                    self.totalHeight = totalHeight
                    self.availableWidth = geometry.size.width
                }
        })
    }
    
    func calculateAlignmentGuides(preferences: \[_GeometryPreference\], geometry: GeometryProxy) -> (\[AnyHashable: CGPoint\], CGFloat) {
        
        var alignmentGuides = \[AnyHashable: CGPoint\]()
        
        var width: CGFloat = 0
        var height: CGFloat = 0
        
        var rowHeights: Set<CGFloat> = \[\]

        preferences.forEach { preference in
            let elementWidth = spacing + preference.rect.width
            
            if width + elementWidth >= geometry.size.width {
                width = 0
                height += (rowHeights.max() ?? 0) + spacing
                //rowHeights.removeAll()
            }
            
            let offset = CGPoint(x: 0 - width, y: 0 - height)
            
            print("Alignment guides offset: \(offset)")
            alignmentGuides\[preference.tag\] = offset
            
            width += elementWidth
            rowHeights.insert(preference.rect.height)
        }

        return (alignmentGuides, height + (rowHeights.max() ?? 0))
    }
}

image

Adamsen answered 3/4, 2020 at 17:2 Comment(0)
I
1

I had the same problem I've, to solve it I pass the object item to a function which first creates the view for the item, then through the UIHostController I will calculate the next position based on the items width. the items view is then returned by the function.

import SwiftUI

class TestItem: Identifiable {
    
    var id = UUID()
    var str = ""
    init(str: String) {
        self.str = str
    }
    
}

struct AutoWrap: View {
    
    var tests: [TestItem] = [
        TestItem(str:"Ninetendo"),
        TestItem(str:"XBox"),
        TestItem(str:"PlayStation"),
        TestItem(str:"PlayStation 2"),
        TestItem(str:"PlayStation 3"),
        TestItem(str:"random"),
        TestItem(str:"PlayStation 4"),
    ]
    
    

    
    var body: some View {
        
        var curItemPos: CGPoint = CGPoint(x: 0, y: 0)
        var prevItemWidth: CGFloat = 0
        return GeometryReader { proxy in
            ZStack(alignment: .topLeading) {
                ForEach(tests) { t in
                    generateItem(t: t, curPos: &curItemPos, containerProxy: proxy, prevItemWidth: &prevItemWidth)
                }
            }.padding(5)
        }
    }
    
    func generateItem(t: TestItem, curPos: inout CGPoint, containerProxy: GeometryProxy, prevItemWidth: inout CGFloat, hSpacing: CGFloat = 5, vSpacing: CGFloat = 5) -> some View {
        let viewItem = Text(t.str).padding([.leading, .trailing], 15).background(Color.blue).cornerRadius(25)
        let itemWidth = UIHostingController(rootView: viewItem).view.intrinsicContentSize.width
        let itemHeight = UIHostingController(rootView: viewItem).view.intrinsicContentSize.height
        let newPosX = curPos.x + prevItemWidth + hSpacing
        let newPosX2 = newPosX + itemWidth
        if newPosX2 > containerProxy.size.width {
            curPos.x = hSpacing
            curPos.y += itemHeight + vSpacing
        } else {
            curPos.x = newPosX
        }
        prevItemWidth = itemWidth
        return viewItem.offset(x: curPos.x, y: curPos.y)
    }
}

struct AutoWrap_Previews: PreviewProvider {
    static var previews: some View {
        AutoWrap()
    }
}
Intricate answered 20/1, 2021 at 12:29 Comment(0)
S
0

I believe what you want is a LazyVGrid

LazyVGrid(
    columns: [
        GridItem(.adaptive(minimum: 100, maximum: 120))
    ],
    content: {
        Text("Placeholder")
        Text("Placeholder")
        Text("Placeholder")
        Text("Placeholder")
    }
)
Schottische answered 20/4 at 9:8 Comment(0)
I
-1

I was using the WrappingHStack package, but when I used more than 2 WrappingHStack in the ScrollView, frame drops occurred, so I implemented it as a new RandomCollection in a simple way and made WStack available from iOS 13.0 or later.

Visit https://github.com/winkitee/WStack

Example Code

import SwiftUI
import WStack

struct ContentView: View {
    let fruits = [
        "🍎 Red Apple",
        "🍐 Pear",
        "🍊 Tangerine",
        "🍋 Lemon",
        "🍌 Banana",
        "🍉 Watermelon"
    ]

    var body: some View {
        ScrollView {
            VStack(alignment: .leading) {
                WStack(fruits, spacing: 4, lineSpacing: 4) { fruit in
                    Text(fruit)
                        .padding(.vertical, 6)
                        .padding(.horizontal, 8)
                        .background(
                            fruit == "🥑 Avocado" ?
                                Color.indigo.opacity(0.6) :
                                Color.secondary.opacity(0.2)
                        )
                        .cornerRadius(20)
                }
            }
            .padding()
        }
    }
}

Imbricate answered 11/4, 2023 at 9:40 Comment(0)
O
-4

You need to handle line configurations right after Text View. Don't use lineLimit(1) if you need multiple lines.

 HStack(alignment: .top, spacing: 10){
                ForEach(collection.platforms, id: \.self) { platform in
                    Text(platform)
                    .fixedSize(horizontal: false, vertical: true)
                    .lineLimit(10)
                    .multilineTextAlignment(.leading)
                        .padding(.all, 5)
                        .font(.caption)
                        .background(Color.blue)
                        .foregroundColor(Color.white)
                        .cornerRadius(5)

                }
            }
Oneself answered 13/11, 2019 at 17:59 Comment(1)
This doesn't actually work. The individual text views (for each platform) are allowed to wrap their text within their own bounds, but the HStack itself is still going to place them all on the same row.Belong

© 2022 - 2024 — McMap. All rights reserved.