Skip to content

Commit

Permalink
Add ListenableBuilder with examples (#116543)
Browse files Browse the repository at this point in the history
* Add ListenableBuilder with examples

* Add tests

* Add tests

* Fix Test

* Change AnimatedBuilder to be a subclass of ListenableBuilder
  • Loading branch information
gspencergoog authored Dec 7, 2022
1 parent 609fe35 commit fb9133b
Show file tree
Hide file tree
Showing 9 changed files with 557 additions and 104 deletions.
172 changes: 172 additions & 0 deletions examples/api/lib/widgets/transitions/listenable_builder.0.dart
Original file line number Diff line number Diff line change
@@ -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<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'),
],
),
),
],
),
),
),
),
),
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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});

Expand All @@ -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}');
},
Expand All @@ -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++,
Expand All @@ -59,7 +60,3 @@ class _MyAppState extends State<MyApp> {
);
}
}

void main() {
runApp(const MyApp());
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -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));
});
}
Original file line number Diff line number Diff line change
@@ -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'));
});
}
2 changes: 1 addition & 1 deletion packages/flutter/lib/src/foundation/change_notifier.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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:
///
Expand Down
10 changes: 5 additions & 5 deletions packages/flutter/lib/src/widgets/framework.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading

0 comments on commit fb9133b

Please sign in to comment.