SwiftUI - ScrollView with LazyVGrid and onTap, scrolling randomly by itself

Hi everyone,

I'm having a hard time figuring out why the code below results in weird behaviour with the ScrollView.

Code :

struct MainView: View {
    @State var strings: [String] = ["A", "B", "C", "D", "E"]
    @State var ints: [Int] = Array(1...300)
    @State var selectedInt: Int?
    var body: some View {
        ZStack {
            ScrollView {
                VStack {
                    ForEach(strings, id: \.self) { string in
                        VStack {
                            HStack {
                                Text(string)
                                Spacer()
                            }
                            LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())]) {
                                ForEach(ints, id: \.self) { int in
                                    VStack {
                                        Text(String(int))
                                        
                                        Spacer()
                                    }
                                    .frame(minWidth: 0, maxWidth: .infinity)
                                    .frame(height: 200)
                                    .background(
                                        RoundedRectangle(cornerRadius: 5)
                                            .fill(int % 2 == 0 ? .orange : .green)
                                    )
                                    .onTapGesture {
                                        self.selectedInt = int
                                    }
                                }
                            }
                        }
                    }
                }
                .padding()
            }
            
            if let selectedInt {
                VStack {
                    Spacer()
                    Text(String(selectedInt))
                    Spacer()
                    
                    Button {
                        self.selectedInt = nil
                    } label: {
                        Image(systemName: "x.circle")
                            .resizable()
                            .scaledToFit()
                            .frame(width: 30)
                    }
                }
                .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity)
            }
        }
    }
}

Issue: Everything is working fine until I scroll to the 3rd or 4th string (after half the full "list" for example) and use the onTapGesture, then the ScrollView goes crazy and goes up for a reason I don't understand (just at the moment I tap the element).

Do I miss something or is this just a bug ?

Thank you for your help :)

Regards Jim

Answered by tootzoe in 752616022

It seems that zstack still has some issues when recalculating multiple layers with lots of views inside it. I consider that overly a floating view on zstack, this view was not calculated by zstack, I copied the code of floating view from here, and the following is the code modified from your version:


import SwiftUI

struct TMainWnd: View {
    @State    var strings: [String] = ["A", "B", "C", "D", "E"]
    @State    var ints: [Int] = Array(1...300)
    @State var selectedInt: Int?
    @State var isShow = false
    var body: some View {
        ZStack {
            
            ScrollView {
                VStack {
                    ForEach(strings, id: \.self) { string in
                        VStack {
                            HStack {
                                Text(string)
                                Spacer()
                            }
                            LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())]) {
                                ForEach(ints, id: \.self) { int in
                                    VStack {
                                        Text(String(int))
                                        
                                        Spacer()
                                    }
                                    .frame(  maxWidth: .infinity )
                                    .frame(height: 200)
                                    .background(
                                        RoundedRectangle(cornerRadius: 5)
                                            .fill(int % 2 == 0 ? .orange : .green)
                                    )
                                    .onTapGesture {
                                        self.selectedInt = int
                                    }
                                }
                            }
                        }
                    }
                }
                .padding()
            }
            
        }.floatingView(above: TFloatingWnd(selectedInt: $selectedInt))
    }
}


struct TFloatingWnd : View
{
    @Binding var selectedInt : Int?
    
    var body: some View {
        
        VStack {
            Spacer()
            
            if let selectedInt {
                Text(  String(selectedInt ) )
            }
            
            Spacer()
            
            if let selectedInt {
                Button {
                    self.selectedInt = nil
                } label: {
                    Image(systemName: "x.circle")
                        .resizable()
                        .scaledToFit()
                        .frame(width: 30)
                }
            }
            
        }.frame( maxWidth: .infinity, maxHeight: .infinity)
    }
}

extension View {
    func floatingView<Content: View>(above: Content) -> ModifiedContent<Self, Above<Content>> {
        self.modifier(Above(aboveContent: above))
    }
}
struct Above<AboveContent: View>: ViewModifier {
    let aboveContent: AboveContent
    func body(content: Content) -> some View {
        content.overlay(
            GeometryReader { proxy in
                Rectangle().fill(Color.clear).overlay(
                    self.aboveContent,
                    alignment: .center
                )
            },
            alignment: .center
        )
    }
}

maybe it is no help to you, but there's no funny behavior here (iPhone 14 Pro simulator on M1, Xcode 14.3 14E222b) ). I just copy/pasted your code and changed its name to ContentView.

Hi ssmith_c, thank you for your reply.

Unfortunately I confirm the same issue with iPhone 14 Pro simulator, see the video I just uploaded here : https://streamable.com/9en3va (will expire in 24 hours). As you will see, the first tap on #231 is working perfectly fine, then I scroll a little bit further, the second tap on #235 makes the scrollview returns (up) to display #1, 2, 3 ...

Really strange, I don't get why :(

Edit: Just to add that sometimes it even happens at the 1st tap, sometimes at the 3rd. It is nearly random. The only thing I noted is I need to scroll down "a lot" to make it happen, I don't have that issue on the first rows.

