Index out of range when binding a Slider value to

2020-03-31 06:55发布

问题:

Description:

I have a model that has the following hierarchy:

  • Recipe
  • ...steps (an array)
  • ...currentStep
  • ......parameters (an array)
  • .........minimum
  • .........maximum
  • .........default
  • .........current

The model works well. I can add steps, parameters, and set the current step to an @EnvironmentObject called recipe.

I've created a sample project here with I two lists of steps and parameters, along with three buttons to add a single step among three hard-coded ones, each containing an array of 0, 1, or 3 parameters.

The top list is the step rows, each being a button to populate the bottom list. The bottom list is the parameter list, each containing a label and a slider in a VStack.

All works fine, except when i am (a) binding the slider to my model and (b) the list contains more sliders (row) than the current step now has. I get an index out of range error.

If I bind the slider value to a local variable, it all works. Here's the relevant code:

class Recipe: BindableObject {
    var didChange = PassthroughSubject<Void, Never>()
    var currentStep = Step() {
        didSet {
            didChange.send(())
        }
    }
}

struct Parameter: Identifiable {
    var id:Int = 0
    var name = ""
    var minimum:Float = 0
    var maximum:Float = 100
    var `default`:Float = 30
    var current:Float = 30
}

struct StepRow: View {
    @EnvironmentObject var recipe: Recipe
    var step: Step!

    init(step: Step) {
        self.step = step
    }
    var body: some View {
        Button(action: {
            self.setCurrentStep()
        }) {
            HStack {
                Text(step.name).font(Font.body.weight(.bold))
            }.frame(height: 50)
        }
    }
    func setCurrentStep() {
        recipe.currentStep = step
    }
}
struct ParameterRow: View {
    @EnvironmentObject var recipe: Recipe
    @State var sliderValue:Float = 30
    var parameter: Parameter!

    init(parameter: Parameter) {
        self.parameter = parameter
    }

    var body: some View {
        VStack {
            Text(parameter.name)
            Slider(

                // This works, swap these two lines to duplicate the index out of range error by:
                // - Adding step 1, step 2, step 3, and finally step 4
                // - Tapping each step in the step list in order, the first three will work but the last one won't

                //value: $recipe.currentStep.parameters[parameter.id].current,
                value: self.$sliderValue,

                from: parameter.minimum,
                through: parameter.maximum,
                by: 1.0
            )
        }
    }
}
struct ContentView : View {
    @EnvironmentObject var recipe: Recipe
    var body: some View {
        VStack {
            List {
                ForEach(recipe.steps) { step in
                    StepRow(step: step)
                }
            }
            List {
                ForEach(recipe.currentStep.parameters) { parameter in
                    ParameterRow(parameter: parameter)
                }
            }
        }
    }
}

Again, a working example of this is project here.

回答1:

I'm still going through your code. But I'd like to comment on something that caught my eye in your functions addStepX():

    func addStep1() {
        let newStep = Step(id: UUID(), name: "Step #1", parameters: [Parameter]())
        currentStep = newStep
        steps.insert(newStep, at: steps.count)
    }

Are you aware that the steps.insert() will not trigger a didSet, and so the didChange.send() will not execute? I propose you invert the order and first insert the step, and later you update currentStep. This way you call didChange.send() exactly once, after all your changes are done.

    func addStep1() {
        let newStep = Step(id: UUID(), name: "Step #1", parameters: [Parameter]())
        steps.insert(newStep, at: steps.count)
        currentStep = newStep
    }

Note that changing that still does not fix the problem, but I though I should mention, as it is definitely a problem.

UPDATE

After your changes, the code looks much cleaner. And I seem to have found a way of preventing the out of bounds.

It seems the problem is due to a timing issue. Your array gets updated, but the parameter passed by the List is still old. Eventually, it will catch up, but because of the out of bound crash... it never does. So why not make it conditional?

Note that I also added the slider value to the Text() view, to make it evident that the binding is successful:

struct ParameterRow: View {
    @EnvironmentObject var recipe: Recipe
    @State var sliderValue:Float = 30
    var parameter: Parameter!

    init(parameter: Parameter) {
        self.parameter = parameter
    }

    var body: some View {
        VStack {
            Text("\(parameter.name) = \(parameter.id < recipe.currentStep.parameters.count ? recipe.currentStep.parameters[parameter.id].current : -1)")
            Slider(
                value: parameter.id < recipe.currentStep.parameters.count ? $recipe.currentStep.parameters[parameter.id].current : .constant(0),
                from: parameter.minimum,
                through: parameter.maximum,
                by: 1.0
            )
        }
    }
}