23

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 5

27

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}
     ))
   }
}
Sign up to request clarification or add additional context in comments.

3 Comments

Thanks! I've been looking for this answer! You can also do self.$items[index] instead of creating a custom Binding.
Lifesaver - works like a charm. Thanks for the great answer!
Be aware: ForEach views only work with static content when you use the index values this way. If you change your content dynamically, you may experience errors.
15

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)
}

Comments

8

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
    }
}

3 Comments

I get a Cannot invoke initializer for type 'TextField<_>' with an argument list of type '(Binding<String>, placeholder: Text, onCommit: @escaping () -> ())' error while doing this. Is that only me?
@nicoyuste the signatures may be different now... this code is from one of the earlier releases
I added a new response on the thread with the code that works now.
6

A solution could be the following:

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

Comments

1

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.

Comments

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.