Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Dynamically size a GeometryReader height based on it's elements

Tags:

swiftui

I'm trying to do something that's pretty straight forward in my mind.

I want a subview of a VStack to dynamically change its height based on its content (ProblematicView in the sample below).

It usually works pretty well, but in this case ProblematicView contains a GeometryReader (to simulate a HStack over several lines).

However, the GeometryReader greedily takes all the space it can (the expected behavior happens if you remove the GeometryReader and it's content). Unfortunately on the Parent view (UmbrellaView in the sample below), the UmbrellaView VStack assigns 50% of itself to the ProblematicView instead of the minimal size to display the content of the view.

I've spend a few hours playing with min/ideal/maxHeight frame arguments, to no avail.

Is what I'm trying to achieve doable?

I added pictures at the bottom to clarify visually.

struct UmbrellaView: View {
    var body: some View {
        VStack(spacing: 0) {
            ProblematicView()
            .background(Color.blue)

            ScrollView(.vertical) {
                Group {
                    Text("A little bit about this").font(.system(size: 20))
                    Divider()
                }
                Group {
                    Text("some").font(.system(size: 20))

                    Divider()
                }
                Group {
                    Text("group").font(.system(size: 20)).padding(.bottom)
                    Divider()
                }
                Group {
                    Text("content").font(.system(size: 20))
                }
            }

        }
    }
}


struct ProblematicView: View {

    var body: some View {
        let tags: [String] = ["content", "content 2 ", "content 3"]
        var width = CGFloat.zero
        var height = CGFloat.zero

        return VStack(alignment: .center) {
            Text("Some reasonnably long text that changes dynamically do can be any size").background(Color.red)
            GeometryReader { g in
                ZStack(alignment: .topLeading) {
                    ForEach(tags, id: \.self) { tag in
                        TagView(content: tag, color: .red, action: {})
                            .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 == tags.last! {
                                    width = 0 //last item
                                } else {
                                    width -= d.width
                                }
                                return result
                            })
                            .alignmentGuide(.top, computeValue: {d in
                                let result = height
                                if tag == tags.last! {
                                    height = 0 // last item
                                }
                                return result
                            })
                    }
                }.background(Color.green)
            }.background(Color.blue)
        }.background(Color.gray)
    }
}

struct TagView: View {
    let content: String
    let color: Color
    let action: () -> Void?

    var body: some View {
        HStack {
            Text(content).padding(EdgeInsets(top: 5, leading: 5, bottom: 5, trailing: 5))
            Button(action: {}) {
                Image(systemName: "xmark.circle").foregroundColor(Color.gray)
            }.padding(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 7))
        }
        .background(color)
        .cornerRadius(8.0)
    }
}

struct ProblematicView_Previews: PreviewProvider {
    static var previews: some View {
        return ProblematicView()
    }
}


struct UmbrellaView_Previews: PreviewProvider {
    static var previews: some View {
        return UmbrellaView()
    }
}

ProblematicView Preview

UmbrellaView Preview

like image 781
Pasta Avatar asked Apr 19 '20 20:04

Pasta


2 Answers

Due to "hen-egg" problem in nature of GeometryReader the solution for topic question is possible only in run-time, because 1) initial height is unknown 2) it needs to calculate internal size based on all available external size 3) it needs to tight external size to calculated internal size.

So here is possible approach (with some additional fixes in your code)

previewrun-timerun-time2

  1. Preview 2-3) Run-time

Code:

struct ProblematicView: View {

    @State private var totalHeight = CGFloat(100) // no matter - just for static Preview !!
    @State private var tags: [String] = ["content", "content 2 ", "content 3", "content 4", "content 5"]

