ForEach updates view unnecessarily

Viewed 91

This is my code:

struct TestPageView: View {
    @State var blocks: [String] = ["1", "2", "3"]
    
    var body: some View {
        VStack {
            ForEach(blocks, id: \.self) { block in
                TestView(block, $blocks)
            }
        }
    }
}

struct TestView: View {
    @Binding var blocks: [String]
    let block: String
    
    init(_ block: String, _ blocks: Binding<[String]>){
        print("Created empty view")
        self.block = block
        self._blocks = blocks
    }
    var body: some View {
        TextField("Test view", text: .constant(block), onCommit: {
            print("Committed")
            blocks.append("new element")
        })
    }
}

Basic idea is that my ForEach loops over my blocks, showing them. When a user performs an action while a block is focused (onCommit here), I need a new block to be added to the list. This code actually does that correctly; the problem is with performance. With the above print statements, the following is the output:

Created empty view
Created empty view
Created empty view
Committed
Created empty view
Created empty view
Created empty view
Created empty view

so it seems the ForEach re-renders all of the items in the list. In this simple example, it's not an issue, but in my real problem, recreating all the TestViews is a large task, and completely unnecessary: I just need the new element to be added, everything else can be left the same. Is there a good way to do this?

Note: The \.self is not the issue. I've tried this with making the "blocks" conform to Identifiable. The ForEach still fails to recognize the same ids and still creates the views again.

1 Answers

There's a lot of black-box behavior that happens with SwiftUI, without enough documentation unfortunately.

At a high level, SwiftUI tries to see which view's bodies need to be invalidated and recomputed. Body re-rendering is a fairly expensive operation, so SwiftUI tries to only do this when needed.

That being said, init should be very inexpensive! Views get initialized all the time as SwiftUI builds and diffs the view tree.

What you should be looking at is the number of times a body is computed, not the number of times an init is called.

And it so happens, that TestView's body is getting recomputed every time. Why? The reason for that is almost certainly due to @Binding var blocks.

When any @State or @Binding or @ObservedObject is changed, SwiftUI recomputes the body. So, having @Binding var blocks, tells SwiftUI that TestView depends on that state, and when blocks array gets updated, it invalidates all the TestViews within ForEach.

What you can do instead is pass an onCommit closure to TestView and add a new element to blocks by the parent TestPageView, which is also the owner of blocks:

struct TestView: View {
   var block: String
   var onCommit: ((String) -> Void)? = nil
    
   var body: some View {
      TextField("Test view", text: .constant(self.block), onCommit: {
         self.onCommit?(self.block)
      })
   }
}
// in TestPageView
ForEach(blocks, id: \.self) { block in
   TestView(block: block) { txt in
      self.blocks.append(txt)
   }
}

Unfortunately, this (surprisingly to me) also doesn't solve the situation, since for some reason SwiftUI determines that having a closure property means that the view should be recomputed.

Fortunately, there's a way to finally convince SwiftUI that we know that the view actually doesn't need to be recomputed with conformance to Equatable, where we could tell SwiftUI exactly how to compare view changes.

extension TestView: Equatable {
   static func == (lhs: TestView, rhs: TestView) -> Bool {
      lhs.block == rhs.block
   }
}

And use an .equatable modifier when you use the view:

TestView(block: block) { txt in
   self.blocks.append(txt)
}.equatable()

FYI, using .constant(value) binding doesn't make much sense, except for preview. I left it here, since you used it in your example.

Related