SwiftUI HStack with wrap and dynamic height
Asked Answered
J

5

46

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

Jupiter answered 30/5, 2020 at 12:47 Comment(0)
H
110

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"])
        }
    }
}
Hypogenous answered 30/5, 2020 at 13:39 Comment(11)
This still does the same problem for me if you add it inside a ScrollView or a ListJupiter
@Ludyem, updated. Anyway that's great that you found your own adaptation - I always glad of that.Hypogenous
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.Crazed
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.Persimmon
@LualdiDylan Hi, How can I make this chips center aligned instead of left aligned?Lundberg
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.Drona
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).Enugu
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 } } )Telepathy
..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?Telepathy
Next Level right here. I have some clients that do not want to update to ios14, and this works perfectly in ios13.Hoitytoity
Nice solution. But this is not drawn properly If I remove all of the items and then re-add them to the list.Bunko
N
18

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
        }
    }
}
Nagpur answered 26/12, 2020 at 1:53 Comment(5)
Thank you, your changes make the solution much easier to reason about and reuse.Challenging
This is the best solution. By the way, how to do right alignment rather than current left aligment?Ravenravening
What about a center alignment with this?Hageman
How to use this?Sarcocarp
@Sarcocarp enum Test: String, CaseIterable { case option1 case option2 var displayName: String { self.rawValue.capitalized } } #Preview { WrappingHStack(models: Test.allCases, viewGenerator: { test in Text(test.displayName) }) } Natatorial
J
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

Jupiter answered 30/5, 2020 at 13:38 Comment(4)
Why is the order of the platforms in view changing?Nosedive
Ninetendo is in the last positionNosedive
There's something buggy with this implementation. If the array contains three items, it only displays two of them. I'm not sure if it's because of the number or the width of the items, but it's definitely a problem.Balsamiferous
If you try this with the array [games, bananas, college football] for example, for some reason "bananas" won't be displayed.Balsamiferous
R
7

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)
    }
 }
Ria answered 6/6, 2022 at 17:5 Comment(1)
This worked perfectly for me, thanks for also showing it in useRegressive
B
1

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

  .fixedSize(horizontal: true, vertical: true)
Bacteriostasis answered 1/12, 2021 at 13:44 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.