26

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

Dávid Pásztor
  • 40,247
  • 8
  • 59
  • 80
Flolle
  • 283
  • 1
  • 4
  • 9

6 Answers6

35

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()
    }
}
Asperi
  • 123,447
  • 8
  • 131
  • 245
  • Thank you very much. Unfortunately there are still a few display errors. Do you have any idea how to fix this? Screenshot: http://awwfood.de/tags_issue01.png – Flolle Nov 19 '19 at 08:34
  • 1
    This is brilliant!! @Asperi – Brett Feb 25 '20 at 03:42
  • @MichałZiobro It won't work with duplicate text items as presented. Submitted an edit to fix. Maybe that was the problem? – aliak May 08 '20 at 10:18
  • Thanks for the example. It explains a lot – Mr.OFF Sep 07 '20 at 15:24
  • This is great, thank you. Hadn't known about alignmentGuides before this. Adapted it into a SwiftUI [tagging interface](https://gist.github.com/mralexhay/d16aab434b9d765c13b9180fb42aada9) in case that's useful for others looking for something similar – mralexhay Nov 21 '20 at 10:45
  • @Asperi Thanks for the trick. I work perfectly. – Luc-Olivier Apr 09 '21 at 22:43
3

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

Kane Buckthorpe
  • 57
  • 1
  • 10
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

Lukas Würzburger
  • 6,207
  • 7
  • 35
  • 68
Michał Ziobro
  • 7,390
  • 6
  • 50
  • 99
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()
    }
}
ARR
  • 1,282
  • 11
  • 22
0

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.

Daniel
  • 17,803
  • 7
  • 74
  • 142
-2

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)

                }
            }
E.Coms
  • 9,003
  • 2
  • 14
  • 30
  • 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. – dented42 Dec 28 '20 at 00:18