SwiftUI's anchorPreference collapses height of its view

Viewed 283

I am using SwiftUI and I am trying to pass up the height from a subview up to its parent view. It’s my understanding to use something like PreferenceKey along with .anchorPreference and then act on the change using .onPreferenceChange.

However, due to the lack of documentation on Apple’s end, I am not sure if I am using this correctly or if this is a bug with the framework perhaps.

Essentially, I want a view that can grow or shrink based on its content, however, I want to cap its size, so it doesn’t grow past, say 300 pts vertically. After that, any clipped content will be accessible via its ScrollView.

The issue is that the value is always zero for height, but I get correct values for the width.

struct SizePreferenceKey: PreferenceKey {
    static var defaultValue: CGSize = .zero

    static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
        value = nextValue()
    }
}

VStack { 
    GeometryReader { geometry in
        VStack(alignment: .leading) {
            content()
        }
        .padding(.top, 10)
        .padding([.leading, .bottom, .trailing], 20)
        .anchorPreference(key: SizePreferenceKey.self, value: .bounds, transform: { geometry[$0].size })
    }
}
.onPreferenceChange(SizePreferenceKey.self) { self.contentHeight = $0.height }

2 Answers

When you want to get size of content then you need to read it from inside content instead of outside parent available space... in your case you could do this (as content itself is unknown) from content's background, like

VStack(alignment: .leading) {
    content()
}
.padding(.top, 10)
.padding([.leading, .bottom, .trailing], 20)
.background(GeometryReader { geometry in
    Color.clear
       .anchorPreference(key: SizePreferenceKey.self, value: .bounds, transform: { geometry[$0].size })

})
.onPreferenceChange(SizePreferenceKey.self) { self.contentHeight = $0.height }

Note: content() should have determined size from itself, otherwise you'll get chicken-egg problem in ScrollView

Unfortunately, there seems to be no easy solution for this. I came up with this:

Anchors are partial complete values and require a GeometryProxy to return a value. That is, you create an anchor value - say a bounds property - for any child view (whose value is incomplete at this time). Then you can get the actual bounds value relative to a given geometry proxy only when you have that proxy.

With onPreferenceChange you don't get a geometry proxy, though. You need to use backgroundPreferenceValue or overlayPreferenceValue.

The idea would be now, to use backgroundPreferenceValue, create a geometry proxy and use this proxy to relate your "bounds" anchors that have been created for each view in your scroll view content and which have been collected with an appropriate preference key, storing anchor bounds values in an array. When you have your proxy and the anchors (view bounds) you can calculate the actual bounds for each view relative to your geometry proxy - and this proxy relates to your ScrollView.

Then with backgroundPreferenceValue we could set the frame of the background view of the ScrollView. However, there's a catch:

The problem with a ScrollView is, that you cannot set the background and expect the scroll view sets its frame accordingly. That won't work.

The solution to this is using a @State variable containing the height of the content, respectively the max height. It must be set somehow when the bounds are available. This is in backgroundPreferenceValue, however, we cannot set this state property directly, since we are in the view "update phase". We can workaround this problem by just using onAppear where we can set a state property.

The state property "height" can then be used to set the frame of the ScrollView directly using the frame modifier.

See code below:

Xcode Version 13.0 beta 4:

import SwiftUI

struct ContentView: View {

    let labels = (0...1).map { "- \($0) -" }
    //let labels = (0...9).map { "- \($0) -" }

    @State var height: CGFloat = 0

    var body: some View {
        HStack {
            ScrollView {
                ForEach(labels, id: \.self) {
                    Text($0)
                        .anchorPreference(
                            key: ContentFramesStorePreferenceKey.self,
                            value: .bounds,
                            transform: { [$0] })
                }
            }
        }
        .frame(height: height)
        .backgroundPreferenceValue(ContentFramesStorePreferenceKey.self) { anchors in
            GeometryReader { proxy in
                let boundss: [CGRect] = anchors.map { proxy[$0] }
                let bounds = boundss.reduce(CGRect.zero) { partialResult, rect in
                    partialResult.union(rect)
                }
                let maxHeight = min(bounds.height, 100)
                Color.red.frame(width: proxy.size.width, height: maxHeight)
                    .onAppear {
                        self.height = maxHeight
                    }
            }
        }
    }

}


fileprivate struct ContentFramesStorePreferenceKey: PreferenceKey {
    typealias Value = [Anchor<CGRect>]
    static var defaultValue: Value = []

    static func reduce(value: inout Value, nextValue: () -> Value) {
        value = value + nextValue()
    }
}

import PlaygroundSupport
PlaygroundPage.current.setLiveView(
    NavigationView {
        ContentView()
    }
    .navigationViewStyle(.stack)
)

Related