Open ilubnon opened 4 years ago
Hi @ilubnon,
Thanks for the great question!
Some time ago we were also discussing this feature in our team. The concrete example might be that you have an address form group with multiple address fields like street, city, etc. and you would want to add another address on a button click. We were considering to define this behavior directly in the XML/JSON, but there were some challenges like:
Although it is not impossible to solve, we decided to take a simple path for now: download the new form from the server and let the server add those fields.
That being said, if you don't need to have this behavior described in the XML/JSON, I think there is a possible solution to this problem on the client-side. However, it will not be implemented in the renderer as you are suggesting but rather in the model layer.
Specifically, there is a FormManager
class which has a form
property. This property is the root of the component tree, that is being rendered in the renderer classes. On your button click instead of rendering something, you would need to take a component in this tree, make a copy and add it back to the tree and only notify the view to re-render itself.
To make it simple I would start by making a special component, for example CopyContainer
that would have list of children on its model and the copy button in the renderer. This copy button would take the element
property of type CopyContainer
, take the first child, make a copy and add it to the end of the list. You would then need to notify CopyContainer to re-render its children (for example by introducing ChildrenCount property that would be manually incremented on the copy and can be also subscribed in the renderer). There is a clone method on the form element which will help you to get the deep copy of the tree, but I think you would also need to change the id property of each element in the copy to avoid conflicts.
Because I find this problem very interesting I may prepare some example of what I just described in the following weeks.
@OndrejKunc Thank you very much!
I took the liberty of creating some tests, as you mentioned. However, I just copied the ReactiveFormGroupRenderer by changing the class name and consequently the model and also the parser.
Something like that:
import 'package:dynamic_forms/dynamic_forms.dart';
import 'package:flutter_dynamic_forms_components/flutter_dynamic_forms_components.dart';
class Group extends Container {
static const String namePropertyName = 'name';
Property<String> get nameProperty => properties[namePropertyName];
set nameProperty(Property<String> value) =>
registerProperty(namePropertyName, value);
String get name => nameProperty.value;
Stream<String> get nameChanged => nameProperty.valueChanged;
@override
FormElement getInstance() {
return Group();
}
}
import 'package:flutter_dynamic_forms_components/flutter_dynamic_forms_components.dart';
import 'package:dynamic_forms/dynamic_forms.dart';
import 'package:tovtec_dynamic_forms/models/groupModel.dart' as model;
class GroupParser<TGroup extends model.Group>
extends ContainerParser<TGroup> {
@override
String get name => 'group';
@override
FormElement getInstance() => model.Group();
@override
void fillProperties(
TGroup formGroup,
ParserNode parserNode,
Element parent,
ElementParserFunction parser,
) {
super.fillProperties(formGroup, parserNode, parent, parser);
formGroup
..nameProperty = parserNode.getStringProperty(
'name',
defaultValue: ParserNode.defaultString,
isImmutable: true,
);
}
}
import 'package:dynamic_forms/dynamic_forms.dart';
import 'package:expression_language/expression_language.dart';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_dynamic_forms/flutter_dynamic_forms.dart';
import 'package:tovtec_dynamic_forms/models/groupModel.dart' as model;
class ReactiveGroupRenderer extends FormElementRenderer<model.Group> {
@override
Widget render(
model.Group element,
BuildContext context,
FormElementEventDispatcherFunction dispatcher,
FormElementRendererFunction renderer) {
return StreamBuilder<List<ExpressionProviderElement>>(
initialData: element.children,
stream: element.childrenChanged,
builder: (context, snapshot) {
List<Widget> childrenWidgets = [
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
element.name,
style: TextStyle(color: Colors.grey),
),
)
];
childrenWidgets.addAll(
snapshot.data.whereType<FormElement>().where((f) => f.isVisible).map(
(child) => renderer(child, context),
),
);
childrenWidgets.add(new IconButton(
icon: const Icon(Icons.add_circle_outline),
onPressed: () {
// Widget elementCopy = renderer(element, context);
// childrenWidgets.add(elementCopy); ???
}));
return Column(children: childrenWidgets);
},
);
}
}
So I gave it a try and was able to find a solution.
The working solution is for now in my branch ok/copy-container
.
I implemented a component called CopyContainer
which is very similar to your Group
component.
Here is what you need to do: 1) Add new behavior subject to your model, so you have an easy way to inform a view that new child was added:
BehaviorSubject<int> changedSubject = BehaviorSubject<int>.seeded(0);
Stream<int> get changedStream => changedSubject.stream;
2) Create a new event which will allow you to pass your component through the dispatcher:
class CopyFirstChildEvent extends FormElementEvent {
final CopyContainer copyContainer; //Pass your Group component instead
CopyFirstChildEvent(this.copyContainer);
}
3)
In your Renderer wrap the outer StreamBuilder in another StreamBuilder listening to the changedStream
you just added.
Also, dispatch the CopyFirstChildEvent
in your onPressed
delegate. This is my renderer class but yours should be very similar.
class CopyContainerRenderer extends FormElementRenderer<CopyContainer> {
@override
Widget render(
CopyContainer element,
BuildContext context,
FormElementEventDispatcherFunction dispatcher,
FormElementRendererFunction renderer) {
return StreamBuilder<int>(
initialData: 0,
stream: element.changedStream,
builder: (context, itemCount) {
return StreamBuilder<List<ExpressionProviderElement>>(
initialData: element.children,
stream: element.childrenChanged,
builder: (context, snapshot) {
return Column(
children: [
...snapshot.data
.whereType<FormElement>()
.where((f) => f.isVisible)
.map(
(child) => renderer(child, context),
)
.toList(),
IconButton(
icon: const Icon(Icons.add_circle_outline),
onPressed: () {
dispatcher(CopyFirstChildEvent(element));
},
)
],
);
},
);
},
);
}
}
4)
Finally, process your event in the same place you are processing ChangeValueEvent
. You should also have your FormManager instance available in this place:
void _onFormElementEvent(FormElementEvent event) {
if (event is ChangeValueEvent) {
_formManager.changeValue(
value: event.value,
elementId: event.elementId,
propertyName: event.propertyName,
ignoreLastChange: event.ignoreLastChange);
}
if (event is CopyFirstChildEvent) {
var children = event.copyContainer.children;
if (children.isEmpty) {
return;
}
// Create copy of the first children
var clonedRoot = children[0].clone(null);
var clonedElements =
getFormElementIterator<FormElement>(clonedRoot).toList();
// Change id of each element in the cloned subtree
for (var i = 0; i < clonedElements.length; i++) {
var clonedElement = clonedElements[i];
if (clonedElement.id == null) {
continue;
}
clonedElement.id = "${clonedElement.id}_$i";
_formManager.formElementMap[clonedElement.id] = clonedElement;
}
// Build expressions in the cloned subtree
var clonedExpressions =
getFormPropertyIterator<CloneableExpressionProperty>(clonedRoot);
for (var expressionValue in clonedExpressions) {
expressionValue.buildExpression(_formManager.formElementMap);
}
// Add subscriptions to existing expressions
for (var expressionValue in clonedExpressions) {
var elementsValuesCollectorVisitor =
ExpressionProviderCollectorVisitor();
expressionValue.getExpression().accept(elementsValuesCollectorVisitor);
for (var sourceProperty
in elementsValuesCollectorVisitor.expressionProviders) {
(sourceProperty as Property).addSubscriber(expressionValue);
}
}
(clonedRoot as FormElement).parentProperty = children[0].parentProperty;
// Add back to the children list
children.add(clonedRoot);
// Notify view about the change
event.copyContainer.changedSubject.add(children.length);
}
}
And that's it.
I admit the code processing the CopyFirstChildEvent
is not so easy to understand and probably should be moved to the library.
Also, this solution is not so ideal, because you can't use expressions referencing the items in the block you are copying - we would need to extend expression language for this use case. However, you can reference any expressions outside this copy component.
I should also add an API which allows user to emit new item in the default PropertyChanged stream so you don't have to define your own Stream for this.
Please let me know if this solved your problem or if you need any further help.
This is pretty interesting, is there any chance this gets rebased and put into master? I'm looking to create a number of form groups based on a select field and this looks like it would be a good base.
Hello @OndrejKunc, congratulations on the project!
Is it possible to duplicate (creating a new formGroup with default values) a formGroup dynamically?
In the ReactiveFormGroupRenderer class, I would like to do something like that, rendering with a button to dynamically add another formGroup
Something like that: