iosswiftuigeometryreaderlazyvgrid

Trying to detect which GridItem is at top of view SwiftUI


I am trying to ascertain which GridItem would effectively be at position x:0, y:0. To achieve this I am simply trying to use a preferenceKey and GeometryReader. I am adding an .overlay to my GridItems and on the GridItem at gridItemIndex 0 adding the GeometryReader around a Color.clear. My expected logic is to track the Y position of that GridItem. Then by dividing that Y offset by the height of each GridItem I will get which item is currently at the top.

I have this working to a point. Once the GridItem at index "gridItemIndex" position 0 is above a certain offset it is no longer read and the y position rests to 0.0. My assumption for this is due to the view being reused?

Currently I am not getting the reading above 40 but I need to get until the bottom of the LazyVGrid appears.

Here is my code

struct DetectScrollPosition: View {
    
    let gridRowLayout = Array(repeating: GridItem(spacing: 0), count: 7)
    
    @State private var scrollPosition: Int = 0
    
    var body: some View {
        NavigationView {
            ScrollView (.vertical){
                LazyVGrid(columns: gridRowLayout, spacing: 0){
                    ForEach(0..<1092, id: \.self) { gridItemIndex in
                        Text("\(abs(gridItemIndex / 7))")
                            .overlay {
                                if gridItemIndex == 0 {
                                    GeometryReader { geometryProxy in
                                        Color.clear
                                            .updateViewsYPosition(geometryProxy)
                                        
                                    }
                                }
                            }
                    }
                }
                
                .onPreferenceChange(ScrollOffsetPreferenceKey.self) { value in
                    self.scrollPosition = abs(Int(value))
                }
                
            }
            
            .coordinateSpace(.named("scroll"))
            .navigationTitle("The Top Row is: \(scrollPosition)")
            .navigationBarTitleDisplayMode(.inline)
        }
        
    }
}

struct ScrollOffsetPreferenceKey: PreferenceKey {
    static var defaultValue: CGFloat = 0
    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
    }
}

extension View {
    func updateViewsYPosition(_ geometryProxy: GeometryProxy) -> some View {
        let offset = geometryProxy.frame(in: .named("scroll")).origin.y / geometryProxy.frame(in: .named("scroll")).height
        return self.preference(key: ScrollOffsetPreferenceKey.self, value: offset)
    }
    
    
}

apologies for poor gif image enter image description here


Solution

  • Indeed, the views are being reused. You should add a view that sets the preference for every row of the grid.

    .overlay {
        if gridItemIndex % 7 == 0 { // adds this for the first view in every row
            GeometryReader { geometryProxy in
                Color.clear
                    .updateViewsYPosition(geometryProxy, gridItemIndex)
                
            }
        }
    }
    

    Now we need to implement the reduce method in the preference key, because there will be multiple sibling views all setting their own preference. The idea is, after reducing everything, the end result will indicate the view that is on the top left.

    Therefore, we need to store both the frame of the view (for reducing) and the index of the view (so that we can update scrollPosition). We will use a type like this for the preference key:

    struct GridItemPosition: Equatable {
        let index: Int
        let frame: CGRect
    }
    

    This is why we also pass in gridItemIndex to updateViewsYPosition in the overlay.

    The actual preference key would be implemented like this:

    struct ScrollOffsetPreferenceKey: PreferenceKey {
        static var defaultValue = GridItemPosition(index: -1, frame: .null)
        static func reduce(value: inout GridItemPosition, nextValue: () -> GridItemPosition) {
            let next = nextValue()
            if abs(value.frame.minY) > abs(next.frame.minY) {
                value = next
            }
        }
    }
    

    reduce is implemented so that the preference is always the view with a y position that is closest to 0. You can change this criteria to whatever you want.

    In updateViewsYPosition, you should get the frame in the scrollView coordinate space.

    func updateViewsYPosition(_ geometryProxy: GeometryProxy, _ i: Int) -> some View {
        let frame = geometryProxy.frame(in: .scrollView)
        return self.preference(
            key: ScrollOffsetPreferenceKey.self,
            value: GridItemPosition(index: i, frame: frame)
        )
    }
    

    Full code:

    struct ContentView: View {
        
        let gridRowLayout = Array(repeating: GridItem(spacing: 0), count: 7)
        
        @State private var scrollPosition: Int = 0
        
        var body: some View {
            NavigationStack {
                ScrollView (.vertical){
                    LazyVGrid(columns: gridRowLayout, spacing: 0){
                        ForEach(0..<1092, id: \.self) { gridItemIndex in
                            Text("\(abs(gridItemIndex))")
                                .overlay {
                                    if gridItemIndex % 7 == 0 {
                                        GeometryReader { geometryProxy in
                                            Color.clear
                                                .updateViewsYPosition(geometryProxy, gridItemIndex)
                                            
                                        }
                                    }
                                }
                        }
                    }
                    .scrollTargetLayout()
                    
                    .onPreferenceChange(ScrollOffsetPreferenceKey.self) { value in
                        self.scrollPosition = value.index
                    }
                }
                .navigationTitle("The Top Row is: \(scrollPosition)")
                .navigationBarTitleDisplayMode(.inline)
            }
            
        }
    }
    
    struct ScrollOffsetPreferenceKey: PreferenceKey {
        static var defaultValue = GridItemPosition(index: -1, frame: .null)
        static func reduce(value: inout GridItemPosition, nextValue: () -> GridItemPosition) {
            let next = nextValue()
            if abs(value.frame.minY) > abs(next.frame.minY) {
                value = next
            }
        }
    }
    
    extension View {
        func updateViewsYPosition(_ geometryProxy: GeometryProxy, _ i: Int) -> some View {
            let frame = geometryProxy.frame(in: .scrollView)
            return self.preference(
                key: ScrollOffsetPreferenceKey.self,
                value: GridItemPosition(index: i, frame: frame)
            )
        }
    }
    
    struct GridItemPosition: Equatable {
        let index: Int
        let frame: CGRect
    }