40

I have this view to show text tags on multiple lines which I got from SwiftUI HStack with Wrap, but when I add it in a VStack the tags overlap any other view that I put below. The tags are shown properly but the height of the view itself is not calculated inside the VStack. How can I make this view use the height of is content?

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

Example code:

struct ExampleTagsView: View {
    var body: some View {
        ScrollView {
            VStack(alignment: .leading) {
                Text("Platforms:")
                TestWrappedLayout()

                Text("Other Platforms:")
                TestWrappedLayout()
            }
        }
    }
}

struct ExampleTagsView_Previews: PreviewProvider {
    static var previews: some View {
        ExampleTagsView()
    }
}

Result: enter image description here

Ludyem
  • 1,709
  • 1
  • 18
  • 33

5 Answers5

102

Ok, here is a bit more generic & improved variant (for the solution initially introduced in SwiftUI HStack with Wrap)

Tested with Xcode 11.4 / iOS 13.4

Note: as height of view is calculated dynamically the result works in run-time, not in Preview

enter image description here

struct TagCloudView: View {
    var tags: [String]

    @State private var totalHeight 
          = CGFloat.zero       // << variant for ScrollView/List
    //    = CGFloat.infinity   // << variant for VStack

    var body: some View {
        VStack {
            GeometryReader { geometry in
                self.generateContent(in: geometry)
            }
        }
        .frame(height: totalHeight)// << variant for ScrollView/List
        //.frame(maxHeight: totalHeight) // << variant for VStack
    }

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

        return ZStack(alignment: .topLeading) {
            ForEach(self.tags, id: \.self) { tag in
                self.item(for: tag)
                    .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 tag == self.tags.last! {
                            width = 0 //last item
                        } else {
                            width -= d.width
                        }
                        return result
                    })
                    .alignmentGuide(.top, computeValue: {d in
                        let result = height
                        if tag == self.tags.last! {
                            height = 0 // last item
                        }
                        return result
                    })
            }
        }.background(viewHeightReader($totalHeight))
    }

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

    private func viewHeightReader(_ binding: Binding<CGFloat>) -> some View {
        return GeometryReader { geometry -> Color in
            let rect = geometry.frame(in: .local)
            DispatchQueue.main.async {
                binding.wrappedValue = rect.size.height
            }
            return .clear
        }
    }
}

struct TestTagCloudView : View {
    var body: some View {
        VStack {
            Text("Header").font(.largeTitle)
            TagCloudView(tags: ["Ninetendo", "XBox", "PlayStation", "PlayStation 2", "PlayStation 3", "PlayStation 4"])
            Text("Some other text")
            Divider()
            Text("Some other cloud")
            TagCloudView(tags: ["Apple", "Google", "Amazon", "Microsoft", "Oracle", "Facebook"])
        }
    }
}
Martijn Pieters
  • 1,048,767
  • 296
  • 4,058
  • 3,343
