2

Here is the contents of my view:

HStack {
    Banner(items: items)
        .layoutPriority(100)
                
    OptionalView()
}

I want to only display the OptionalView if there is a minimum amount of width left over for it after displaying the items in the Banner view.

I can use GeometryReader, but since it's a push-out view, if the OptionalView is not needed, it takes up some space and pushes the banner items to the left so they are not centered.

HStack {
    Banner(items: items)
        .layoutPriority(100)
                
    GeometryReader { geometry in
        if geometry.size.width >= 70 {
            OptionalView()
        }
    }
}
Jeff G
  • 1,996
  • 1
  • 13
  • 22
  • I'm not sure this is solvable (at least to a satisfiable level) without knowing what Banner really is (ie how it determines its size). For example, if it's just a rectangle with a `frame` with a defined width, it *already* pushes `OptionalView` to the side. Can you create a reproducible example to experiment with? – jnpdx Feb 13 '21 at 00:00
  • Does putting the geometry reader outside the HStack fix it from bumping your Banner off center? – Nicholas Rees Feb 13 '21 at 22:29
  • @NicholasRees That would just give me the width available for the entire `HStack`. I need to know how much width is available after displaying the variable number of items passed into the `Banner` view. And each item in the `Banner` is sized based on the data in the items array, so their widths are not identical. – Jeff G Feb 14 '21 at 18:32
  • So... hard to say without knowing what Banner does... but what I meant was, if you test on a couple of different simulator devices you should be able to figure out what the smallest screen is where displaying your optional item would be acceptable and then test for that devices screen width. If that doesn’t float your boat, you could always offset the position to recenter the banner after geometry reader pushes it off center. – Nicholas Rees Feb 14 '21 at 19:36

3 Answers3

3

The answer is to use SwiftUI's sparsely documented PreferenceKey protocol.

See:

Here's a handy view modifier that returns the frame of any view:

fileprivate struct IntrinsicFramePreferenceKey: PreferenceKey {
    static var defaultValue: CGRect = .zero
    static func reduce(value: inout CGRect, nextValue: () -> CGRect) {}
}

fileprivate struct IntrinsicFrame: ViewModifier {
    @Binding var frame: CGRect
    
    func body(content: Content) -> some View {
        content
            .background(GeometryReader { geometry in
                Color.clear
                    .preference(key: IntrinsicFramePreferenceKey.self, value: geometry.frame(in: .global))
            })
            .onPreferenceChange(IntrinsicFramePreferenceKey.self) { newFrame in
                frame = newFrame
            }
    }
}

extension View {
    func intrinsicFrame(_ frame: Binding<CGRect>) -> some View {
        self.modifier(IntrinsicFrame(frame: frame))
    }
}

Usage:

struct MyView: View {
    @State private var frame = CGRect()

    var body: some View {
        Text("Hello")
            .intrinsicFrame($frame)
    }
}
Jeff G
  • 1,996
  • 1
  • 13
  • 22
1

Update EmptyView is a helper view that takes up room in the declarative-tree, but not on the screen. Here is an example using a slider to dynamically set the number of items in the banner. When the threshold is exceeded the EmptyView swapped in as the second member.

struct HysteresisView: View {
        @State var count : CGFloat = 10
        let threshold : CGFloat = 125
        var rectangles = [BannerItem]()
        
        init() {
            (0..<10).forEach { n in
                let color = UIColor(hue: CGFloat.random(in: 0...1), saturation: 0.75, brightness: 0.75, alpha: 1)
                let width = CGFloat.random(in: 30...100)
                rectangles.append(BannerItem(id: n, color: Color(color), width: width))
            }
        }
        
        var body: some View {
            VStack {
                Slider(value: $count, in: 1...10, label: { Text("Show this many") })
                HStack {
                    ForEach(rectangles.filter({ $0.id < Int(count) }), id: \.id) { rectangle in
                        Rectangle().foregroundColor(rectangle.color).frame(width: rectangle.width, height: 30)
                    }
                    Spacer()
                    GeometryReader { geo in
                        HStack {
                            if geo.size.width > threshold {
                                Text("Optional View")
                            } else {
                                EmptyView()
                            }
                        }
                        .frame(width: threshold)
                        .background(Color.gray)
                    }
                }.frame(height: 25)
                Spacer()
            }
        }
        
        struct BannerItem {
            let id : Int
            let color : Color
            let width : CGFloat
        }
    }
Helperbug
  • 529
  • 3
  • 8
  • Thanks for your input. The issue is, my `Banner` view displays horizontal views based on the data in the `items` array and each view width can be different. In your example, the width of the banner is set in the parent view. In my scenario, `Banner` determines its width. – Jeff G Feb 14 '21 at 18:29
  • The answer code has been updated to swap in rectangles as banner items. Please review and give feedback. – Helperbug Feb 14 '21 at 20:32
0

Here's a wrapper view that may be a solution:

struct IfFits<Content: View>: View {
    let content: () -> Content

    var body: some View {
        ViewThatFits {
            content()
            Spacer()
                .frame(width: 0, height: 0)
        }
    }
}

Usage:

HStack(spacing: 0) {
    Rectangle()
        .fill(.cyan)
        .frame(width: 200)
    IfFits {
        Rectangle()
            .fill(.indigo)
            .frame(width: 50)
    }
}
//.frame(width: 250, height: 50)
.frame(width: 249, height: 50)
.border(.orange)
Olex
  • 178
  • 1
  • 4