tenuki
tenuki

Reputation: 370

How can I prevent lazy collections from excessively re-evaluating conditional child views?

Given a LazyHStack inside a ScrollView, child views are generally re-evaluated only as they come on screen, or if their underlying state changes. However, if the child views contain a conditional statement, this behavior breaks down. When the underlying state changes, all views that have been displayed so far are re-evaluated, even those that are well off screen. Why exactly does this happen, and are there any good workarounds to prevent this?

I have included some basic example code below, to be run in a Playground. To see the issue I'm talking about, do the following:

  1. Uncomment the lines below "This will cause unnecessary re-evaluation" and run the code
  2. Scroll a good way through the collection, then press the "Mutate collection" button. Note that "Evaluating (n)" will be printed for every single child item that has been displayed, even those that aren't visible
  3. Comment out the code from step (1), and uncomment the simple Text view under "This is fine". Run the code again.
  4. Repeat step (2), noting that "Evaluating (n)" is only printed for the child items in the immediate vicinity of those displayed on screen (the desired behavior)
struct ChildView: View {
    let s: String
    var body: some View {

        // This will cause unnecessary re-evaluation
        // if #available(iOS 16, *) {
        //     Text(s)
        //         .draggable("test")
        // } else {
        //     Text(s)
        // }

        // This is fine
        // Text(s)
    }
}

struct ParentView: View {
    @State private var items = Array(0...100)
    var body: some View {
        ScrollView(.horizontal) {
            LazyHStack {
                ForEach(items, id: \.self) { n in
                    let _ = print("Evaluating \(n)")
                    ChildView(s: "Num \(n)")
                }
            }
        }
        Button("Mutate collection") {
            items[0] = Int.random(in: 0...100)
        }
        .frame(width: 200, height: 500)
    }
}

PlaygroundPage.current.setLiveView(ParentView())

Upvotes: 1

Views: 111

Answers (2)

malhal
malhal

Reputation: 30575

Make the number of views per row constant, explained here

https://developer.apple.com/wwdc23/10160?time=1087

The basic equation to think about is that the row count resulting from a ForEach in a List is equal to the number of elements multiplied by the number of views produced for each element. You need to ensure the number of views per element is a constant, or SwiftUI has to build the views in addition to the identifiers in order to identify the rows.

So I suppose the runtime if makes it think it’s not constant.

Upvotes: 1

Radioactive
Radioactive

Reputation: 698

I don't know why it happens, but the solution that I found was encapsulating the mutating view in a view that which it won't affect it:

struct ChildView: View {
    let s: String
    
    var body: some View {
        ZStack {
            if #available(iOS 16, *) {
                Text(s)
                    .draggable("test")
            } else {
                Text(s)
            }
        }
    }
}

Upvotes: 1

Related Questions