Regards Jim

ah. thanks for the video, I wasn't scrolling far enough but I couldn't tell where I was. I modified your code so it is easier to see what cell you're at. I couldn't reproduce it with an array of only 30, or even 100 Ints. But with 150 (as below), I can press on C14 and the scroll position will jump so that I'm looking at E14. Your code doesn't modify the scroll position, so this looks like a bug to me.

struct ContentView: View {
    let strings: [String] = ["A", "B", "C", "D", "E"]
    let ints: [Int] = Array(1...150)
    @State var selectedCell: String?
    var body: some View {
        ZStack {
            ScrollView {
                VStack {
                    ForEach(strings, id: \.self) { string in
                        VStack {
                            HStack {
                                Text(string)
                                Spacer()
                            }
                            LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())]) {
                                ForEach(ints, id: \.self) { int in
                                    VStack {
                                        Text(string + String(int))
                                        
                                        Spacer()
                                    }
                                    .frame(minWidth: 0, maxWidth: .infinity)
                                    .frame(height: 200)
                                    .background(
                                        RoundedRectangle(cornerRadius: 5)
                                            .fill(int % 2 == 0 ? .orange : .green)
                                    )
                                    .onTapGesture {
                                        self.selectedCell = string + String(int)
                                    }
                                }
                            }
                        }
                    }
                }
                .padding()
            }
            
            if let selectedCell {
                VStack {
                    Spacer()
                    Text(selectedCell)
                    Spacer()
                    
                    Button {
                        self.selectedCell = nil
                    } label: {
                        Image(systemName: "x.circle")
                            .resizable()
                            .scaledToFit()
                            .frame(width: 30)
                    }
                }
                .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity)
            }
        }
    }
}

the good news is that it seems you can work around it by making the second VStack persist, regardless of whether you are displaying a selection or not. Modify the second VStack like this (remove the if let selectedCell condition)

VStack {
                Spacer()
                Text(selectedCell ?? "you don't see me")
                    .foregroundColor(selectedCell == nil ? .clear : .black)
                Spacer()
                
                Button {
                    self.selectedCell = nil
                } label: {
                    Image(systemName: "x.circle")
                        .resizable()
                        .scaledToFit()
                        .frame(width: 30)
                }
            }

Thanks again ssmith_c, unfortunately it does not do the trick :( : https://streamable.com/i0dgx1 (I copied/pasted your code).

Accepted Answer

It seems that zstack still has some issues when recalculating multiple layers with lots of views inside it. I consider that overly a floating view on zstack, this view was not calculated by zstack, I copied the code of floating view from here, and the following is the code modified from your version:


import SwiftUI

struct TMainWnd: View {
    @State    var strings: [String] = ["A", "B", "C", "D", "E"]
    @State    var ints: [Int] = Array(1...300)
    @State var selectedInt: Int?
    @State var isShow = false
    var body: some View {
        ZStack {
            
            ScrollView {
                VStack {
                    ForEach(strings, id: \.self) { string in
                        VStack {
                            HStack {
                                Text(string)
                                Spacer()
                            }
                            LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())]) {
                                ForEach(ints, id: \.self) { int in
                                    VStack {
                                        Text(String(int))
                                        
                                        Spacer()
                                    }
                                    .frame(  maxWidth: .infinity )
                                    .frame(height: 200)
                                    .background(
                                        RoundedRectangle(cornerRadius: 5)
                                            .fill(int % 2 == 0 ? .orange : .green)
                                    )
                                    .onTapGesture {
                                        self.selectedInt = int
                                    }
                                }
                            }
                        }
                    }
                }
                .padding()
            }
            
        }.floatingView(above: TFloatingWnd(selectedInt: $selectedInt))
    }
}


struct TFloatingWnd : View
{
    @Binding var selectedInt : Int?
    
    var body: some View {
        
        VStack {
            Spacer()
            
            if let selectedInt {
                Text(  String(selectedInt ) )
            }
            
            Spacer()
            
            if let selectedInt {
                Button {
                    self.selectedInt = nil
                } label: {
                    Image(systemName: "x.circle")
                        .resizable()
                        .scaledToFit()
                        .frame(width: 30)
                }
            }
            
        }.frame( maxWidth: .infinity, maxHeight: .infinity)
    }
}

extension View {
    func floatingView<Content: View>(above: Content) -> ModifiedContent<Self, Above<Content>> {
        self.modifier(Above(aboveContent: above))
    }
}
struct Above<AboveContent: View>: ViewModifier {
    let aboveContent: AboveContent
    func body(content: Content) -> some View {
        content.overlay(
            GeometryReader { proxy in
                Rectangle().fill(Color.clear).overlay(
                    self.aboveContent,
                    alignment: .center
                )
            },
            alignment: .center
        )
    }
}

Hi tootzoe,

Thank you, it does the trick ... I'm still a little bit mad about that bug haha but finally I can move on :D Thanks for the solution !

Regards Jim

SwiftUI - ScrollView with LazyVGrid and onTap, scrolling randomly by itself
 
 
Q