diff --git a/README.md b/README.md index 8ebc164b4a..6fa1aa25f3 100644 --- a/README.md +++ b/README.md @@ -319,6 +319,27 @@ FormBuilderTextField( ), ``` +#### Stream for Real-time Form Changes + +You can subscribe to the stream on [FormBuilderState.onChanged](https://pub.dev/documentation/flutter_form_builder/latest/flutter_form_builder/FormBuilderState/onChanged.html) in order to react to the changes in real-time. You can use this stream in a `StreamBuilder` widget, an example would be: + +```dart +StreamBuilder( + stream: _formKey.currentState?.onChanged, + builder: (context, AsyncSnapshot snapshot) { + if (!snapshot.hasData) { + // if there are data, render a widget + } else { + // if there are no data, render a widget + } + }, +) +``` + +You can see a further example in the example app. + +You can also use this stream with [Bloc library](https://bloclibrary.dev). + ## Support ### Contribute diff --git a/example/lib/main.dart b/example/lib/main.dart index e127b814ca..cf07e5e2d5 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -61,6 +61,7 @@ class _CompleteFormState extends State { onChanged: () { _formKey.currentState!.save(); debugPrint(_formKey.currentState!.value.toString()); + setState(() {}); }, autovalidateMode: AutovalidateMode.disabled, initialValue: const { @@ -74,6 +75,23 @@ class _CompleteFormState extends State { child: Column( children: [ const SizedBox(height: 15), + // StreamBuilder( + // stream: _formKey.currentState?.onChanged, + // builder: + // (context, AsyncSnapshot snapshot) { + // if (snapshot.hasData) { + // final data = snapshot.data; + + // return Column( + // children: data!.entries + // .map((e) => Text('${e.key}: ${e.value.value}')) + // .toList(), + // ); + // } + + // return const Text('no data'); + // }, + // ), FormBuilderDateTimePicker( name: 'date', initialEntryMode: DatePickerEntryMode.calendar, @@ -359,6 +377,96 @@ class _CompleteFormState extends State { ], onChanged: _onChanged, ), + // realtime data stream + Padding( + padding: const EdgeInsets.only(top: 8, bottom: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Realtime Data Stream', + style: Theme.of(context).textTheme.headline6, + ), + StreamBuilder( + stream: _formKey.currentState?.onChanged, + builder: (context, + AsyncSnapshot + snapshot) => + !snapshot.hasData + // if there are no data + ? const Center( + child: Text( + 'No data yet! Change some values.', + ), + ) + // if there are data + : Table( + border: TableBorder.all(), + columnWidths: const { + 0: IntrinsicColumnWidth(flex: 1), + 1: IntrinsicColumnWidth(flex: 2), + }, + children: [ + TableRow( + children: [ + Padding( + padding: + const EdgeInsets.all(8.0), + child: Text( + 'Key', + textAlign: TextAlign.right, + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith( + fontWeight: + FontWeight.bold, + ), + ), + ), + Padding( + padding: + const EdgeInsets.all(8.0), + child: Text( + 'Value', + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith( + fontWeight: + FontWeight.bold, + ), + ), + ), + ], + ), + ...snapshot.data!.entries.map( + (e) => TableRow( + children: [ + Padding( + padding: + const EdgeInsets.all(8), + child: Text( + e.key, + textAlign: TextAlign.right, + ), + ), + Padding( + padding: + const EdgeInsets.all(8), + child: Text( + e.value.value.toString(), + ), + ), + ], + ), + ), + ], + ), + ), + ], + ), + ), ], ), ), diff --git a/lib/src/form_builder.dart b/lib/src/form_builder.dart index 1bc8163953..798f58b790 100644 --- a/lib/src/form_builder.dart +++ b/lib/src/form_builder.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:developer'; import 'package:flutter/material.dart'; @@ -113,6 +114,7 @@ class FormBuilderState extends State { final _transformers = {}; final _instantValue = {}; final _savedValue = {}; + final _onChangedStreamController = StreamController(); Map get instantValue => Map.unmodifiable(_instantValue.map((key, value) => @@ -128,6 +130,9 @@ class FormBuilderState extends State { FormBuilderFields get fields => _fields; + /// A stream that informs the subscribers when the form changes. + Stream get onChanged => _onChangedStreamController.stream; + dynamic transformValue(String name, T? v) { final t = _transformers[name]; return t != null ? t.call(v) : v; @@ -149,6 +154,7 @@ class FormBuilderState extends State { setState(() {}); } widget.onChanged?.call(); + _onChangedStreamController.add(fields); } bool get isValid => @@ -162,6 +168,7 @@ class FormBuilderState extends State { if (isSetState) { setState(() {}); } + _onChangedStreamController.add(fields); } void registerField(String name, FormBuilderFieldState field) { @@ -192,6 +199,7 @@ class FormBuilderState extends State { populateForm: false, ); } + _onChangedStreamController.add(fields); } void unregisterField(String name, FormBuilderFieldState field) { @@ -216,6 +224,7 @@ class FormBuilderState extends State { return true; }()); } + _onChangedStreamController.add(fields); } void save() { @@ -270,6 +279,12 @@ class FormBuilderState extends State { }); } + @override + void dispose() { + _onChangedStreamController.close(); + super.dispose(); + } + @override Widget build(BuildContext context) { return Form( diff --git a/test/form_builder_stream_test.dart b/test/form_builder_stream_test.dart new file mode 100644 index 0000000000..9058d64e99 --- /dev/null +++ b/test/form_builder_stream_test.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_form_builder/flutter_form_builder.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'form_builder_tester.dart'; + +void main() { + group('onChanged --', () { + late GlobalKey formKey; + late FormBuilderTextField emptyTextField; + late FormBuilder form; + + setUp(() { + formKey = GlobalKey(); + emptyTextField = FormBuilderTextField( + key: const Key('text1'), + name: 'text1', + ); + form = FormBuilder(key: formKey, child: emptyTextField); + }); + + testWidgets('initial', (WidgetTester tester) async { + await tester.pumpWidget(buildTestableFieldWidget(form)); + final nextChange = await formKey.currentState!.onChanged.first; + + expect(nextChange, contains('text1')); + expect(nextChange['text1']?.value, isNull); + }); + + testWidgets('on changed', (WidgetTester tester) async { + await tester.runAsync(() async { + await tester.pumpWidget(buildTestableFieldWidget(form)); + final widget = find.byWidget(emptyTextField); + + expectLater( + formKey.currentState!.onChanged.map( + (fields) => + fields.entries.map((e) => {e.key: e.value.value}).toList(), + ), + emitsInOrder([ + [ + {'text1': null} + ], + [ + {'text1': 'foo'} + ], + [], // caused by `FormBuilderState.unregisterField` + emitsDone, + ]), + ); + + await tester.enterText(widget, 'foo'); + }); + }); + }); +}