From fb9133b8811004138d7b1b06c0fa445a4b8b0a1a Mon Sep 17 00:00:00 2001 From: Greg Spencer Date: Tue, 6 Dec 2022 17:15:22 -0800 Subject: [PATCH] Add ListenableBuilder with examples (#116543) * Add ListenableBuilder with examples * Add tests * Add tests * Fix Test * Change AnimatedBuilder to be a subclass of ListenableBuilder --- .../transitions/listenable_builder.0.dart | 172 +++++++++++++++ .../transitions/listenable_builder.1.dart} | 29 ++- .../change_notifier.0_test.dart | 33 --- .../listenable_builder.0_test.dart | 43 ++++ .../listenable_builder.1_test.dart | 24 ++ .../lib/src/foundation/change_notifier.dart | 2 +- .../flutter/lib/src/widgets/framework.dart | 10 +- .../flutter/lib/src/widgets/transitions.dart | 207 +++++++++++++----- .../test/widgets/transitions_test.dart | 145 ++++++++++++ 9 files changed, 559 insertions(+), 106 deletions(-) create mode 100644 examples/api/lib/widgets/transitions/listenable_builder.0.dart rename examples/api/lib/{foundation/change_notifier/change_notifier.0.dart => widgets/transitions/listenable_builder.1.dart} (61%) delete mode 100644 examples/api/test/foundation/change_notifier/change_notifier.0_test.dart create mode 100644 examples/api/test/widgets/transitions/listenable_builder.0_test.dart create mode 100644 examples/api/test/widgets/transitions/listenable_builder.1_test.dart diff --git a/examples/api/lib/widgets/transitions/listenable_builder.0.dart b/examples/api/lib/widgets/transitions/listenable_builder.0.dart new file mode 100644 index 0000000000..801bbdd3f7 --- /dev/null +++ b/examples/api/lib/widgets/transitions/listenable_builder.0.dart @@ -0,0 +1,172 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/// Flutter code sample for [ListenableBuilder]. + +import 'package:flutter/material.dart'; + +void main() => runApp(const ListenableBuilderExample()); + +/// This widget listens for changes in the focus state of the subtree defined by +/// its [child] widget, changing the border and color of the container it is in +/// when it has focus. +/// +/// A [FocusListenerContainer] swaps out the [BorderSide] of a border around the +/// child widget with [focusedSide], and the background color with +/// [focusedColor], when a widget that is a descendant of this widget has focus. +class FocusListenerContainer extends StatefulWidget { + const FocusListenerContainer({ + super.key, + this.border, + this.padding, + this.focusedSide, + this.focusedColor = Colors.black12, + required this.child, + }); + + /// This is the border that will be used when not focused, and which defines + /// all the attributes except for the [OutlinedBorder.side] when focused. + final OutlinedBorder? border; + + /// This is the [BorderSide] that will be used for [border] when the [child] + /// subtree is focused. + final BorderSide? focusedSide; + + /// This is the [Color] that will be used as the fill color for the background + /// of the [child] when a descendant widget is focused. + final Color? focusedColor; + + /// The padding around the inside of the container. + final EdgeInsetsGeometry? padding; + + /// This is defines the subtree to listen to for focus changes. + final Widget child; + + @override + State createState() => _FocusListenerContainerState(); +} + +class _FocusListenerContainerState extends State { + final FocusNode _focusNode = FocusNode(); + + @override + void dispose() { + _focusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final OutlinedBorder effectiveBorder = widget.border ?? const RoundedRectangleBorder(); + return ListenableBuilder( + listenable: _focusNode, + child: Focus( + focusNode: _focusNode, + skipTraversal: true, + canRequestFocus: false, + child: widget.child, + ), + builder: (BuildContext context, Widget? child) { + return Container( + padding: widget.padding, + decoration: ShapeDecoration( + color: _focusNode.hasFocus ? widget.focusedColor : null, + shape: effectiveBorder.copyWith( + side: _focusNode.hasFocus ? widget.focusedSide : null, + ), + ), + child: child, + ); + }, + ); + } +} + +class MyField extends StatefulWidget { + const MyField({super.key, required this.label}); + + final String label; + + @override + State createState() => _MyFieldState(); +} + +class _MyFieldState extends State { + final TextEditingController controller = TextEditingController(); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded(child: Text(widget.label)), + Expanded( + flex: 2, + child: TextField( + controller: controller, + onEditingComplete: () { + debugPrint('Field ${widget.label} changed to ${controller.value}'); + }, + ), + ), + ], + ); + } +} + +class ListenableBuilderExample extends StatelessWidget { + const ListenableBuilderExample({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('ListenableBuilder Example')), + body: Center( + child: SizedBox( + width: 300, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Padding( + padding: EdgeInsets.only(bottom: 8), + child: MyField(label: 'Company'), + ), + FocusListenerContainer( + padding: const EdgeInsets.all(8), + border: const RoundedRectangleBorder( + side: BorderSide( + strokeAlign: BorderSide.strokeAlignOutside, + ), + borderRadius: BorderRadius.all( + Radius.circular(5), + ), + ), + // The border side will get wider when the subtree has focus. + focusedSide: const BorderSide( + width: 4, + strokeAlign: BorderSide.strokeAlignOutside, + ), + // The container background will change color to this when + // the subtree has focus. + focusedColor: Colors.blue.shade50, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: const [ + Text('Owner:'), + MyField(label: 'First Name'), + MyField(label: 'Last Name'), + ], + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/examples/api/lib/foundation/change_notifier/change_notifier.0.dart b/examples/api/lib/widgets/transitions/listenable_builder.1.dart similarity index 61% rename from examples/api/lib/foundation/change_notifier/change_notifier.0.dart rename to examples/api/lib/widgets/transitions/listenable_builder.1.dart index d2cc9f3d03..8693df9b39 100644 --- a/examples/api/lib/foundation/change_notifier/change_notifier.0.dart +++ b/examples/api/lib/widgets/transitions/listenable_builder.1.dart @@ -2,10 +2,12 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -/// Flutter code sample for a [ChangeNotifier] with an [AnimatedBuilder]. +/// Flutter code sample for a [ChangeNotifier] with a [ListenableBuilder]. import 'package:flutter/material.dart'; +void main() { runApp(const ListenableBuilderExample()); } + class CounterBody extends StatelessWidget { const CounterBody({super.key, required this.counterValueNotifier}); @@ -18,13 +20,12 @@ class CounterBody extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.center, children: [ const Text('Current counter value:'), - // Thanks to the [AnimatedBuilder], only the widget displaying the - // current count is rebuilt when `counterValueNotifier` notifies its - // listeners. The [Text] widget above and [CounterBody] itself aren't + // Thanks to the ListenableBuilder, only the widget displaying the + // current count is rebuilt when counterValueNotifier notifies its + // listeners. The Text widget above and CounterBody itself aren't // rebuilt. - AnimatedBuilder( - // [AnimatedBuilder] accepts any [Listenable] subtype. - animation: counterValueNotifier, + ListenableBuilder( + listenable: counterValueNotifier, builder: (BuildContext context, Widget? child) { return Text('${counterValueNotifier.value}'); }, @@ -35,21 +36,21 @@ class CounterBody extends StatelessWidget { } } -class MyApp extends StatefulWidget { - const MyApp({super.key}); +class ListenableBuilderExample extends StatefulWidget { + const ListenableBuilderExample({super.key}); @override - State createState() => _MyAppState(); + State createState() => _ListenableBuilderExampleState(); } -class _MyAppState extends State { +class _ListenableBuilderExampleState extends State { final ValueNotifier _counter = ValueNotifier(0); @override Widget build(BuildContext context) { return MaterialApp( home: Scaffold( - appBar: AppBar(title: const Text('AnimatedBuilder example')), + appBar: AppBar(title: const Text('ListenableBuilder Example')), body: CounterBody(counterValueNotifier: _counter), floatingActionButton: FloatingActionButton( onPressed: () => _counter.value++, @@ -59,7 +60,3 @@ class _MyAppState extends State { ); } } - -void main() { - runApp(const MyApp()); -} diff --git a/examples/api/test/foundation/change_notifier/change_notifier.0_test.dart b/examples/api/test/foundation/change_notifier/change_notifier.0_test.dart deleted file mode 100644 index c7fc76c6cb..0000000000 --- a/examples/api/test/foundation/change_notifier/change_notifier.0_test.dart +++ /dev/null @@ -1,33 +0,0 @@ -// Copyright 2014 The Flutter Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -import 'package:flutter/material.dart'; -import 'package:flutter_api_samples/foundation/change_notifier/change_notifier.0.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - testWidgets('Smoke test for MyApp', (WidgetTester tester) async { - await tester.pumpWidget(const MyApp()); - - expect(find.byType(Scaffold), findsOneWidget); - expect(find.byType(CounterBody), findsOneWidget); - expect(find.byType(FloatingActionButton), findsOneWidget); - expect(find.text('Current counter value:'), findsOneWidget); - }); - - testWidgets('Counter update', (WidgetTester tester) async { - await tester.pumpWidget(const MyApp()); - - // Initial state of the counter - expect(find.text('0'), findsOneWidget); - - // Tapping the increase button - await tester.tap(find.byType(FloatingActionButton)); - await tester.pumpAndSettle(); - - // Counter should be at 1 - expect(find.text('1'), findsOneWidget); - expect(find.text('0'), findsNothing); - }); -} diff --git a/examples/api/test/widgets/transitions/listenable_builder.0_test.dart b/examples/api/test/widgets/transitions/listenable_builder.0_test.dart new file mode 100644 index 0000000000..68e38c0c90 --- /dev/null +++ b/examples/api/test/widgets/transitions/listenable_builder.0_test.dart @@ -0,0 +1,43 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter_api_samples/widgets/transitions/listenable_builder.0.dart' as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Changing focus changes border', (WidgetTester tester) async { + await tester.pumpWidget(const example.ListenableBuilderExample()); + + Finder findContainer() => find.descendant(of: find.byType(example.FocusListenerContainer), matching: find.byType(Container)).first; + Finder findChild() => find.descendant(of: findContainer(), matching: find.byType(Column)).first; + bool childHasFocus() => Focus.of(tester.element(findChild())).hasFocus; + Container getContainer() => tester.widget(findContainer()) as Container; + ShapeDecoration getDecoration() => getContainer().decoration! as ShapeDecoration; + OutlinedBorder getBorder() => getDecoration().shape as OutlinedBorder; + + expect(find.text('Company'), findsOneWidget); + expect(find.text('First Name'), findsOneWidget); + expect(find.text('Last Name'), findsOneWidget); + + await tester.tap(find.byType(TextField).first); + await tester.pumpAndSettle(); + expect(childHasFocus(), isFalse); + expect(getBorder().side.width, equals(1)); + expect(getContainer().color, isNull); + expect(getDecoration().color, isNull); + + await tester.tap(find.byType(TextField).at(1)); + await tester.pumpAndSettle(); + expect(childHasFocus(), isTrue); + expect(getBorder().side.width, equals(4)); + expect(getDecoration().color, equals(Colors.blue.shade50)); + + await tester.tap(find.byType(TextField).at(2)); + await tester.pumpAndSettle(); + expect(childHasFocus(), isTrue); + expect(getBorder().side.width, equals(4)); + expect(getDecoration().color, equals(Colors.blue.shade50)); + }); +} diff --git a/examples/api/test/widgets/transitions/listenable_builder.1_test.dart b/examples/api/test/widgets/transitions/listenable_builder.1_test.dart new file mode 100644 index 0000000000..00f18d1c28 --- /dev/null +++ b/examples/api/test/widgets/transitions/listenable_builder.1_test.dart @@ -0,0 +1,24 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter_api_samples/widgets/transitions/listenable_builder.1.dart' as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Tapping FAB increments counter', (WidgetTester tester) async { + await tester.pumpWidget(const example.ListenableBuilderExample()); + + String getCount() => (tester.widget(find.descendant(of: find.byType(ListenableBuilder), matching: find.byType(Text))) as Text).data!; + + expect(find.text('Current counter value:'), findsOneWidget); + expect(find.text('0'), findsOneWidget); + expect(find.byIcon(Icons.add), findsOneWidget); + expect(getCount(), equals('0')); + + await tester.tap(find.byType(FloatingActionButton).first); + await tester.pumpAndSettle(); + expect(getCount(), equals('1')); + }); +} diff --git a/packages/flutter/lib/src/foundation/change_notifier.dart b/packages/flutter/lib/src/foundation/change_notifier.dart index c09c025e09..01b9ac0926 100644 --- a/packages/flutter/lib/src/foundation/change_notifier.dart +++ b/packages/flutter/lib/src/foundation/change_notifier.dart @@ -104,7 +104,7 @@ const String _flutterFoundationLibrary = 'package:flutter/foundation.dart'; /// It is O(1) for adding listeners and O(N) for removing listeners and dispatching /// notifications (where N is the number of listeners). /// -/// {@macro flutter.flutter.animatedbuilder_changenotifier.rebuild} +/// {@macro flutter.flutter.ListenableBuilder.ChangeNotifier.rebuild} /// /// See also: /// diff --git a/packages/flutter/lib/src/widgets/framework.dart b/packages/flutter/lib/src/widgets/framework.dart index 51f2d36c43..b37b90ea5c 100644 --- a/packages/flutter/lib/src/widgets/framework.dart +++ b/packages/flutter/lib/src/widgets/framework.dart @@ -4910,14 +4910,14 @@ typedef NullableIndexedWidgetBuilder = Widget? Function(BuildContext context, in /// /// The child should typically be part of the returned widget tree. /// -/// Used by [AnimatedBuilder.builder], as well as [WidgetsApp.builder] and -/// [MaterialApp.builder]. +/// Used by [AnimatedBuilder.builder], [ListenableBuilder.builder], +/// [WidgetsApp.builder], and [MaterialApp.builder]. /// /// See also: /// -/// * [WidgetBuilder], which is similar but only takes a [BuildContext]. -/// * [IndexedWidgetBuilder], which is similar but also takes an index. -/// * [ValueWidgetBuilder], which is similar but takes a value and a child. +/// * [WidgetBuilder], which is similar but only takes a [BuildContext]. +/// * [IndexedWidgetBuilder], which is similar but also takes an index. +/// * [ValueWidgetBuilder], which is similar but takes a value and a child. typedef TransitionBuilder = Widget Function(BuildContext context, Widget? child); /// An [Element] that composes other [Element]s. diff --git a/packages/flutter/lib/src/widgets/transitions.dart b/packages/flutter/lib/src/widgets/transitions.dart index 861256c6d9..ce9ba3fc8d 100644 --- a/packages/flutter/lib/src/widgets/transitions.dart +++ b/packages/flutter/lib/src/widgets/transitions.dart @@ -32,7 +32,7 @@ export 'package:flutter/rendering.dart' show RelativeRect; /// {@end-tool} /// /// For more complex case involving additional state, consider using -/// [AnimatedBuilder]. +/// [AnimatedBuilder] or [ListenableBuilder]. /// /// ## Relationship to [ImplicitlyAnimatedWidget]s /// @@ -55,8 +55,10 @@ export 'package:flutter/rendering.dart' show RelativeRect; /// with subclasses of [ImplicitlyAnimatedWidget] (see above), which are usually /// named `AnimatedFoo`. Commonly used animated widgets include: /// -/// * [AnimatedBuilder], which is useful for complex animation use cases and a -/// notable exception to the naming scheme of [AnimatedWidget] subclasses. +/// * [ListenableBuilder], which uses a builder pattern that is useful for +/// complex [Listenable] use cases. +/// * [AnimatedBuilder], which uses a builder pattern that is useful for +/// complex [Animation] use cases. /// * [AlignTransition], which is an animated version of [Align]. /// * [DecoratedBoxTransition], which is an animated version of [DecoratedBox]. /// * [DefaultTextStyleTransition], which is an animated version of @@ -97,7 +99,7 @@ abstract class AnimatedWidget extends StatefulWidget { @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); - properties.add(DiagnosticsProperty('animation', listenable)); + properties.add(DiagnosticsProperty('listenable', listenable)); } } @@ -996,10 +998,108 @@ class DefaultTextStyleTransition extends AnimatedWidget { } } +/// A general-purpose widget for building a widget subtree when a [Listenable] +/// changes. +/// +/// [ListenableBuilder] is useful for more complex widgets that wish to listen +/// to changes in other objects as part of a larger build function. To use +/// [ListenableBuilder], simply construct the widget and pass it a [builder] +/// function. +/// +/// Any subtype of [Listenable] (such as a [ChangeNotifier], [ValueNotifier], or +/// [Animation]) can be used with a [ListenableBuilder] to rebuild only certain +/// parts of a widget when the [Listenable] notifies its listeners. Although +/// they have identical implementations, if an [Animation] is being listened to, +/// consider using an [AnimatedBuilder] instead for better readability. +/// +/// ## Performance optimizations +/// +/// {@template flutter.widgets.transitions.ListenableBuilder.optimizations} +/// If the [builder] function contains a subtree that does not depend on the +/// [listenable], it's often more efficient to build that subtree once instead +/// of rebuilding it on every change of the listenable. +/// +/// If a pre-built subtree is passed as the [child] parameter, the +/// [ListenableBuilder] will pass it back to the [builder] function so that it +/// can be incorporated into the build. +/// +/// Using this pre-built [child] is entirely optional, but can improve +/// performance significantly in some cases and is therefore a good practice. +/// {@endtemplate} +/// +/// {@tool dartpad} +/// This example shows how a [ListenableBuilder] can be used to listen to a +/// [FocusNode] (which is also a [ChangeNotifier]) to see when a subtree has +/// focus, and modify a decoration when its focus state changes. +/// +/// ** See code in examples/api/lib/widgets/transitions/listenable_builder.0.dart ** +/// {@end-tool} +/// +/// {@template flutter.flutter.ListenableBuilder.ChangeNotifier.rebuild} +/// ## Improve rebuild performance +/// +/// Performance can be improved by specifying any widgets that don't need to +/// change as a result of changes in the listener as the prebuilt +/// [ListenableBuilder.child] attribute. +/// +/// {@tool dartpad} +/// The following example implements a simple counter that utilizes a +/// [ListenableBuilder] to limit rebuilds to only the [Text] widget containing +/// the count. The current count is stored in a [ValueNotifier], which rebuilds +/// the [ListenableBuilder]'s contents when its value is changed. +/// +/// ** See code in examples/api/lib/widgets/transitions/listenable_builder.1.dart ** +/// {@end-tool} +/// {@endtemplate} +/// +/// See also: +/// +/// * [AnimatedBuilder], which has the same functionality, but is named more +/// appropriately for a builder triggered by [Animation]s. +class ListenableBuilder extends AnimatedWidget { + /// Creates a builder that responds to changes in [listenable]. + /// + /// The [listenable] and [builder] arguments must not be null. + const ListenableBuilder({ + super.key, + required super.listenable, + required this.builder, + this.child, + }); + + // Overridden getter to replace with documentation tailored to + // ListenableBuilder. + + /// The [Listenable] supplied to the constructor. + /// + /// Also accessible through the [listenable] getter. + /// + /// See also: + /// + /// * [AnimatedBuilder], a widget with an identical functionality commonly + /// used with [Animation] [Listenable]s for better readability. + @override + Listenable get listenable => super.listenable; + + /// Called every time the [listenable] notifies about a change. + /// + /// The child given to the builder should typically be part of the returned + /// widget tree. + final TransitionBuilder builder; + + /// The child widget to pass to the [builder]. + /// + /// {@macro flutter.widgets.transitions.ListenableBuilder.optimizations} + final Widget? child; + + @override + Widget build(BuildContext context) => builder(context, child); +} + /// A general-purpose widget for building animations. /// -/// AnimatedBuilder is useful for more complex widgets that wish to include -/// an animation as part of a larger build function. To use AnimatedBuilder, +/// [AnimatedBuilder] is useful for more complex widgets that wish to include +/// an animation as part of a larger build function. To use [AnimatedBuilder], /// simply construct the widget and pass it a builder function. /// /// For simple cases without additional state, consider using @@ -1009,16 +1109,18 @@ class DefaultTextStyleTransition extends AnimatedWidget { /// /// ## Performance optimizations /// -/// If your [builder] function contains a subtree that does not depend on the -/// animation, it's more efficient to build that subtree once instead of -/// rebuilding it on every animation tick. +/// {@template flutter.widgets.transitions.AnimatedBuilder.optimizations} +/// If the [builder] function contains a subtree that does not depend on the +/// animation passed to the constructor, it's more efficient to build that +/// subtree once instead of rebuilding it on every animation tick. /// -/// If you pass the pre-built subtree as the [child] parameter, the -/// [AnimatedBuilder] will pass it back to your builder function so that you -/// can incorporate it into your build. +/// If a pre-built subtree is passed as the [child] parameter, the +/// [AnimatedBuilder] will pass it back to the [builder] function so that it can +/// be incorporated into the build. /// /// Using this pre-built child is entirely optional, but can improve /// performance significantly in some cases and is therefore a good practice. +/// {@endtemplate} /// /// {@tool dartpad} /// This code defines a widget that spins a green square continually. It is @@ -1028,61 +1130,64 @@ class DefaultTextStyleTransition extends AnimatedWidget { /// ** See code in examples/api/lib/widgets/transitions/animated_builder.0.dart ** /// {@end-tool} /// -/// {@template flutter.flutter.animatedbuilder_changenotifier.rebuild} -/// ## Improve rebuilds performance using AnimatedBuilder +/// ## Improve rebuild performance /// -/// Despite the name, [AnimatedBuilder] is not limited to [Animation]s. Any subtype -/// of [Listenable] (such as [ChangeNotifier] and [ValueNotifier]) can be used with -/// an [AnimatedBuilder] to rebuild only certain parts of a widget when the -/// [Listenable] notifies its listeners. This technique is a performance improvement -/// that allows rebuilding only specific widgets leaving others untouched. +/// Despite the name, [AnimatedBuilder] is not limited to [Animation]s, any +/// subtype of [Listenable] (such as [ChangeNotifier] or [ValueNotifier]) can be +/// used to trigger rebuilds. Although they have identical implementations, if +/// an [Animation] is not being listened to, consider using a +/// [ListenableBuilder] for better readability. +/// +/// You can use an [AnimatedBuilder] or [ListenableBuilder] to rebuild only +/// certain parts of a widget when the [Listenable] notifies its listeners. You +/// can improve performance by specifying any widgets that don't need to change +/// as a result of changes in the listener as the prebuilt [child] attribute. /// /// {@tool dartpad} /// The following example implements a simple counter that utilizes an -/// [AnimatedBuilder] to limit rebuilds to only the [Text] widget. The current count -/// is stored in a [ValueNotifier], which rebuilds the [AnimatedBuilder]'s contents -/// when its value is changed. +/// [AnimatedBuilder] to limit rebuilds to only the [Text] widget. The current +/// count is stored in a [ValueNotifier], which rebuilds the [AnimatedBuilder]'s +/// contents when its value is changed. /// -/// ** See code in examples/api/lib/foundation/change_notifier/change_notifier.0.dart ** +/// ** See code in examples/api/lib/widgets/transitions/listenable_builder.1.dart ** /// {@end-tool} -/// {@endtemplate} /// /// See also: /// -/// * [TweenAnimationBuilder], which animates a property to a target value -/// without requiring manual management of an [AnimationController]. -class AnimatedBuilder extends AnimatedWidget { +/// * [ListenableBuilder], a widget with similar functionality, but is named +/// more appropriately for a builder triggered on changes in [Listenable]s +/// that aren't [Animation]s. +/// * [TweenAnimationBuilder], which animates a property to a target value +/// without requiring manual management of an [AnimationController]. +class AnimatedBuilder extends ListenableBuilder { /// Creates an animated builder. /// - /// The [animation] and [builder] arguments must not be null. + /// The [animation] and [builder] arguments are required. const AnimatedBuilder({ super.key, required Listenable animation, - required this.builder, - this.child, - }) : assert(animation != null), - assert(builder != null), - super(listenable: animation); + required super.builder, + super.child, + }) : super(listenable: animation); - /// Called every time the animation changes value. - final TransitionBuilder builder; + /// The [Listenable] supplied to the constructor (typically an [Animation]). + /// + /// Also accessible through the [listenable] getter. + /// + /// See also: + /// + /// * [ListenableBuilder], a widget with similar functionality commonly used + /// with [Listenable]s (such as [ChangeNotifier]) for better readability + /// when the [animation] isn't an [Animation]. + Listenable get animation => super.listenable; - /// The child widget to pass to the [builder]. - /// - /// If a [builder] callback's return value contains a subtree that does not - /// depend on the animation, it's more efficient to build that subtree once - /// instead of rebuilding it on every animation tick. - /// - /// If the pre-built subtree is passed as the [child] parameter, the - /// [AnimatedBuilder] will pass it back to the [builder] function so that it - /// can be incorporated into the build. - /// - /// Using this pre-built child is entirely optional, but can improve - /// performance significantly in some cases and is therefore a good practice. - final Widget? child; + // Overridden getter to replace with documentation tailored to + // AnimatedBuilder. + /// Called every time the [animation] notifies about a change. + /// + /// The child given to the builder should typically be part of the returned + /// widget tree. @override - Widget build(BuildContext context) { - return builder(context, child); - } + TransitionBuilder get builder => super.builder; } diff --git a/packages/flutter/test/widgets/transitions_test.dart b/packages/flutter/test/widgets/transitions_test.dart index 4546cef017..9b1a33bc9a 100644 --- a/packages/flutter/test/widgets/transitions_test.dart +++ b/packages/flutter/test/widgets/transitions_test.dart @@ -562,4 +562,149 @@ void main() { expect(tester.layers, isNot(contains(isA()))); }); }); + + group('Builders', () { + testWidgets('AnimatedBuilder rebuilds when changed', (WidgetTester tester) async { + final GlobalKey redrawKey = GlobalKey(); + final ChangeNotifier notifier = ChangeNotifier(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: AnimatedBuilder( + animation: notifier, + builder: (BuildContext context, Widget? child) { + return RedrawCounter(key: redrawKey, child: child); + }, + ), + ), + ); + + expect(redrawKey.currentState!.redraws, equals(1)); + await tester.pump(); + expect(redrawKey.currentState!.redraws, equals(1)); + notifier.notifyListeners(); + await tester.pump(); + expect(redrawKey.currentState!.redraws, equals(2)); + + // Pump a few more times to make sure that we don't rebuild unnecessarily. + await tester.pump(); + await tester.pump(); + expect(redrawKey.currentState!.redraws, equals(2)); + }); + + testWidgets("AnimatedBuilder doesn't rebuild the child", (WidgetTester tester) async { + final GlobalKey redrawKey = GlobalKey(); + final GlobalKey redrawKeyChild = GlobalKey(); + final ChangeNotifier notifier = ChangeNotifier(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: AnimatedBuilder( + animation: notifier, + builder: (BuildContext context, Widget? child) { + return RedrawCounter(key: redrawKey, child: child); + }, + child: RedrawCounter(key: redrawKeyChild), + ), + ), + ); + + expect(redrawKey.currentState!.redraws, equals(1)); + expect(redrawKeyChild.currentState!.redraws, equals(1)); + await tester.pump(); + expect(redrawKey.currentState!.redraws, equals(1)); + expect(redrawKeyChild.currentState!.redraws, equals(1)); + notifier.notifyListeners(); + await tester.pump(); + expect(redrawKey.currentState!.redraws, equals(2)); + expect(redrawKeyChild.currentState!.redraws, equals(1)); + + // Pump a few more times to make sure that we don't rebuild unnecessarily. + await tester.pump(); + await tester.pump(); + expect(redrawKey.currentState!.redraws, equals(2)); + expect(redrawKeyChild.currentState!.redraws, equals(1)); + }); + + testWidgets('ListenableBuilder rebuilds when changed', (WidgetTester tester) async { + final GlobalKey redrawKey = GlobalKey(); + final ChangeNotifier notifier = ChangeNotifier(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: ListenableBuilder( + listenable: notifier, + builder: (BuildContext context, Widget? child) { + return RedrawCounter(key: redrawKey, child: child); + }, + ), + ), + ); + + expect(redrawKey.currentState!.redraws, equals(1)); + await tester.pump(); + expect(redrawKey.currentState!.redraws, equals(1)); + notifier.notifyListeners(); + await tester.pump(); + expect(redrawKey.currentState!.redraws, equals(2)); + + // Pump a few more times to make sure that we don't rebuild unnecessarily. + await tester.pump(); + await tester.pump(); + expect(redrawKey.currentState!.redraws, equals(2)); + }); + + testWidgets("ListenableBuilder doesn't rebuild the child", (WidgetTester tester) async { + final GlobalKey redrawKey = GlobalKey(); + final GlobalKey redrawKeyChild = GlobalKey(); + final ChangeNotifier notifier = ChangeNotifier(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: ListenableBuilder( + listenable: notifier, + builder: (BuildContext context, Widget? child) { + return RedrawCounter(key: redrawKey, child: child); + }, + child: RedrawCounter(key: redrawKeyChild), + ), + ), + ); + + expect(redrawKey.currentState!.redraws, equals(1)); + expect(redrawKeyChild.currentState!.redraws, equals(1)); + await tester.pump(); + expect(redrawKey.currentState!.redraws, equals(1)); + expect(redrawKeyChild.currentState!.redraws, equals(1)); + notifier.notifyListeners(); + await tester.pump(); + expect(redrawKey.currentState!.redraws, equals(2)); + expect(redrawKeyChild.currentState!.redraws, equals(1)); + + // Pump a few more times to make sure that we don't rebuild unnecessarily. + await tester.pump(); + await tester.pump(); + expect(redrawKey.currentState!.redraws, equals(2)); + expect(redrawKeyChild.currentState!.redraws, equals(1)); + }); + }); +} + +class RedrawCounter extends StatefulWidget { + const RedrawCounter({ super.key, this.child }); + + final Widget? child; + + @override + State createState() => RedrawCounterState(); +} + +class RedrawCounterState extends State { + int redraws = 0; + + @override + Widget build(BuildContext context) { + redraws += 1; + return SizedBox(child: widget.child); + } }