George
George

Reputation: 30461

LazyVStack - row onAppear is called early

I have a LazyVStack, with lots of rows. Code:

struct ContentView: View {
    var body: some View {
        ScrollView {
            LazyVStack {
                ForEach(0 ..< 100) { i in
                    Text("Item: \(i + 1)")
                        .onAppear {
                            print("Appeared:", i + 1)
                        }
                }
            }
        }
    }
}

Only about 40 rows are visible on the screen initially, yet onAppear is triggered for 77 rows. Why is this, why is it called before it is actually visible on the screen? I don't see why SwiftUI would have to 'preload' them.

Is there a way to fix this, or if this is intended, how can I accurately know the last visible item (accepting varying row heights)?

Edit

The documentation for LazyVStack states:

The stack is “lazy,” in that the stack view doesn’t create items until it needs to render them onscreen.

So this must be a bug then, I presume?

Upvotes: 3

Views: 3800

Answers (5)

Željko Nikolić
Željko Nikolić

Reputation: 89

For some strange reason, ScrollView + LazyVStack + onAppear only works as you expect on a real device. Try a device instead of a simulator.

Upvotes: 1

llulek
llulek

Reputation: 1

In my case the problem was I put my LazyVStack in ScrollView, which in turn was in another ScrollView (easy to lose track of it). Once that was sorted out everything started to work.

Upvotes: 0

Luchi Parejo Alcazar
Luchi Parejo Alcazar

Reputation: 151

It seems incredible but just adding a GeometryReader containing your ScrollView would resolve the issue

GeometryReader { _ in
        ScrollView(.vertical, showsIndicators: false) {
            LazyVStack(spacing: 14) {
                Text("Items")
                LazyVStack(spacing: 16) {
                    ForEach(viewModel.data, id: \.id) { data in
                        MediaRowView(data: data)
                        .onAppear {
                            print(data.title, "item appeared")
                        }
                    }
                    if viewModel.state == .loading {
                        ProgressView()
                    }
                }
            }
            .padding(.horizontal, 16)
        }
    }

Upvotes: 2

valeCocoa
valeCocoa

Reputation: 344

enter image description here

It works as per my comments above.

struct ContentView: View {
    var body: some View {
        ScrollView {
            LazyVStack {
                ForEach(0 ..< 100) { i in
                    Text("Item: \(i + 1)")
                        .id(i)
                        .frame(width: 100, height: 100)
                        .padding()
                        .onAppear { print("Appeared:", i + 1) }
                }
            }
        }
    }
}

Upvotes: 0

George
George

Reputation: 30461

By words from the documentation, onAppear shouldn't be like this:

The stack is “lazy,” in that the stack view doesn’t create items until it needs to render them onscreen.

However, if you are having problems getting this to work properly, see my solution below.


Although I am unsure why the rows onAppears are triggered early, I have created a workaround solution. This reads the geometry of the scroll view bounds and the individual view to track, compares them, and sets whether it is visible or not.

In this example, the isVisible property changes when the top edge of the last item is visible in the scroll view's bounds. This may not be when it is visible on screen, due to safe area, but you can change this to your needs.

Code:

struct ContentView: View {
    @State private var isVisible = false

    var body: some View {
        GeometryReader { geo in
            ScrollView {
                LazyVStack {
                    ForEach(0 ..< 100) { i in
                        Text("Item: \(i + 1)")
                            .background(tracker(index: i))
                    }
                }
            }
            .onPreferenceChange(TrackerKey.self) { edge in
                let isVisible = edge < geo.frame(in: .global).maxY

                if isVisible != self.isVisible {
                    self.isVisible = isVisible
                    print("Now visible:", isVisible ? "yes" : "no")
                }
            }
        }
    }

    @ViewBuilder private func tracker(index: Int) -> some View {
        if index == 99 {
            GeometryReader { geo in
                Color.clear.preference(
                    key: TrackerKey.self,
                    value: geo.frame(in: .global).minY
                )
            }
        }
    }
}
struct TrackerKey: PreferenceKey {
    static let defaultValue: CGFloat = .greatestFiniteMagnitude

    static func reduce(value: inout Value, nextValue: () -> Value) {
        value = nextValue()
    }
}

Upvotes: 1

Related Questions