skip to Main Content

I’m struggling trying to implement a PreferenceKey. I’ve read countless articles, so
obviously I’m missing something. onPreferenceChange reports once at simulator startup but
not during scrolling. Scrolling updates the text in the TextEditor overlay, but the
offsetCGSize property is apparently never updated. Here’s a simplified version
of the code:

struct SOView: View {

    @State private var firstText: String = "first"
    @State private var secondText: String = "second"
    @State private var thirdText: String = "third"

    @State private var offsetCGSize: CGSize = .zero

    var body: some View {
        ScrollView {
            VStack {
            
                ForEach(0..<20) {index in
                    Text("Line number (index)")
                }
            
                Image(systemName: "globe")
                    .imageScale(.large)
                    .foregroundColor(.accentColor)
                TextField("First", text: $firstText)
            
                GeometryReader { teGeo in
                    TextEditor(text: $secondText)
                        .frame(height: 100)
                        .overlay(
                            RoundedRectangle(cornerRadius: 10).stroke(lineWidth: 2)
                        )
                        .overlay(
                            Text("(teGeo.frame(in: .global).minY)")
                        )
                        .preference(key: OffsetPreferenceKey.self, value: CGSize(width: 0, height: teGeo.frame(in: .global).minY))
                }//geo
                .frame(height: 100)
            
                TextField("Third", text: $thirdText)
                Text("TextEditor top is (offsetCGSize.height)")
            
            }//v
            .padding()
        
        }//scroll
        .onPreferenceChange(OffsetPreferenceKey.self) { value in
            offsetCGSize = value
            print("offsetCGSize is (offsetCGSize)")
        }

    }//body
}//so view

private struct OffsetPreferenceKey: PreferenceKey {
    static var defaultValue: CGSize = CGSize.zero
    static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
        value = nextValue()
    }
}//off pref

Any guidance would be appreciated. Xcode 14.0.1, iOS 16

2

Answers


  1. Chosen as BEST ANSWER

    For others - I don't understand why, but by moving the two TextFields, the Image and the reporting Text outside of the ScrollView I get the results I expect. (In this file, I changed the key name to OffsetPreferenceKey2)

    enter image description here

    struct SOView2: View {
        @State private var firstText: String = "first"
        @State private var secondText: String = "second"
        @State private var thirdText: String = "third"
    
        @State private var offsetCGSize: CGSize = .zero
    
        var body: some View {
            VStack {
            
                Image(systemName: "globe")
                    .imageScale(.large)
                    .foregroundColor(.accentColor)
                TextField("First", text: $firstText)
                    .padding(.horizontal)
            
                ScrollView {
                    VStack {
                        ForEach(0..<20) {index in
                            Text("Line number (index)")
                        }
                    
                        GeometryReader { teGeo in
                            TextEditor(text: $secondText)
                                .frame(height: 100)
                                .overlay(
                                    RoundedRectangle(cornerRadius: 10).stroke(lineWidth: 2)
                                )
                                .overlay(
                                    Text("(teGeo.frame(in: .global).minY)")
                                )
                                .preference(key: OffsetPreferenceKey2.self, value: CGSize(width: 0, height: teGeo.frame(in: .global).minY))
                        }//geo
                        .frame(height: 100)
                    
                    }//v
                    .padding()
                }//scroll
                .onPreferenceChange(OffsetPreferenceKey2.self) { value in
                    offsetCGSize = value
                    print("offsetCGSize is (offsetCGSize)")
                }// pref change
            
                TextField("Third", text: $thirdText)
                    .padding(.horizontal)
                Text("TextEditor top is (offsetCGSize.height)")
            
            }//outer v
        
        }//body
    }//so view
    
    private struct OffsetPreferenceKey2: PreferenceKey {
        static var defaultValue: CGSize = CGSize.zero
        static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
            value = nextValue()
        }
    }//off pref
    

  2. Like JohnSF, I have also been searching for use cases for preferenceKey. After a lot of searching and playing around, I wanted to post some information that hopefully will help others. Essentially there are 3 parts to a successful implementation. In my example, I needed to be able to react the any size change to a ScrollView (i.e. different screen resolutions, portrait vs landscape).

    1. The first part is to define a preferenceKey struct with the value you want to track. In my example, I decided to get the CGRect of the ScrollView so I had the position (x, y) and size (width, height)
    struct MyPreferenceKey: PreferenceKey {
        typealias Value  = CGRect // <-- 1.
        static var defaultValue: CGRect = .zero
        
        static func reduce(value: inout CGRect, nextValue: () -> CGRect) {
            value = nextValue()
        }
    }
    

    So, if you wanted to track just the size, replace all the CGRect with CGSize, etc.

    Next, define an @State variable to store the values you want to track. In my case I used

    @State private var rect: CGRect: .zero

    So my changes I’m tracking will be found in my "rect" variable anytime I want them.

    1. The next part is to decide where to place your preference key. Part of this has to include a GeometryReader. In this example, I have a hierarchy of ScrollView -> GeometryReader -> ZStack. So, I placed the preferenceKey on the ZStack. Both the GeometryReader and the ZStack will by default fit the entire size of the ScrollView. I used the GeoProxy (I called this "scroll") to fill out my CGRect by defining the origin and size of a my CGRect
        @State private var rect: CGRect = .zero
    
        ScrollView(.horizontal, showsIndicators: false) {
            GeometryReader { scroll in
                ZStack {
                  // Stuff
                } //: ZSTACK
                .preference(key: MyPreferenceKey, value: CGRect(origin: CGPoint(x: scroll.size.width / 2, y: scroll.size.height / 2), size: scroll.size)) // <-- 2.
            } //: GEOMETRY SCROLL
        } //: SCROLL
        .onPreferenceChange(MyPreferenceKey.self) { value in
            print("PreferenceChange (value)")
            rect = value // <-- 3.
        }
    
    1. Then, as shown above, I placed the onPreferenceChange modifier on the actual ScrollView so that whenever the ScrollView changed, I would know its new size and position by using my "rect" variable.

    Lastly if there is a View that you want to track the size of like an image, you can place your preference key and a GeometryReader in the background like this.

    Image("someImage")
       .background(
          GeometryReader { geometry in
             Color.clear
             .preferenceKey here to track size
    

    Then put your .onPreferenceChange on something like a VStack, etc. Then if you rotate your device, you can read the new size.

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search