Asperi
  • 228,894
  • 20
  • 464
  • 690
  • This still does the same problem for me if you add it inside a ScrollView or a List – Ludyem May 30 '20 at 13:44
  • 2
    @Ludyem, updated. Anyway that's great that you found your own adaptation - I always glad of that. – Asperi May 30 '20 at 13:53
  • Great!, But I would like to select any tag by clicking on it. I tried to add onTabGesture to the Text in item(), but it seems that only the first row of tags is "sensible". I also tried to replace the Text by a Button, but nothing changed. – Dario Scoppelletti Jul 05 '20 at 20:17
  • Works well. Thank you. How to make it right aligned? So on your screen I need that Facebook under Oracle. And Playstation 3 and 4 under Playstation 2. – StronkStinq Jul 22 '20 at 02:11
  • 5
    @LualdiDylan Hi, How can I make this chips center aligned instead of left aligned? – Jigar Aug 25 '20 at 15:58
  • 1
    this is an excellent solution, but why the alignment of the ZStack (which's topLeading) really matters ? when I change it to something else it doesn't place items correctly. – JAHelia Sep 16 '20 at 07:24
  • If the item text is large enough to take more than 1 line, the whole layout will shift and the tags will overlap each other. The easiest solution I've found is to set a limit on the number of lines for the item text `.lineLimit(1)`. – Iaenhaall Mar 10 '21 at 08:56
  • This is brilliant!! I was able to get it working in Xcode preview by changing the viewHeightReader to using onAppear instead of DispatchQueue: background( GeometryReader { geometry in Color.clear.onAppear { height.wrappedValue = geometry.size.height } } ) – TruMan1 Apr 02 '21 at 13:52
  • ..but im not quite sure what `geometry.frame(in: .local)` does, does any mind explaining a bit what this means and if it's really necessary? – TruMan1 Apr 02 '21 at 13:53
  • Next Level right here. I have some clients that do not want to update to ios14, and this works perfectly in ios13. – DaWiseguy Sep 29 '21 at 17:10
  • Nice solution. But this is not drawn properly If I remove all of the items and then re-add them to the list. – hariszaman Feb 11 '22 at 13:55
15

I adapted Asperi's solution to accept any type of view and model. Thought I would share it here. I added it to a GitHub Gist and included the code here.

struct WrappingHStack<Model, V>: View where Model: Hashable, V: View {
    typealias ViewGenerator = (Model) -> V
    
    var models: [Model]
    var viewGenerator: ViewGenerator
    var horizontalSpacing: CGFloat = 2
    var verticalSpacing: CGFloat = 0

    @State private var totalHeight
          = CGFloat.zero       // << variant for ScrollView/List
    //    = CGFloat.infinity   // << variant for VStack

    var body: some View {
        VStack {
            GeometryReader { geometry in
                self.generateContent(in: geometry)
            }
        }
        .frame(height: totalHeight)// << variant for ScrollView/List
        //.frame(maxHeight: totalHeight) // << variant for VStack
    }

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

        return ZStack(alignment: .topLeading) {
            ForEach(self.models, id: \.self) { models in
                viewGenerator(models)
                    .padding(.horizontal, horizontalSpacing)
                    .padding(.vertical, verticalSpacing)
                    .alignmentGuide(.leading, computeValue: { dimension in
                        if (abs(width - dimension.width) > geometry.size.width)
                        {
                            width = 0
                            height -= dimension.height
                        }
                        let result = width
                        if models == self.models.last! {
                            width = 0 //last item
                        } else {
                            width -= dimension.width
                        }
                        return result
                    })
                    .alignmentGuide(.top, computeValue: {dimension in
                        let result = height
                        if models == self.models.last! {
                            height = 0 // last item
                        }
                        return result
                    })
            }
        }.background(viewHeightReader($totalHeight))
    }

    private func viewHeightReader(_ binding: Binding<CGFloat>) -> some View {
        return GeometryReader { geometry -> Color in
            let rect = geometry.frame(in: .local)
            DispatchQueue.main.async {
                binding.wrappedValue = rect.size.height
            }
            return .clear
        }
    }
}
robhasacamera
  • 2,967
  • 2
  • 28
  • 41
13

I just managed to solve this by moving the GeometryReader up to the ExampleTagsView and using platforms.first instead of last inside .alignmentGuide

Full code:

import SwiftUI

struct ExampleTagsView: View {
    var body: some View {
        GeometryReader { geometry in
            ScrollView(.vertical) {
                VStack(alignment: .leading) {
                    Text("Platforms:")
                    TestWrappedLayout(geometry: geometry)

                    Text("Other Platforms:")
                    TestWrappedLayout(geometry: geometry)
                }
            }
        }
    }
}

struct ExampleTagsView_Previews: PreviewProvider {
    static var previews: some View {
        ExampleTagsView()
    }
}

struct TestWrappedLayout: View {
    @State var platforms = ["Ninetendo", "XBox", "PlayStation", "PlayStation 2", "PlayStation 3", "PlayStation 4", "PlayStation 5", "Ni", "Xct5Box", "PlayStatavtion", "PlvayStation 2", "PlayStatiadfon 3", "PlaySdatation 4", "PlaySdtation 5"]
    let geometry: GeometryProxy

    var body: some View {
        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.first! {
                            width = 0 //last item
                        } else {
                            width -= d.width
                        }
                        return result
                    })
                    .alignmentGuide(.top, computeValue: {d in
                        let result = height
                        if platform == self.platforms.first! {
                            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)
    }
}

Result: enter image description here

Ludyem
  • 1,709
  • 1
  • 18
  • 33
6

I adapted robhasacamera's solution (which was adapted previously from Asperi) in a way this can be used in a different package. I have a package only for helpers and view extensions, for example.

import SwiftUI

public struct WrappedHStack<Data, V>: View where Data: RandomAccessCollection, V: View {
    
    // MARK: - Properties
    public typealias ViewGenerator = (Data.Element) -> V
    
    private var models: Data
    private var horizontalSpacing: CGFloat
    private var verticalSpacing: CGFloat
    private var variant: WrappedHStackVariant
    private var viewGenerator: ViewGenerator
    
    @State private var totalHeight: CGFloat
    
    public init(_ models: Data, horizontalSpacing: CGFloat = 4, verticalSpacing: CGFloat = 4,
                variant: WrappedHStackVariant = .lists, @ViewBuilder viewGenerator: @escaping ViewGenerator) {
        self.models = models
        self.horizontalSpacing = horizontalSpacing
        self.verticalSpacing = verticalSpacing
        self.variant = variant
        _totalHeight = variant == .lists ? State<CGFloat>(initialValue: CGFloat.zero) : State<CGFloat>(initialValue: CGFloat.infinity)
        self.viewGenerator = viewGenerator
    }
    
    // MARK: - Views
    public var body: some View {
        VStack {
            GeometryReader { geometry in
                self.generateContent(in: geometry)
            }
        }.modifier(FrameViewModifier(variant: self.variant, totalHeight: $totalHeight))
    }
    
    private func generateContent(in geometry: GeometryProxy) -> some View {
        var width = CGFloat.zero
        var height = CGFloat.zero
        
        return ZStack(alignment: .topLeading) {
            ForEach(0..<self.models.count, id: \.self) { index in
                let idx = self.models.index(self.models.startIndex, offsetBy: index)
                viewGenerator(self.models[idx])
                    .padding(.horizontal, horizontalSpacing)
                    .padding(.vertical, verticalSpacing)
                    .alignmentGuide(.leading, computeValue: { dimension in
                        if abs(width - dimension.width) > geometry.size.width {
                            width = 0
                            height -= dimension.height
                        }
                        let result = width
                        
                        if index == (self.models.count - 1) {
                            width = 0 // last item
                        } else {
                            width -= dimension.width
                        }
                        return result
                    })
                    .alignmentGuide(.top, computeValue: {_ in
                        let result = height
                        if index == (self.models.count - 1) {
                            height = 0 // last item
                        }
                        return result
                    })
            }
        }.background(viewHeightReader($totalHeight))
    }
}

public func viewHeightReader(_ binding: Binding<CGFloat>) -> some View {
    return GeometryReader { geometry -> Color in
        let rect = geometry.frame(in: .local)
        DispatchQueue.main.async {
            binding.wrappedValue = rect.size.height
        }
        return .clear
    }
}

public enum WrappedHStackVariant {
    case lists // ScrollView/List/LazyVStack
    case stacks // VStack/ZStack
}

internal struct FrameViewModifier: ViewModifier {
    
    var variant: WrappedHStackVariant
    @Binding var totalHeight: CGFloat
    
    func body(content: Content) -> some View {
        if variant == .lists {
            content
                .frame(height: totalHeight)
        } else {
            content
                .frame(maxHeight: totalHeight)
        }
    }
}

Also, having the @ViewBuilder annotation before the viewGenerator, allow us to use it like this:

 var body: some View {
    WrappedHStack(self.models, id: \.self) { model in
      YourViewHere(model: model)
    }
 }
Alexandre
  • 349
  • 6
  • 10
2

You can use "fixedSize" to wrap content in one or 2 directions:

  .fixedSize(horizontal: true, vertical: true)