Search code examples
swiftswiftuilazyvstack

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?


Solution

  • 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()
        }
    }