1

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?

George
  • 25,988
  • 10
  • 79
  • 133
  • If it's of any help, I was unable to reproduce it with XCode 13.1 and iPhone 13 Pro Max simulator (which shows 39 rows). When the view loads there are 39 records printed, somewhat in random order, once the scrolling starts additional records are printed in order. – tromgy Nov 28 '21 at 22:26
  • @tromgy Ah I'm using Xcode 13.2.0 beta 2 :/ Is this seriously a beta issue? Arghhh I'll give it a go on 13.1 – George Nov 28 '21 at 22:28
  • @tromgy Nope - tried replicating the same exact conditions. Still prints too many initially, not sure what's different. Even on a real device it still has the same issues :/ – George Nov 28 '21 at 22:38
  • @George: If you change 100 to 1000 and after first scroll you would see that it is working just fine. – ios coder Nov 28 '21 at 22:49
  • @swiftPunk Nope, not for me. It's still called about 39 rows early - meaning it must be somehow be doubling what it thinks is the visible area – George Nov 28 '21 at 22:53
  • It should be a matter of laying out the views. Try adding a fixed frame size to each view inside the `ForEach`. – valeCocoa Nov 28 '21 at 22:59
  • @valeCocoa The row heights will be an undeterminable size. Anyways, I gave it a go but that still doesn't work unfortunately. – George Nov 28 '21 at 23:05
  • @George: It is obvious if you see the last visible Text is 200, SwiftUI would not call for 201, because it is too late, I mean the view must be ready to get used even it is not visible yet! So for example if you see last visible Text is 200, then logically SwiftUI should call for 2030 and so on ... – ios coder Nov 28 '21 at 23:08
  • @George the fact that for swiftPunk it works because he's using 1000 elements rather than just 100 is the hint: the view is able to initially layout all elements, and then resizes the layout to fill the whole space. – valeCocoa Nov 28 '21 at 23:09
  • @swiftPunk SwiftUI doesn't need to preload these views, they are ephemeral. Unless there is any documentation to backup the reason for this, I don't believe this is what is happening. – George Nov 28 '21 at 23:10
  • @valeCocoa I commented that I tried 1000 items, and it still didn't work because the `onAppear`s were triggered at an offset. All of them are however many ahead of when it should be called. And I also replied to you that I tried a fixed height and it still didn't work. – George Nov 28 '21 at 23:11
  • @George try adding a fixed frame to those `Text`, and you'll understand what is happening. – valeCocoa Nov 28 '21 at 23:12
  • @valeCocoa For the third time, I did – George Nov 28 '21 at 23:12
  • @George: You can trust me, It was and it is so from begging, Lazy load would never ever risk of delay! if your screen fit 20 items visible, SwiftUI would cash 60 items in memory if you got those data, 60 means: 20 for up scroll + 20 visible + 20 for down scroll. the numbers are just example to show what is happening. – ios coder Nov 28 '21 at 23:13
  • @swiftPunk The [documentation](https://developer.apple.com/documentation/swiftui/lazyvstack) states: `The stack is “lazy,” in that the stack view doesn’t create items until it needs to render them onscreen.`, so that's not true. This seems like a much better source than "you can trust me" – George Nov 28 '21 at 23:17
  • @George: You want depend on some line in website or the actual test that you can do with Lazy load method? try it for yourself. See the things I am saying is true or that link. – ios coder Nov 28 '21 at 23:20
  • @swiftPunk Come on... this clearly isn't what the documented behaviour states, plus it doesn't make _sense_ to preload this because of how SwiftUI works. I have to believe for now this is a bug since this isn't the documented behaviour – George Nov 28 '21 at 23:22
  • @George it works as expected. – valeCocoa Nov 28 '21 at 23:29
  • @George the `ScrollView` here is the one doing the layout at first. – valeCocoa Nov 28 '21 at 23:32

4 Answers4

1

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)
        }
    }
0

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()
    }
}
George
  • 25,988
  • 10
  • 79
  • 133
0

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) }
                }
            }
        }
    }
}
valeCocoa
  • 344
  • 1
  • 8
  • What version of Xcode are you using? I've tried 13.1 and 13.2 beta 2 – George Nov 28 '21 at 23:31
  • Xcode 13.1. I don't think it's a matter of XCode version. – valeCocoa Nov 28 '21 at 23:35
  • It still isn't working for me even with the exact code you have so idk why it's not working on my machine. I asked about Xcode versions because SwiftUI behaviour often changes between versions – George Nov 28 '21 at 23:39
  • @George I've added the code to the answer, I don't see how this doesn't work the same way on your machine. – valeCocoa Nov 28 '21 at 23:49
  • in my experience, lazy loading works differently on simulator vs. hardware. try using a physical device and I bet it works. – Alex Hartford Jan 31 '22 at 20:17
0

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.

llulek
  • 1