Unverified Commit fb9133b8 authored by Greg Spencer's avatar Greg Spencer Committed by GitHub

Add ListenableBuilder with examples (#116543)

* Add ListenableBuilder with examples

* Add tests

* Add tests

* Fix Test

* Change AnimatedBuilder to be a subclass of ListenableBuilder
parent 609fe35f
// 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<FocusListenerContainer> createState() => _FocusListenerContainerState();
}
class _FocusListenerContainerState extends State<FocusListenerContainer> {
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<MyField> createState() => _MyFieldState();
}
class _MyFieldState extends State<MyField> {
final TextEditingController controller = TextEditingController();
@override
Widget build(BuildContext context) {
return Row(
children: <Widget>[
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: <Widget>[
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 <Widget>[
Text('Owner:'),
MyField(label: 'First Name'),
MyField(label: 'Last Name'),
],
),
),
],
),
),
),
),
),
);
}
}
......@@ -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: <Widget>[
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<MyApp> createState() => _MyAppState();
State<ListenableBuilderExample> createState() => _ListenableBuilderExampleState();
}
class _MyAppState extends State<MyApp> {
class _ListenableBuilderExampleState extends State<ListenableBuilderExample> {
final ValueNotifier<int> _counter = ValueNotifier<int>(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<MyApp> {
);
}
}
void main() {
runApp(const MyApp());
}
// 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));
});
}
......@@ -3,31 +3,22 @@
// 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_api_samples/widgets/transitions/listenable_builder.1.dart' as example;
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('Smoke test for MyApp', (WidgetTester tester) async {
await tester.pumpWidget(const MyApp());
testWidgets('Tapping FAB increments counter', (WidgetTester tester) async {
await tester.pumpWidget(const example.ListenableBuilderExample());
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());
String getCount() => (tester.widget(find.descendant(of: find.byType(ListenableBuilder), matching: find.byType(Text))) as Text).data!;
// Initial state of the counter
expect(find.text('Current counter value:'), findsOneWidget);
expect(find.text('0'), findsOneWidget);
expect(find.byIcon(Icons.add), findsOneWidget);
expect(getCount(), equals('0'));
// Tapping the increase button
await tester.tap(find.byType(FloatingActionButton));
await tester.tap(find.byType(FloatingActionButton).first);
await tester.pumpAndSettle();
// Counter should be at 1
expect(find.text('1'), findsOneWidget);
expect(find.text('0'), findsNothing);
expect(getCount(), equals('1'));
});
}
......@@ -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:
///
......
......@@ -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.
......
......@@ -562,4 +562,149 @@ void main() {
expect(tester.layers, isNot(contains(isA<ImageFilterLayer>())));
});
});
group('Builders', () {
testWidgets('AnimatedBuilder rebuilds when changed', (WidgetTester tester) async {
final GlobalKey<RedrawCounterState> redrawKey = GlobalKey<RedrawCounterState>();
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<RedrawCounterState> redrawKey = GlobalKey<RedrawCounterState>();
final GlobalKey<RedrawCounterState> redrawKeyChild = GlobalKey<RedrawCounterState>();
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<RedrawCounterState> redrawKey = GlobalKey<RedrawCounterState>();
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<RedrawCounterState> redrawKey = GlobalKey<RedrawCounterState>();
final GlobalKey<RedrawCounterState> redrawKeyChild = GlobalKey<RedrawCounterState>();
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<RedrawCounter> createState() => RedrawCounterState();
}
class RedrawCounterState extends State<RedrawCounter> {
int redraws = 0;
@override
Widget build(BuildContext context) {
redraws += 1;
return SizedBox(child: widget.child);
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment