2

I'm using anchorPreferences to set the height of GeometryReader to fit the height of its content. The issue I'm experiencing is, after scrolling up and down the List a few times, the app freezes, the design gets messy, and I get the following message in the console:

Bound preference CGFloatPreferenceKey tried to update multiple times per frame.

Any ideas how I can fix this?

IMPORTANT NOTE: I've simplified my design as much as I could.

enter image description here

Here is the code:

struct ListAndPreferences: View {
    var body: some View {
        List(1..<35) { idx in
            HStack {
                Text("idx: \(idx)")
                
                InnerView(idx: idx)
            }
        }
    }
}

struct InnerView: View {
    @State var height: CGFloat = 0
    var idx: Int
    
    var body: some View {
        GeometryReader { proxy in
            generateContent(maxWidth: proxy.frame(in: .global).size.width)
                .anchorPreference(key: CGFloatPreferenceKey.self, value: Anchor<CGRect>.Source.bounds, transform: { anchor in
                    proxy[anchor].size.height
                })
        }
        .frame(height: height)
        .onPreferenceChange(CGFloatPreferenceKey.self, perform: { value in
            height = value
        })
    }
    
    private func generateContent(maxWidth: CGFloat) -> some View {
            VStack {
                HStack {
                    Text("hello")
                        .padding()
                        .background(Color.purple)
                    
                    Text("world")
                        .padding()
                        .background(Color.purple)
                }               
            }
            .frame(width: maxWidth)
    }
}

struct CGFloatPreferenceKey: PreferenceKey {
    static var defaultValue: CGFloat = 0
    static func reduce(value: inout CGFloat , nextValue: () -> CGFloat) {
        value = nextValue()
    }
}
Aнгел
  • 1,153
  • 2
  • 14
  • 24

2 Answers2

2

Actually we don't know how many times on stack SwiftUI can render a body of our custom view, but preferences really required to be written only once (and then they should be transformed, which is more complex).

The possible solution is to use different type of container in Preferences, so values not re-written but accumulated.

Here is modified parts of your code. Tested with Xcode 12.1 / iOS 14.1

// use dictionary to store calculated height per view idx
struct CGFloatPreferenceKey: PreferenceKey {
    static var defaultValue: [Int: CGFloat] = [:]
    static func reduce(value: inout [Int: CGFloat] , nextValue: () -> [Int: CGFloat]) {
        value.merge(nextValue()) { $1 }
    }
}

struct InnerView: View {
    @State var height: CGFloat = 0
    var idx: Int
    
    var body: some View {
        GeometryReader { proxy in
            generateContent(maxWidth: proxy.frame(in: .global).size.width)
                .anchorPreference(key: CGFloatPreferenceKey.self, value: Anchor<CGRect>.Source.bounds, transform: { anchor in
                    [idx: proxy[anchor].size.height]
                })
        }
        .frame(minHeight: height)
        .onPreferenceChange(CGFloatPreferenceKey.self, perform: { value in
            height = value[idx] ?? .zero
        })
    }
    
    private func generateContent(maxWidth: CGFloat) -> some View {
            VStack {
                HStack {
                    Text("hello")
                        .padding()
                        .background(Color.purple)
                    
                    Text("world")
                        .padding()
                        .background(Color.purple)
                }
            }
            .frame(width: maxWidth)
    }
}

Asperi
  • 123,447
  • 8
  • 131
  • 245
  • `but preferences really required to be written only once` - so having "tried to update multiple times per frame." is a bad thing? – Mofawaw Dec 21 '20 at 06:45
  • I don't think it is *bad*, it is just a warning meaning that some of your stored preference values might be lost w/o being applied when needed. – Asperi Dec 21 '20 at 06:47
  • Ah that makes sense. Thank you! – Mofawaw Dec 21 '20 at 06:51
  • Thanks @Asperi! Is there a way to "generate" the "idx" inside InnerView so that I don't have to send it as a parameter? – Aнгел Dec 21 '20 at 07:23
0

The code in your List is trying to do too much. One of the benefits of SwiftUI is that you don't need to manually set the height of your views. Maybe the GeometryReader is just left over from when you simplified your example, but in this simplified case, you don't need it (or Preferences). This is all you need:

import SwiftUI

struct ListAndPreferences: View {
    var body: some View {
        List(1..<35) { idx in
            HStack {
                Text("idx: \(idx)")
                InnerView()
            }
        }
    }
}

struct InnerView: View {
    var body: some View {
        VStack {
            HStack {
                Text("hello")
                    .padding()
                    .background(Color.purple)

                Text("world")
                    .padding()
                    .background(Color.purple)
            }
        }.frame(maxWidth: .infinity)
    }
}

struct InnerView_Previews: PreviewProvider {
    static var previews: some View {
        ListAndPreferences()
    }
}

If for whatever reason you do need to listen for changes via onPreferenceChange, or for that matter, onChange(of:), you need to ensure that any GUI-impacting changes happen on the main thread. The following change would silence the warnings you're seeing in your original code:

.onPreferenceChange(CGFloatPreferenceKey.self) { value in
    DispatchQueue.main.async {
        height = value
    }
}
mbxDev
  • 565
  • 4
  • 16