Why is the Range of SwiftUI's ForEach loop not updating?

Viewed 1425

I am trying to display a list of items in a subview (ListView) inside ContainerView whenever the value of the array of those items is not nil. Every time the array is updated a new ListView should be displayed replacing the previous or be removed if the array is nil. The issue is that ListView seems to be displayed, removed and updated correctly except for the ForEach loop. It uses the same range every time eventhough a new ListView seems to be displayed.

I've created a simple example to demonstrate the issue. This is the ContainerView:

struct ContainerView: View {

    @State private var items: [Int]?
    private var listView: some View {
        items.map { ListView(items: $0) }
    }

    var body: some View {
        VStack {
            Button("Display new List") { self.items = self.randomItems() }
            listView
        }
    }

    private func randomItems() -> [Int] {...}
}

This is the ListView:

struct ListView: View {

    private let randomInt: Int
    private let items: [Int]

    init(randomInt: Int = Int.random(in: 100 ..< 999), items: [Int]) {
        self.randomInt = randomInt
        self.items = items
        print("init | items: \(items)")
    }

    var body: some View {
        VStack(alignment: .leading, spacing: 8) {
            Text("\(randomInt)")
            ForEach(0 ..< items.count) { index -> Text? in
                print("ForEach | index: \(index) items.count: \(self.items.count)")
                return self.items.count > index ? Text("\(self.items[index])") : nil
            }
        }
    }
}

When running this and tapping the "Display new List" button several times it will print something like this:

init | items: [0, 1, 2]
ForEach | index: 0 items.count: 3
ForEach | index: 1 items.count: 3
ForEach | index: 2 items.count: 3
init | items: [0, 1, 2, 3, 4, 5, 6, 7, 8]
ForEach | index: 0 items.count: 9
ForEach | index: 1 items.count: 9
ForEach | index: 2 items.count: 9
init | items: [0, 1, 2, 3, 4, 5]
ForEach | index: 0 items.count: 6
ForEach | index: 1 items.count: 6
ForEach | index: 2 items.count: 6

So apparently a new ListView is created and on the device you also see that the correct randomInt is displayed, but for some reason ForEach is using the same range every time no matter how long the new array is.

Why is the range not updating or how can I force it to update?

2 Answers
@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *)
extension ForEach where Data == Range<Int>, ID == Int, Content : View {

    /// Creates an instance that computes views on demand over a *constant*
    /// range.
    ///
    /// This instance only reads the initial value of `data` and so it does not
    /// need to identify views across updates.
    ///
    /// To compute views on demand over a dynamic range use
    /// `ForEach(_:id:content:)`.
    public init(_ data: Range<Int>, @ViewBuilder content: @escaping (Int) -> Content)
}

To compute views on demand over a dynamic range use ForEach(_:id:content:)

The SwiftUI backend optimises views rendering and does not recreate view if thinks that it is equal, so it needs to inform that view differs.

The following fixes described issue. (Tested with Xcode 11.4beta3 / iOS 13.3)

private var listView: some View {
    items.map { ListView1(items: $0).id($0.count) }
}
Related