Question: ForEach in combination with List not working - how to solve?

I stumbled across this strange behavior and have no clue how to solve it.

I have a Picker in which the user can chose to generate either 5, 10, or 20 random numbers. The possible numbers are later stored in an array and the selection is a State var:

let possibleAmountOfNumbers = [5, 10, 20]
@State private var amountOfNumbers = 10
@State private var arrayOfNumbers: [Int] = []

Then I have a button, which calls a function to generate random numbers. Those numbers are appended to an array of Int. I make sure that no number is duplicated and that the numbers are sorted. Every time the button is clicked, the array is emptied and then the random numbers are added. I pass in the parameter amountOfNumbers from the picker how many random numbers shall be generated.

Nothing fancy till now.

Then I want to display the numbers in a List.

Section {
                if !arrayOfNumbers.isEmpty {
                    ForEach(0..<amountOfNumbers) { index in
                        Text(String(arrayOfNumbers[index]))
                    }
                }
            }

This part of the code is not working correctly.
Xcode tells me with a yellow warning the following:
Non-constant range: argument must be an integer literal

What is happening:

  • If the default selection of the picker (which is 10) is not altered and I press the Button, 10 random numbers are displayed. If I press the button again, 10 newly generated numbers are displayed. This is, what I’m expecting.

  • If I change the picker before I pressed the button, either to 5 or 20, and then press the button, either 5 or 20 numbers are displayed. If I press the button again, either 5 or 20 newly generated numbers are displayed. This is what I’m expecting.

  • If I press the button, 10 numbers are displayed. If I change the picker to 20 and press the button again, only 10 newly generated numbers are displayed. This is NOT what I’m expecting.

  • If I press the button, 10 numbers are displayed. If I change the picker to 5 and press the button, the app crashes and I get an out of range error message in the ForEach loop.

Note:
The State variable amountOfNumbers is correctly updated. If I change the Picker from 10 to 5, I can see that the variable is indeed 5.
Still, in above cases when I change the picker after I already pressed the button, the ForEach tries to loop over the FORMER value of the picker. If the new number is smaller, I get the out of range error message. If the new number is higher, it will not loop over the whole array.

Question:
It seems that the ForEach is not the correct way to display the numbers in the List view. Unfortunately, I did not find a working solution and hope you can help me out.

The complete code is:


//
//  ContentView.swift
//  ForEachAndPicker
//
//  Created by xy on 10.01.23.
//

import SwiftUI

struct ContentView: View {
    
    // User can select how many numbers are displayed, out of 5, 10, or 20. Default: 10
    let possibleAmountOfNumbers = [5, 10, 20]
    @State private var amountOfNumbers = 10
    @State private var arrayOfNumbers: [Int] = []
    
    var body: some View {
        
        List {
            
            // Section 1: Picker to chose amount of numbers
            Section(header: Text("Chose amount of random numbers")) {
                Picker("Amount of numbers", selection: $amountOfNumbers) {
                    ForEach (possibleAmountOfNumbers, id: \.self) {
                        Text("\($0)")
                    }
                }
                .pickerStyle(.segmented)
            }
            
            // Section 2: Button to generate random numbers
            Section {
                HStack  {
                    Spacer()
                    Button("Generate Numbers") {
                        arrayOfNumbers = generateRandomNumbers(amountOfNumbers: amountOfNumbers)
                    }
                    .buttonStyle(.borderedProminent)
                    Spacer()
                }
            }
            
            // Section 3: If numbers are available, display them
            Section {
                if !arrayOfNumbers.isEmpty {
                    ForEach(0..<amountOfNumbers) { index in
                        Text(String(arrayOfNumbers[index]))
                    }
                }
            }
        }
    }
    
    
    // Generate random numbers
    func generateRandomNumbers(amountOfNumbers: Int) -> [Int] {
        // Delete array
        arrayOfNumbers = []
        
        var interimArray: [Int] = []
        while interimArray.count < amountOfNumbers {
            let randomInteger = Int.random(in: 1...100)
            if !interimArray.contains(randomInteger) {
                interimArray.append(randomInteger)
            }
        }
        arrayOfNumbers = interimArray.sorted()
        return  arrayOfNumbers
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

The problem (or, at least, part of the problem) is that the version of ForEach you are using above only reads in the range the first time and doesn’t get updated if amountOfNumbers changes.

From the docs:

The instance only reads the initial value of the provided data and doesn’t need to identify views across updates.

This is what’s causing the crash and also being stuck with 10 numbers when you are expecting more.

I can’t get to it until later today but I can work up an example fix if you want.

1 Like

Thanks for the great explanation. A potential fix would be very much appreciated :slight_smile:
(No need to rush)

I realized I didn’t need to change anything else except this part, so I was able to knock it out right now:

Replace this:

Section {
    if !arrayOfNumbers.isEmpty {
        ForEach(0..<amountOfNumbers) { index in
            Text(String(arrayOfNumbers[index]))
        }
    }
}
Section {
    ForEach(arrayOfNumbers, id: \.self) { num in
        Text(num, format: .number)
    }
}

Since you know all of your numbers will be unique, you can use the numbers themselves as the id for the ForEach and eliminate the need to loop through using however many numbers are in the array. And you no longer need to check !arrayOfNumbers.isEmpty because the ForEach just won’t run if arrayOfNumbers is empty.

Hope this helps!

2 Likes

Awesome!
Thank you so much for your perfect and quick help.