Using ForEach with a an array of Bindings (SwiftUI)

Viewed 9293

My objective is to dynamically generate a form from JSON. I've got everything put together except for generating the FormField views (TextField based) with bindings to a dynamically generated list of view models.

If I swap out the FormField views for just normal Text views it works fine (see screenshot):

ForEach(viewModel.viewModels) { vm in
    Text(vm.placeholder)
}

for

ForEach(viewModel.viewModels) { vm in
     FormField(viewModel: $vm)
}

I've tried to make the viewModels property of ConfigurableFormViewModel an @State var, but it loses its codability. JSON > Binding<[FormFieldViewModel] naturally doesn't really work.

Here's the gist of my code:

Screenshot of form from JSON but using <code>Text</code>

5 Answers

The first thing that you can try is this:

ForEach(0 ..< numberOfItems) { index in
   HStack {
     TextField("PlaceHolder", text: Binding(
       get: { return items[index] },
       set: { (newValue) in return self.items[index] = newValue}
     ))
   }
}

The problem with the previous approach is that if numberOfItems is some how dynamic and could change because of an action of a Button for example, it is not going to work and it is going to throw the following error: ForEach<Range<Int>, Int, HStack<TextField<Text>>> count (3) != its initial count (0). 'ForEach(_:content:)' should only be used for *constant* data. Instead conform data to 'Identifiable' or use 'ForEach(_:id:content:)' and provide an explicit 'id'!

If you have that use case, you can do something like this, it will work even if the items are increasing or decreasing during the lifecycle of the SwiftView:

ForEach(items.indices, id:\.self ){ index in
   HStack {
     TextField("PlaceHolder", text: Binding(
       get: { return items[index] },
       set: { (newValue) in return self.items[index] = newValue}
     ))
   }
}

Trying a different approach. The FormField maintains it's own internal state and publishes (via completion) when its text is committed:

struct FormField : View {
    @State private var output: String = ""
    let viewModel: FormFieldViewModel
    var didUpdateText: (String) -> ()

    var body: some View {
        VStack {
            TextField($output, placeholder: Text(viewModel.placeholder), onCommit: {
                self.didUpdateText(self.output)
            })

            Line(color: Color.lightGray)
        }.padding()
    }
}
ForEach(viewModel.viewModels) { vm in
    FormField(viewModel: vm) { (output) in
        vm.output = output
    }
}

Swift 5.5

From Swift 5.5 version, you can use binding array directly by passing in the bindable like this.

ForEach($viewModel.viewModels, id: \.self) { $vm in
     FormField(viewModel: $vm)
}

A solution could be the following:

ForEach(viewModel.viewModels.indices, id: \.self) { idx in
     FormField(viewModel:  self.$viewModel.viewModels[idx])
}

Took some time to figure out a solution to this puzzle. IMHO, it's a major omission, especially with SwiftUI Apps proposing documents that has models in struct and using Binding to detect changes.

It's not cute, and it takes a lot of CPU time, so I would not use this for large arrays, but this actually has the intended result, and, unless someone points out an error, it follows the intent of the ForEach limitation, which is to only reuse if the Identifiable element is identical.

ForEach(viewModel.viewModels) { vm in
    ViewBuilder.buildBlock(viewModel.viewModels.firstIndex(of: zone) == nil
        ? ViewBuilder.buildEither(first: Spacer())
        : ViewBuilder.buildEither(second: FormField(viewModel: $viewModel.viewModels[viewModel.viewModels.firstIndex(of: vm)!])))
}

For reference, the ViewBuilder.buildBlock idiom can be done in the root of the body element, but if you prefer, you can put this with an if.

Related