Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

ScrollView with multiple LazyVGrids jumping around when using .scrollPosition

I am using macOS.

I have multiple LazyVGrid in my ScrollView. I am using .scrollPosition() to track the scroll position. Without using this modifier, I don't have any issues, but if, the scrolling is broken.

The problem is similiar to this one: Add multiple LazyVGrid to ScrollView

Unfortunately, I cannot use this solution, because my Grids definitely have different heights and this will cause the problem.

Here is an example code:

struct ContentView: View {
    
    @State var scrollPosition: UUID? = nil
    
    struct MyStruct: Identifiable {
        let id = UUID()
        let text: String
        let uneven: Bool
    }
        
    @State var elements: [MyStruct]
    
    init() {
        var el = [MyStruct]()
        for i in 1..<30 {
            el.append(MyStruct(text: "Item\(i)", uneven: i % 2 == 0))
        }
        self.elements = el
    }
    
    var body: some View {
        
        VStack {
            
            
            ScrollView(.vertical) {
                
                LazyVStack {
                    
                    ForEach(self.elements) { element in
                        VStack {
                            Text(element.text)
                            LazyVGrid(columns: Array(repeating: GridItem(.fixed(30)), count: 7), content: {
                                ForEach(0..<7 * (element.uneven == true ? 6 : 7), id: \.self) { index in
                                    Text("\(index)")
                                }
                            })
                            .padding(.bottom, 20)
                        }
                    }
                    
                }   // end LazVStack
                .scrollTargetLayout()
                .frame(width: 600)
                
            }
            .scrollPosition(id: self.$scrollPosition)
        }
        
    }
    
}

You will experience the problem when you completely scroll down the list and then scroll up. Scrolling up is not possible anymore.

If you remove the .scrollPosition-modifier, it works. If you change the ForEach line to this:

ForEach(0..<7 * 7, id: \.self) { index in

it will work too, because then every Grid has the same height.

Adding a fake row will solve the problem, but it will also leave some extra space. Adding a negative padding or offset will not help, it will cause the problem again.

I am sure, that this is a bug in SwiftUI and I will file it. But because Apple barely fixes such bugs, does anyone has an idea for a workaround?

like image 632
Lupurus Avatar asked Dec 01 '25 16:12

Lupurus


1 Answers

I created my own modifiers to track the scroll position and it works now (although there is sometimes a small lag when scrolling very fast and having the bounce behaviour on for the ScrollView):

struct ContentView: View {
    
    @State private var offset = CGFloat.zero
    
    struct MyStruct: Identifiable {
        let id = UUID()
        let text: String
        let uneven: Bool
        var height: CGFloat = 0
    }
        
    @State var elements: [MyStruct]
    
    init() {
        var el = [MyStruct]()
        for i in 1..<30 {
            el.append(MyStruct(text: "Item\(i)", uneven: i % 2 == 0))
        }
        self.elements = el
    }
    
    @State var scrollPos: UUID? = nil
    var body: some View {
        
        VStack {
            
            if let scrollPos, let el = self.elements.first(where: { $0.id == scrollPos }) {
                Text(el.text)
            } else {
                Text("NaN")
            }
            
            ScrollView(.vertical) {
                
                VStack {
                    
                    ForEach(self.elements) { element in
                        VStack {
                            Text(element.text)
                            LazyVGrid(columns: Array(repeating: GridItem(.fixed(30)), count: 7), content: {
                                ForEach(0..<7 * (element.uneven == true ? 6 : 7), id: \.self) { index in
                                    Text("\(index)")
                                }
                            })
                        }
                        .scrollPositionTracking(id: element.id)
                    }
                    
                }   // end LazVStack
                .frame(width: 600)
            }
            .scrollPositionTracker(id: self.$scrollPos)
            .coordinateSpace(name: "scroll")
        }
        
    }
    
}


struct ViewOffsetKey: PreferenceKey {
    typealias Value = CGFloat
    static var defaultValue = CGFloat.zero
    static func reduce(value: inout Value, nextValue: () -> Value) {
        value += nextValue()
    }
}

@Observable
class ScrollTrackerNegatives {
    var negatives = [UUID: CGFloat]()
}
struct ScrollPositionTracker: ViewModifier {
    
    @State private var negativesStore = ScrollTrackerNegatives()
    var scrollPos: Binding<UUID?>
    
    func body(content: Content) -> some View {
        content
            .environment(self.negativesStore)
            .background(
                GeometryReader { proxy in
                    if self.negativesStore.negatives.count > 0 {
                        if let max = self.negativesStore.negatives.max(by: { $0.value < $1.value }) {
                            Task { @MainActor in
                                self.scrollPos.wrappedValue = max.key
                            }
                        }
                    }
                    return Color.clear
                }
            )
    }
}
struct ScrollPositionTracking: ViewModifier {

    @Environment(ScrollTrackerNegatives.self) private var negativesStore
    let id: UUID
    
    func body(content: Content) -> some View {
        content
            .background(GeometryReader {
                Color.clear.preference(key: ViewOffsetKey.self,
                                       value: $0.frame(in: .named("scroll")).origin.y)
            })
            .onPreferenceChange(ViewOffsetKey.self) { posY in
                if posY < 0 {
                    Task { @MainActor in
                        self.negativesStore.negatives[id] = posY
                    }
                } else {
                    Task { @MainActor in
                        self.negativesStore.negatives.removeValue(forKey: id)
                    }
                }
            }
    }
}

extension View {
    func scrollPositionTracker(id: Binding<UUID?>) -> some View {
        self
            .modifier(ScrollPositionTracker(scrollPos: id))
    }
    func scrollPositionTracking(id: UUID) -> some View {
        self
            .modifier(ScrollPositionTracking(id: id))
    }
}
like image 87
Lupurus Avatar answered Dec 03 '25 22:12

Lupurus



Donate For Us

If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!