    var body: some View {
        var width = CGFloat.zero
        var height = CGFloat.zero

        return VStack {
            Text("Some reasonnably long text that changes dynamically do can be any size").background(Color.red)
            VStack { // << external container
                GeometryReader { g in
                    ZStack(alignment: .topLeading) { // internal container
                        ForEach(self.tags, id: \.self) { tag in
                            TagView(content: tag, color: .red, action: {
                                    // self.tags.removeLast()         // << just for testing
                                })
                                .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(Color.green)
                    .background(GeometryReader {gp -> Color in
                        DispatchQueue.main.async {
                            // update on next cycle with calculated height of ZStack !!!
                            self.totalHeight = gp.size.height
                        }
                        return Color.clear
                    })
                }.background(Color.blue)
            }.frame(height: totalHeight)
        }.background(Color.gray)
    }
}

struct TagView: View {
    let content: String
    let color: Color
    let action: (() -> Void)?

    var body: some View {
        HStack {
            Text(content).padding(EdgeInsets(top: 5, leading: 5, bottom: 5, trailing: 5))
            Button(action: action ?? {}) {
                Image(systemName: "xmark.circle").foregroundColor(Color.gray)
            }.padding(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 7))
        }
        .background(color)
        .cornerRadius(8.0)
    }
}
like image 183
Asperi Avatar answered Oct 21 '22 09:10

Asperi


Based on @Asperi's code I've implemented a universal solution. It works in Previews and is compatible with iOS 13+. My solution does not use DispatchQueue.main.async and has a convenient @ViewBuilder for you to toss in any View you like. Put the VerticalFlow in VStack or ScrollView. Set hSpacing and vSpacing to items. Add padding to the whole View.

Simple example:

struct ContentView: View {
    @State var items: [String] = ["One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight"]
    var body: some View {
        VerticalFlow(items: $items) { item in
            Text(item)
        }
    }
}

VerticalFlow.swift:

import SwiftUI

struct VerticalFlow<Item, ItemView: View>: View {
    @Binding var items: [Item]
    var hSpacing: CGFloat = 20
    var vSpacing: CGFloat = 10
    @ViewBuilder var itemViewBuilder: (Item) -> ItemView
    @SwiftUI.State private var size: CGSize = .zero
    var body: some View {
        var width: CGFloat = .zero
        var height: CGFloat = .zero
        VStack {
            GeometryReader { geometryProxy in
                ZStack(alignment: .topLeading) {
                    ForEach(items.indices, id: \.self) { i in
                        itemViewBuilder(items[i])
                            .alignmentGuide(.leading) { dimensions in
                                if abs(width - dimensions.width) > geometryProxy.size.width {
                                    width = 0
                                    height -= dimensions.height + vSpacing
                                }
                                let leadingOffset = width
                                if i == items.count - 1 {
                                    width = 0
                                } else {
                                    width -= dimensions.width + hSpacing
                                }
                                return leadingOffset
                            }
                            .alignmentGuide(.top) { dimensions in
                                let topOffset = height
                                if i == items.count - 1 {
                                    height = 0
                                }
                                return topOffset
                            }
                    }
                }
                .readVerticalFlowSize(to: $size)
            }
        }
        .frame(height: size.height > 0 ? size.height : nil)
    }
}

struct VerticalFlow_Previews: PreviewProvider {
    @SwiftUI.State static var items: [String] = [
        "One 1", "Two 2", "Three 3", "Four 4", "Eleven 5", "Six 6",
        "Seven 7", "Eight 8", "Nine 9", "Ten 10", "Eleven 11",
        "ASDFGHJKLqwertyyuio d fadsf",
        "Poiuytrewq lkjhgfdsa mnbvcxzI 0987654321"
    ]
    static var previews: some View {
        VStack {
            Text("Text at the top")
            VerticalFlow(items: $items) { item in
                VerticalFlowItem(systemImage: "pencil.circle", title: item, isSelected: true)
            }
            Text("Text at the bottom")
        }
        ScrollView {
            VStack {
                Text("Text at the top")
                VerticalFlow(items: $items) { item in
                    VerticalFlowItem(systemImage: "pencil.circle", title: item, isSelected: true)
                }
                Text("Text at the bottom")
            }
        }
    }
}

private struct VerticalFlowItem: View {
    let systemImage: String
    let title: String
    @SwiftUI.State var isSelected: Bool
    var body: some View {
        HStack {
            Image(systemName: systemImage).font(.title3)
            Text(title).font(.title3).lineLimit(1)
        }
        .padding(10)
        .foregroundColor(isSelected ? .white : .blue)
        .background(isSelected ? Color.blue : Color.white)
        .cornerRadius(40)
        .overlay(RoundedRectangle(cornerRadius: 40).stroke(Color.blue, lineWidth: 1.5))
        .onTapGesture {
            isSelected.toggle()
        }
    }
}

private extension View {
    
    func readVerticalFlowSize(to size: Binding<CGSize>) -> some View {
        background(GeometryReader { proxy in
            Color.clear.preference(
                key: VerticalFlowSizePreferenceKey.self,
                value: proxy.size
            )
        })
        .onPreferenceChange(VerticalFlowSizePreferenceKey.self) {
            size.wrappedValue = $0
        }
    }
    
}

private struct VerticalFlowSizePreferenceKey: PreferenceKey {
    
    static let defaultValue: CGSize = .zero

    static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
        let next = nextValue()
        if next != .zero {
            value = next
        }
    }
    
}

VerticalFlow Preview

like image 2
MarK Avatar answered Oct 21 '22 10:10

MarK