Swift UI LazyVStack with nested VStack

651 Views Asked by At

I'm experiencing this issue and not user if it's a swift ui bug or expected behaviour. Could not find something related to this online.

Given this view

struct ContentView: View {
  var body: some View {
    ScrollView(.vertical) {
      LazyVStack {
        // has has many items so you can scroll
        ForEach((0...40), id: \.self) { index in
          MyView()  // (1)
          VStack {
            MyView()  // (2)
          }
        }
      }
    }
  }
}

Where MyView is a simple view

struct MyView: View {
    var body: some View {
        Color.random // see https://stackoverflow.com/a/43365841/901249
    }
}

When I scroll down (so the MyView (2) is off screen) and then back up, MyView (2) has a different color, while MyView (1) remains unchanged the same.

If I add let _ = Self._printChanges() in MyView body, MyView (2) will print

MyView: @self changed

This happens no matter what kind of stack it is (HStack, ZStack, VStack) as long as it's in a LazyXStack it has this behaviour.

Why does a nested XStack inside a LazyYStack recreates it's content?

2

There are 2 best solutions below

3
On

It's certainly an interesting case. I tried out a number of variations in an attempt to get a better understanding. Here are some observations:

  • If a VStack is used instead of LazyVStack then the colors are preserved. This is perhaps no big surprise because you would expect a VStack to load the nested content once and then hold it in memory.

  • If you change the implementation of MyView to this:

struct MyView: View {
    let color = Color.random
    var body: some View {
        color
    }
}

...then you find that the colors are preserved. So it is not the view instances themselves which are being discarded, but the views returned by their body property.

  • To confirm the last point, you can add an initializor to MyView and print the index (as was suggested in one of the comments to the question):
init(_ index: Int, _ item: Int) {
    print("creating \(index)-\(item)")
}

This shows that the views are only created once.

  • I added some buttons to make scrolling quicker:
ScrollViewReader { proxy in
    ScrollView(.vertical) {
        LazyVStack {
            Button("Scroll to bottom") {
                withAnimation(.easeInOut(duration: 10)) { proxy.scrollTo(-2) }
            }
            .id(-1)
            .buttonStyle(.borderedProminent)

            // has has many items so you can scroll
            ForEach((0...40), id: \.self) { index in
                MyView(index, 1)  // (1)
                VStack {
                    MyView(index, 2)  // (2)
                }
            }
            Button("Scroll to top") {
                withAnimation(.easeInOut(duration: 10)) { proxy.scrollTo(-1) }
            }
            .id(-2)
            .buttonStyle(.borderedProminent)
        }
    }
}

Then I tried steadily doubling the number of rows, trying to see if there was a point at which the views were being re-created (meaning, they were no longer being cached). I thought that this might cause the colors of view 1 to change, sooner or later. If you scroll up and down many times then what you find is that it steadily fills the gaps of the rows that were not created the first time, but it is difficult to tell if any rows are actually being re-created. My guess is that the cache is optimized, so anything that was recently shown is not likely to be discarded. I certainly wasn't able to see any changes in the rows I was monitoring.

Conclusions from all of this

It is not surprising to find that the content of the LazyVStack is being discarded when it is out of view. What is perhaps more surprising, is that all cases of view 1 preserve their color. But the experiments above have shown that in fact it is only the body of the nested views (view 2) that are being discarded, probably as a memory optimization. If the random color is captured in init instead of being chosen in body then these views do not change color either.

0
On

Absolutely sure this is expected behaviour.

LazyVStack just know how much blocks inside of it do you have and which size of each block. (to know size of LazyVStack). But LazyVStack does not render views inside of it before user will see it.

That's why it called LAZY.


So when LazyVStack think that user must to see some view soon - it will render it. On each time when it will render block of

struct MyView: View {
    var body: some View {
        Color.random // see https://stackoverflow.com/a/43365841/901249
    }
}

this block will generate the new one color. On EACH RE-RENDER!

In the same time each block that user must not see soon is removed from memory. So you loose pre-rendered information and it will be re-generated on another display of view.

So if you want to have static colors set inside of LazyVStack - you need to create array of random colors.

Or in another case you need to use VStack instead of LazyVStack . It's renders ALL children views, that's why all colors will be static due to scroll process on any distance.

But you need to understand that colors can be the new one on each View refresh in case of change view's id. This means that even VStack will not guarantee you that this set of colors will be the same after view refresh.

That's why you need to use pre-initiated array of random colors for this in any case - will you use VStack or will you use LazyVStack.