From ea355c66dfd9ad1a5b0109c9e2f54e50273e0dfc Mon Sep 17 00:00:00 2001 From: xster Date: Wed, 15 Aug 2018 19:22:08 -0700 Subject: [PATCH] Create a ValueListenableBuilder (#19729) --- .../src/widgets/value_listenable_builder.dart | 126 ++++++++++++++++++ packages/flutter/lib/widgets.dart | 1 + .../value_listenable_builder_test.dart | 120 +++++++++++++++++ 3 files changed, 247 insertions(+) create mode 100644 packages/flutter/lib/src/widgets/value_listenable_builder.dart create mode 100644 packages/flutter/test/widgets/value_listenable_builder_test.dart diff --git a/packages/flutter/lib/src/widgets/value_listenable_builder.dart b/packages/flutter/lib/src/widgets/value_listenable_builder.dart new file mode 100644 index 0000000000..4ca7c7a817 --- /dev/null +++ b/packages/flutter/lib/src/widgets/value_listenable_builder.dart @@ -0,0 +1,126 @@ +// Copyright 2018 The Chromium 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/foundation.dart'; + +import 'framework.dart'; + +/// Builds a [Widget] when given a concrete value of a [ValueListenable]. +/// +/// If the `child` parameter provided to the [ValueListenableBuilder] is not +/// null, the same `child` widget is passed back to this [ValueWidgetBuilder] +/// and should typically be incorporated in the returned widget tree. +/// +/// See also: +/// +/// * [ValueListenableBuilder], a widget which invokes this builder each time +/// a [ValueListenable] changes value. +typedef Widget ValueWidgetBuilder(BuildContext context, T value, Widget child); + +/// A widget whose content stays sync'ed with a [ValueListenable]. +/// +/// Given a [ValueListenable] and a [builder] which builds widgets from +/// concrete values of `T`, this class will automatically register itself as a +/// listener of the [ValueListenable] and call the [builder] with updated values +/// when the value changes. +/// +/// ## Performance optimizations +/// +/// If your [builder] function contains a subtree that does not depend on the +/// value of the [ValueListenable], 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 +/// [ValueListenableBuilder] will pass it back to your [builder] function so +/// that you can incorporate it into your build. +/// +/// Using this pre-built child is entirely optional, but can improve +/// performance significantly in some cases and is therefore a good practice. +/// +/// See also: +/// +/// * [AnimatedBuilder], which also triggers rebuilds from a [Listenable] +/// without passing back a specific value from a [ValueListenable]. +/// * [NotificationListener], which lets you rebuild based on [Notification] +/// coming from its descendent widgets rather than a [ValueListenable] that +/// you have a direct reference to. +/// * [StreamBuilder], where a builder can depend on a [Stream] rather than +/// a [ValueListenable] for more advanced use cases. +class ValueListenableBuilder extends StatefulWidget { + /// Creates a [ValueListenableBuilder]. + /// + /// The [valueListenable] and [builder] arguments must not be null. + /// The [child] is optional but is good practice to use if part of the widget + /// subtree does not depend on the value of the [valueListenable]. + const ValueListenableBuilder({ + @required this.valueListenable, + @required this.builder, + this.child, + }) : assert(valueListenable != null), + assert(builder != null); + + /// The [ValueListenable] whose value you depend on in order to build. + /// + /// This widget does not ensure that the [ValueListenable]'s value is not + /// null, therefore your [builder] may need to handle null values. + /// + /// This [ValueListenable] itself must not be null. + final ValueListenable valueListenable; + + /// A [ValueWidgetBuilder] which builds a widget depending on the + /// [valueListenable]'s value. + /// + /// Can incorporate a [valueListenable] value-independent widget subtree + /// from the [child] parameter into the returned widget tree. + /// + /// Must not be null. + final ValueWidgetBuilder builder; + + /// A [valueListenable]-independent widget which is passed back to the [builder]. + /// + /// This argument is optional and can be null if the entire widget subtree + /// the [builder] builds depends on the value of the [valueListenable]. For + /// example, if the [valueListenable] is a [String] and the [builder] simply + /// returns a [Text] widget with the [String] value. + final Widget child; + + @override + State createState() => new _ValueListenableBuilderState(); +} + +class _ValueListenableBuilderState extends State> { + T value; + + @override + void initState() { + super.initState(); + value = widget.valueListenable.value; + widget.valueListenable.addListener(_valueChanged); + } + + @override + void didUpdateWidget(ValueListenableBuilder oldWidget) { + if (oldWidget.valueListenable != widget.valueListenable) { + oldWidget.valueListenable.removeListener(_valueChanged); + value = widget.valueListenable.value; + widget.valueListenable.addListener(_valueChanged); + } + super.didUpdateWidget(oldWidget); + } + + @override + void dispose() { + widget.valueListenable.removeListener(_valueChanged); + super.dispose(); + } + + void _valueChanged() { + setState(() { value = widget.valueListenable.value; }); + } + + @override + Widget build(BuildContext context) { + return widget.builder(context, value, widget.child); + } +} diff --git a/packages/flutter/lib/widgets.dart b/packages/flutter/lib/widgets.dart index 3ec5fe3c98..72920b09ae 100644 --- a/packages/flutter/lib/widgets.dart +++ b/packages/flutter/lib/widgets.dart @@ -98,6 +98,7 @@ export 'src/widgets/ticker_provider.dart'; export 'src/widgets/title.dart'; export 'src/widgets/transitions.dart'; export 'src/widgets/unique_widget.dart'; +export 'src/widgets/value_listenable_builder.dart'; export 'src/widgets/viewport.dart'; export 'src/widgets/visibility.dart'; export 'src/widgets/widget_inspector.dart'; diff --git a/packages/flutter/test/widgets/value_listenable_builder_test.dart b/packages/flutter/test/widgets/value_listenable_builder_test.dart new file mode 100644 index 0000000000..293d974e32 --- /dev/null +++ b/packages/flutter/test/widgets/value_listenable_builder_test.dart @@ -0,0 +1,120 @@ +// Copyright 2018 The Chromium 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_test/flutter_test.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +void main() { + SpyStringValueNotifier valueListenable; + Widget textBuilderUnderTest; + + Widget builderForValueListenable( + ValueListenable valueListenable, + ) { + return new Directionality( + textDirection: TextDirection.ltr, + child: new ValueListenableBuilder( + valueListenable: valueListenable, + builder: (BuildContext context, String value, Widget child) { + if (value == null) + return const Placeholder(); + return new Text(value); + }, + ), + ); + } + + setUp(() { + valueListenable = new SpyStringValueNotifier(null); + textBuilderUnderTest = builderForValueListenable(valueListenable); + }); + + testWidgets('Null value is ok', (WidgetTester tester) async { + await tester.pumpWidget(textBuilderUnderTest); + + expect(find.byType(Placeholder), findsOneWidget); + }); + + testWidgets('Widget builds with initial value', (WidgetTester tester) async { + valueListenable = new SpyStringValueNotifier('Bachman'); + + await tester.pumpWidget(builderForValueListenable(valueListenable)); + + expect(find.text('Bachman'), findsOneWidget); + }); + + testWidgets('Widget updates when value changes', (WidgetTester tester) async { + await tester.pumpWidget(textBuilderUnderTest); + + valueListenable.value = 'Gilfoyle'; + await tester.pump(); + expect(find.text('Gilfoyle'), findsOneWidget); + + valueListenable.value = 'Dinesh'; + await tester.pump(); + expect(find.text('Gilfoyle'), findsNothing); + expect(find.text('Dinesh'), findsOneWidget); + }); + + testWidgets('Can change listenable', (WidgetTester tester) async { + await tester.pumpWidget(textBuilderUnderTest); + + valueListenable.value = 'Gilfoyle'; + await tester.pump(); + expect(find.text('Gilfoyle'), findsOneWidget); + + final ValueListenable differentListenable = + new SpyStringValueNotifier('Hendricks'); + + await tester.pumpWidget(builderForValueListenable(differentListenable)); + + expect(find.text('Gilfoyle'), findsNothing); + expect(find.text('Hendricks'), findsOneWidget); + }); + + testWidgets('Stops listening to old listenable after chainging listenable', (WidgetTester tester) async { + await tester.pumpWidget(textBuilderUnderTest); + + valueListenable.value = 'Gilfoyle'; + await tester.pump(); + expect(find.text('Gilfoyle'), findsOneWidget); + + final ValueListenable differentListenable = + new SpyStringValueNotifier('Hendricks'); + + await tester.pumpWidget(builderForValueListenable(differentListenable)); + + expect(find.text('Gilfoyle'), findsNothing); + expect(find.text('Hendricks'), findsOneWidget); + + // Change value of the (now) disconnected listenable. + valueListenable.value = 'Big Head'; + + expect(find.text('Gilfoyle'), findsNothing); + expect(find.text('Big Head'), findsNothing); + expect(find.text('Hendricks'), findsOneWidget); + }); + + testWidgets('Self-cleans when removed', (WidgetTester tester) async { + await tester.pumpWidget(textBuilderUnderTest); + + valueListenable.value = 'Gilfoyle'; + await tester.pump(); + expect(find.text('Gilfoyle'), findsOneWidget); + + await tester.pumpWidget(const Placeholder()); + + expect(find.text('Gilfoyle'), findsNothing); + expect(valueListenable.hasListeners, false); + }); +} + +class SpyStringValueNotifier extends ValueNotifier { + SpyStringValueNotifier(String initialValue) : super(initialValue); + + /// Override for test visibility only. + @override + bool get hasListeners => super.hasListeners; +}