Trying to detect which GridItem is at top of view SwiftUI

68 Views Asked by At

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

2

There are 2 best solutions below

0
Sweeper On BEST ANSWER

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
}
0
Benzy Neez On

You are only detecting the position of the first grid item, so I expect that once it has been scrolled a certain distance out of sight, the LazyVGrid is discarding it.

I would suggest putting a "detector" behind the first cell of every row. Also, it can be done using .onChange instead of using a dedicated preference key. Like this:

ScrollView (.vertical){
    LazyVGrid(columns: gridRowLayout, spacing: 0){
        ForEach(0..<1092, id: \.self) { gridItemIndex in
            Text("\(abs(gridItemIndex / 7))")
                .background {
                    if gridItemIndex % gridRowLayout.count == 0 {
                        GeometryReader { geometryProxy in
                            let height = geometryProxy.size.height
                            let minY = geometryProxy.frame(in: .named("scroll")).minY
                            let isTopRow = minY >= 0 && minY - height < 0
                            Color.clear
                                .onChange(of: isTopRow) { oldVal, newVal in
                                    if newVal {
                                        scrollPosition = gridItemIndex / gridRowLayout.count
                                    }
                                }

                        }
                    }
                }
        }
    }
}
.coordinateSpace(.named("scroll"))
.navigationTitle("The Top Row is: \(scrollPosition)")
.navigationBarTitleDisplayMode(.inline)

The other code is unchanged, except that ScrollOffsetPreferenceKey and the view extension updateViewsYPosition are no longer needed.