From 2c2a7bb54802e5e7b7210b650d5ab3d5f0f0c563 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Fri, 22 Sep 2023 19:07:10 +0900 Subject: [PATCH] feat: add `MultiReactionBuilder` widget (#917) * feat: add `MultiReactionBuilder` widget --- flutter_mobx/CHANGELOG.md | 4 ++ .../lib/src/multi_reaction_builder.dart | 57 +++++++++++++++++ flutter_mobx/lib/src/reaction_builder.dart | 25 +++++--- flutter_mobx/pubspec.yaml | 3 +- flutter_mobx/test/all_tests.dart | 2 + .../test/multi_reaction_builder_test.dart | 61 +++++++++++++++++++ flutter_mobx/test/reaction_builder_test.dart | 17 ++++++ 7 files changed, 158 insertions(+), 11 deletions(-) create mode 100644 flutter_mobx/lib/src/multi_reaction_builder.dart create mode 100644 flutter_mobx/test/multi_reaction_builder_test.dart diff --git a/flutter_mobx/CHANGELOG.md b/flutter_mobx/CHANGELOG.md index 7ccde40f4..4f0ce7c56 100644 --- a/flutter_mobx/CHANGELOG.md +++ b/flutter_mobx/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.1.0 + +- feat: add `MultiReactionBuilder` widget by [@amondnet](https://github.com/amondnet) + ## 2.0.6+3 - 2.0.6+5 - Moved the version into its own file (`version.dart`) and exported from the main library file diff --git a/flutter_mobx/lib/src/multi_reaction_builder.dart b/flutter_mobx/lib/src/multi_reaction_builder.dart new file mode 100644 index 000000000..e817fb69e --- /dev/null +++ b/flutter_mobx/lib/src/multi_reaction_builder.dart @@ -0,0 +1,57 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_mobx/src/reaction_builder.dart'; +import 'package:provider/provider.dart'; + +/// {@template multi_reaction_builder} +/// Merges multiple [ReactionBuilder] widgets into one widget tree. +/// +/// [MultiReactionBuilder] improves the readability and eliminates the need +/// to nest multiple [ReactionBuilder]s. +/// +/// By using [MultiReactionBuilder] we can go from: +/// +/// ```dart +/// ReactionBuilder( +/// builder: (context) {}, +/// child: ReactionBuilder( +/// builder: (context) {}, +/// child: ReactionBuilder( +/// builder: (context) {}, +/// child: ChildA(), +/// ), +/// ), +/// ) +/// ``` +/// +/// to: +/// +/// ```dart +/// MultiReactionBuilder( +/// builders: [ +/// ReactionBuilder( +/// builder: (context) {}, +/// ), +/// ReactionBuilder( +/// builder: (context) {}, +/// ), +/// ReactionBuilder( +/// builder: (context) {}, +/// ), +/// ], +/// child: ChildA(), +/// ) +/// ``` +/// +/// [MultiReactionBuilder] converts the [ReactionBuilder] list into a tree of nested +/// [ReactionBuilder] widgets. +/// As a result, the only advantage of using [MultiReactionBuilder] is improved +/// readability due to the reduction in nesting and boilerplate. +/// {@endtemplate} +class MultiReactionBuilder extends MultiProvider { + /// {@macro multi_reaction_builder} + MultiReactionBuilder({ + Key? key, + required List builders, + required Widget child, + }) : super(key: key, providers: builders, child: child); +} diff --git a/flutter_mobx/lib/src/reaction_builder.dart b/flutter_mobx/lib/src/reaction_builder.dart index cd4b7fe39..f381eec94 100644 --- a/flutter_mobx/lib/src/reaction_builder.dart +++ b/flutter_mobx/lib/src/reaction_builder.dart @@ -1,5 +1,6 @@ import 'package:flutter/widgets.dart'; import 'package:mobx/mobx.dart'; +import 'package:provider/single_child_widget.dart'; /// A builder function that creates a reaction typedef ReactionBuilderFunction = ReactionDisposer Function( @@ -16,19 +17,18 @@ typedef ReactionBuilderFunction = ReactionDisposer Function( /// [builder] that takes in a [BuildContext] and prepares the reaction. It should /// end up returning a [ReactionDisposer]. This will be disposed when the [ReactionBuilder] /// is disposed. The [child] Widget gets rendered as part of the build process. -class ReactionBuilder extends StatefulWidget { +class ReactionBuilder extends SingleChildStatefulWidget { final ReactionBuilderFunction builder; - final Widget child; - const ReactionBuilder({Key? key, required this.child, required this.builder}) - : super(key: key); + const ReactionBuilder({Key? key, Widget? child, required this.builder}) + : super(key: key, child: child); @override ReactionBuilderState createState() => ReactionBuilderState(); } @visibleForTesting -class ReactionBuilderState extends State { +class ReactionBuilderState extends SingleChildState { late ReactionDisposer _disposeReaction; bool get isDisposed => _disposeReaction.reaction.isDisposed; @@ -40,14 +40,19 @@ class ReactionBuilderState extends State { _disposeReaction = widget.builder(context); } - @override - Widget build(BuildContext context) { - return widget.child; - } - @override void dispose() { _disposeReaction(); super.dispose(); } + + @override + Widget buildWithChild(BuildContext context, Widget? child) { + assert( + child != null, + '''${widget.runtimeType} used outside of MultiReactionBuilder must specify a child''', + ); + + return child!; + } } diff --git a/flutter_mobx/pubspec.yaml b/flutter_mobx/pubspec.yaml index 9924a9843..4431095cf 100644 --- a/flutter_mobx/pubspec.yaml +++ b/flutter_mobx/pubspec.yaml @@ -2,7 +2,7 @@ name: flutter_mobx description: Flutter integration for MobX. It provides a set of Observer widgets that automatically rebuild when the tracked observables change. -version: 2.0.6+5 +version: 2.1.0 homepage: https://github.com/mobxjs/mobx.dart issue_tracker: https://github.com/mobxjs/mobx.dart/issues @@ -14,6 +14,7 @@ dependencies: flutter: sdk: flutter mobx: ^2.0.6 + provider: ^6.0.0 dev_dependencies: build_runner: ^2.0.6 diff --git a/flutter_mobx/test/all_tests.dart b/flutter_mobx/test/all_tests.dart index 68ca85951..fc8110c7c 100644 --- a/flutter_mobx/test/all_tests.dart +++ b/flutter_mobx/test/all_tests.dart @@ -1,7 +1,9 @@ import 'flutter_mobx_test.dart' as flutter_mobx_test; import 'reaction_builder_test.dart' as reaction_builder_test; +import 'multi_reaction_builder_test.dart' as multi_reaction_builder_test; void main() { flutter_mobx_test.main(); reaction_builder_test.main(); + multi_reaction_builder_test.main(); } diff --git a/flutter_mobx/test/multi_reaction_builder_test.dart b/flutter_mobx/test/multi_reaction_builder_test.dart new file mode 100644 index 000000000..be22ab4be --- /dev/null +++ b/flutter_mobx/test/multi_reaction_builder_test.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_mobx/src/multi_reaction_builder.dart'; +import 'package:flutter_mobx/src/reaction_builder.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mobx/mobx.dart'; + +class Counter { + Counter(); + + Observable state = Observable(0); + + void increment() => runInAction(() => state.value = state.value + 1); +} + +void main() { + group('MultiReactionBuilder', () { + testWidgets('calls reactions on state changes', (tester) async { + final statesA = []; + const expectedStatesA = [1, 2]; + final counterA = Counter(); + + final statesB = []; + final expectedStatesB = [1]; + final counterB = Counter(); + + await tester.pumpWidget( + MultiReactionBuilder( + key: const Key('MultiReactionBuilder'), + builders: [ + ReactionBuilder( + builder: (context) => reaction( + (_) => counterA.state.value, + (int state) => statesA.add(state), + ), + ), + ReactionBuilder( + builder: (context) => reaction( + (_) => counterB.state.value, + (int state) => statesB.add(state), + ), + ), + ], + child: const SizedBox(key: Key('multiListener_child')), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('multiListener_child')), findsOneWidget); + + counterA.increment(); + await tester.pump(); + counterA.increment(); + await tester.pump(); + counterB.increment(); + await tester.pump(); + + expect(statesA, expectedStatesA); + expect(statesB, expectedStatesB); + }); + }); +} diff --git a/flutter_mobx/test/reaction_builder_test.dart b/flutter_mobx/test/reaction_builder_test.dart index 3f0fdc6ec..ed3c891d9 100644 --- a/flutter_mobx/test/reaction_builder_test.dart +++ b/flutter_mobx/test/reaction_builder_test.dart @@ -82,5 +82,22 @@ void main() { message.value += 1; expect(count, 2); }); + + testWidgets( + 'throws AssertionError if child is not specified in the builder', + (tester) async { + final message = Observable(0); + const expected = + '''ReactionBuilder used outside of MultiReactionBuilder must specify a child'''; + await tester.pumpWidget(ReactionBuilder( + builder: (context) { + return reaction((_) => message.value, (int value) {}); + }, + )); + expect( + tester.takeException(), + isA().having((e) => e.message, 'message', expected), + ); + }); }); }