diff --git a/examples/api/lib/widgets/shared_app_data/shared_app_data.0.dart b/examples/api/lib/widgets/shared_app_data/shared_app_data.0.dart new file mode 100644 index 00000000000..04a9d3e40c9 --- /dev/null +++ b/examples/api/lib/widgets/shared_app_data/shared_app_data.0.dart @@ -0,0 +1,75 @@ +// 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 SharedAppData + +import 'package:flutter/material.dart'; + +class ShowSharedValue extends StatelessWidget { + const ShowSharedValue({ Key? key, required this.appDataKey }) : super(key: key); + + final String appDataKey; + + @override + Widget build(BuildContext context) { + // The SharedAppData.getValue() call here causes this widget to depend + // on the value of the SharedAppData's 'foo' key. If it's changed, with + // SharedAppData.setValue(), then this widget will be rebuilt. + final String value = SharedAppData.getValue(context, appDataKey, () => 'initial'); + return Text('$appDataKey: $value'); + } +} + +// Demonstrates that changes to the SharedAppData _only_ cause the dependent widgets +// to be rebuilt. In this case that's the ShowSharedValue widget that's +// displaying the value of a key whose value has been updated. +class Home extends StatefulWidget { + const Home({ Key? key }) : super(key: key); + + @override + State createState() => _HomeState(); +} + +class _HomeState extends State { + int _fooVersion = 0; + int _barVersion = 0; + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const ShowSharedValue(appDataKey: 'foo'), + const SizedBox(height: 16), + const ShowSharedValue(appDataKey: 'bar'), + const SizedBox(height: 16), + ElevatedButton( + child: const Text('change foo'), + onPressed: () { + _fooVersion += 1; + // Changing the SharedAppData's value for 'foo' causes the widgets that + // depend on 'foo' to be rebuilt. + SharedAppData.setValue(context, 'foo', 'FOO $_fooVersion'); // note: no setState() + }, + ), + const SizedBox(height: 16), + ElevatedButton( + child: const Text('change bar'), + onPressed: () { + _barVersion += 1; + SharedAppData.setValue(context, 'bar', 'BAR $_barVersion'); // note: no setState() + }, + ), + ], + ), + ), + ); + } +} + +void main() { + runApp(const MaterialApp(home: Home())); +} diff --git a/examples/api/lib/widgets/shared_app_data/shared_app_data.1.dart b/examples/api/lib/widgets/shared_app_data/shared_app_data.1.dart new file mode 100644 index 00000000000..03bb83ba0ab --- /dev/null +++ b/examples/api/lib/widgets/shared_app_data/shared_app_data.1.dart @@ -0,0 +1,66 @@ +// 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 SharedAppData + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +// A single lazily constructed object that's shared with the entire +// application via `SharedObject.of(context)`. The value of the object +// can be changed with `SharedObject.reset(context)`. Resetting the value +// will cause all of the widgets that depend on it to be rebuilt. +class SharedObject { + SharedObject._(); + + static final Object _sharedObjectKey = Object(); + + @override + String toString() => describeIdentity(this); + + static void reset(BuildContext context) { + // Calling SharedAppData.setValue() causes dependent widgets to be rebuilt. + SharedAppData.setValue(context, _sharedObjectKey, SharedObject._()); + } + + static SharedObject of(BuildContext context) { + // If a value for _sharedObjectKey has never been set then the third + // callback parameter is used to generate an initial value. + return SharedAppData.getValue(context, _sharedObjectKey, () => SharedObject._()); + } +} + +// An example of a widget which depends on the SharedObject's value, +// which might be provided - along with SharedObject - in a Dart package. +class CustomWidget extends StatelessWidget { + const CustomWidget({ Key? key }) : super(key: key); + + @override + Widget build(BuildContext context) { + // Will be rebuilt if the shared object's value is changed. + return ElevatedButton( + child: Text('Replace ${SharedObject.of(context)}'), + onPressed: () { + SharedObject.reset(context); + }, + ); + } +} + +class Home extends StatelessWidget { + const Home({ Key? key }) : super(key: key); + + @override + Widget build(BuildContext context) { + return const Scaffold( + body: Center( + child: CustomWidget() + ), + ); + } +} + +void main() { + runApp(const MaterialApp(home: Home())); +} diff --git a/packages/flutter/lib/src/widgets/app.dart b/packages/flutter/lib/src/widgets/app.dart index 16814929f15..ab4fb47de7c 100644 --- a/packages/flutter/lib/src/widgets/app.dart +++ b/packages/flutter/lib/src/widgets/app.dart @@ -24,6 +24,7 @@ import 'restoration.dart'; import 'router.dart'; import 'scrollable.dart'; import 'semantics_debugger.dart'; +import 'shared_app_data.dart'; import 'shortcuts.dart'; import 'text.dart'; import 'title.dart'; @@ -1664,17 +1665,19 @@ class _WidgetsAppState extends State with WidgetsBindingObserver { return RootRestorationScope( restorationId: widget.restorationScopeId, - child: Shortcuts( - debugLabel: '', - shortcuts: widget.shortcuts ?? WidgetsApp.defaultShortcuts, - // DefaultTextEditingShortcuts is nested inside Shortcuts so that it can - // fall through to the defaultShortcuts. - child: DefaultTextEditingShortcuts( - child: Actions( - actions: widget.actions ?? WidgetsApp.defaultActions, - child: FocusTraversalGroup( - policy: ReadingOrderTraversalPolicy(), - child: child, + child: SharedAppData( + child: Shortcuts( + debugLabel: '', + shortcuts: widget.shortcuts ?? WidgetsApp.defaultShortcuts, + // DefaultTextEditingShortcuts is nested inside Shortcuts so that it can + // fall through to the defaultShortcuts. + child: DefaultTextEditingShortcuts( + child: Actions( + actions: widget.actions ?? WidgetsApp.defaultActions, + child: FocusTraversalGroup( + policy: ReadingOrderTraversalPolicy(), + child: child, + ), ), ), ), diff --git a/packages/flutter/lib/src/widgets/shared_app_data.dart b/packages/flutter/lib/src/widgets/shared_app_data.dart new file mode 100644 index 00000000000..58666cbcbe0 --- /dev/null +++ b/packages/flutter/lib/src/widgets/shared_app_data.dart @@ -0,0 +1,201 @@ +// 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/foundation.dart'; + +import 'framework.dart'; +import 'inherited_model.dart'; + +/// The type of the [SharedAppData.getValue] `init` parameter. +/// +/// This callback is used to lazily create the initial value for +/// a [SharedAppData] keyword. +typedef SharedAppDataInitCallback = T Function(); + +/// Enables sharing key/value data with its `child` and all of the +/// child's descendants. +/// +/// - `SharedAppData.getValue(context, key, initCallback)` creates a dependency +/// on the key and returns the value for the key from the shared data table. +/// If no value exists for key then the initCallback is used to create +/// the initial value. +/// +/// - `SharedAppData.setValue(context, key, value)` changes the value of an entry +/// in the shared data table and forces widgets that depend on that entry +/// to be rebuilt. +/// +/// A widget whose build method uses SharedAppData.getValue(context, +/// keyword, initCallback) creates a dependency on the SharedAppData. When +/// the value of keyword changes with SharedAppData.setValue(), the widget +/// will be rebuilt. The values managed by the SharedAppData are expected +/// to be immutable: intrinsic changes to values will not cause +/// dependent widgets to be rebuilt. +/// +/// An instance of this widget is created automatically by [WidgetsApp]. +/// +/// There are many ways to share data with a widget subtree. This +/// class is based on [InheritedModel], which is an [InheritedWidget]. +/// It's intended to be used by packages that need to share a modest +/// number of values among their own components. +/// +/// SharedAppData is not intended to be a substitute for Provider or any of +/// the other general purpose application state systems. SharedAppData is +/// for situations where a package's custom widgets need to share one +/// or a handful of immutable data objects that can be lazily +/// initialized. It exists so that packages like that can deliver +/// custom widgets without requiring the developer to add a +/// package-specific umbrella widget to their application. +/// +/// A good way to create an SharedAppData key that avoids potential +/// collisions with other packages is to use a static `Object()` value. +/// The `SharedObject` example below does this. +/// +/// {@tool dartpad} +/// The following sample demonstrates using the automatically created +/// `SharedAppData`. Button presses cause changes to the values for keys +/// 'foo', and 'bar', and those changes only cause the widgets that +/// depend on those keys to be rebuilt. +/// +/// ** See code in examples/api/lib/widgets/shared_app_data/shared_app_data.0.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// The following sample demonstrates how a single lazily computed +/// value could be shared within an app. A Flutter package that +/// provided custom widgets might use this approach to share a (possibly +/// private) value with instances of those widgets. +/// +/// ** See code in examples/api/lib/widgets/shared_app_data/shared_app_data.1.dart ** +/// {@end-tool} +class SharedAppData extends StatefulWidget { + /// Creates a widget based on [InheritedModel] that supports build + /// dependencies qualified by keywords. Descendant widgets create + /// such dependencies with [SharedAppData.getValue] and they trigger + /// rebuilds with [SharedAppData.setValue]. + /// + /// This widget is automatically created by the [WidgetsApp]. + const SharedAppData({ Key? key, required this.child }) : super(key: key); + + /// The widget below this widget in the tree. + /// + /// {@macro flutter.widgets.ProxyWidget.child} + final Widget child; + + @override + State createState() => _SharedAppDataState(); + + /// Returns the app model's value for `key` and ensures that each + /// time the value of `key` is changed with [SharedAppData.setValue], the + /// specified context will be rebuilt. + /// + /// If no value for `key` exists then the `init` callback is used to + /// generate an initial value. The callback is expected to return + /// an immutable value because intrinsic changes to the value will + /// not cause dependent widgets to be rebuilt. + /// + /// A widget that depends on the app model's value for `key` should use + /// this method in their `build` methods to ensure that they are rebuilt + /// if the value changes. + /// + /// The type parameter `K` is the type of the keyword and `V` + /// is the type of the value. + static V getValue(BuildContext context, K key, SharedAppDataInitCallback init) { + final _SharedAppModel? model = InheritedModel.inheritFrom<_SharedAppModel>(context, aspect: key); + assert(_debugHasSharedAppData(model, context, 'getValue')); + return model!.sharedAppDataState.getValue(key, init); + } + + /// Changes the app model's `value` for `key` and rebuilds any widgets + /// that have created a dependency on `key` with [SharedAppData.getValue]. + /// + /// If `value` is `==` to the current value of `key` then nothing + /// is rebuilt. + /// + /// The `value` is expected to be immutable because intrinsic + /// changes to the value will not cause dependent widgets to be + /// rebuilt. + /// + /// Unlike [SharedAppData.getValue], this method does _not_ create a dependency + /// between `context` and `key`. + /// + /// The type parameter `K` is the type of the value's keyword and `V` + /// is the type of the value. + static void setValue(BuildContext context, K key, V value) { + final _SharedAppModel? model = context.getElementForInheritedWidgetOfExactType<_SharedAppModel>()?.widget as _SharedAppModel?; + assert(_debugHasSharedAppData(model, context, 'setValue')); + model!.sharedAppDataState.setValue(key, value); + } + + static bool _debugHasSharedAppData(_SharedAppModel? model, BuildContext context, String methodName) { + assert(() { + if (model == null) { + throw FlutterError.fromParts( + [ + ErrorSummary('No SharedAppData widget found.'), + ErrorDescription('SharedAppData.$methodName requires an SharedAppData widget ancestor.\n'), + context.describeWidget('The specific widget that could not find an SharedAppData ancestor was'), + context.describeOwnershipChain('The ownership chain for the affected widget is'), + ErrorHint( + 'Typically, the SharedAppData widget is introduced by the MaterialApp ' + 'or WidgetsApp widget at the top of your application widget tree. It ' + 'provides a key/value map of data that is shared with the entire ' + 'application.', + ), + ], + ); + } + return true; + }()); + return true; + } +} + +class _SharedAppDataState extends State { + late Map data = {}; + + @override + Widget build(BuildContext context) { + return _SharedAppModel(sharedAppDataState: this, child: widget.child); + } + + V getValue(K key, SharedAppDataInitCallback init) { + data[key] ??= init(); + return data[key] as V; + } + + void setValue(K key, V value) { + if (data[key] != value) { + setState(() { + data = Map.from(data); + data[key] = value; + }); + } + } +} + +class _SharedAppModel extends InheritedModel { + _SharedAppModel({ + Key? key, + required this.sharedAppDataState, + required Widget child + }) : data = sharedAppDataState.data, super(key: key, child: child); + + final _SharedAppDataState sharedAppDataState; + final Map data; + + @override + bool updateShouldNotify(_SharedAppModel old) { + return data != old.data; + } + + @override + bool updateShouldNotifyDependent(_SharedAppModel old, Set keys) { + for (final Object key in keys) { + if (data[key] != old.data[key]) { + return true; + } + } + return false; + } +} diff --git a/packages/flutter/lib/widgets.dart b/packages/flutter/lib/widgets.dart index f3c0a02ffd2..c424d6e9e30 100644 --- a/packages/flutter/lib/widgets.dart +++ b/packages/flutter/lib/widgets.dart @@ -107,6 +107,7 @@ export 'src/widgets/scroll_view.dart'; export 'src/widgets/scrollable.dart'; export 'src/widgets/scrollbar.dart'; export 'src/widgets/semantics_debugger.dart'; +export 'src/widgets/shared_app_data.dart'; export 'src/widgets/shortcuts.dart'; export 'src/widgets/single_child_scroll_view.dart'; export 'src/widgets/size_changed_layout_notifier.dart'; diff --git a/packages/flutter/test/material/debug_test.dart b/packages/flutter/test/material/debug_test.dart index f0095bed8f6..e3191324633 100644 --- a/packages/flutter/test/material/debug_test.dart +++ b/packages/flutter/test/material/debug_test.dart @@ -200,6 +200,8 @@ void main() { ' _FocusMarker\n' ' Focus\n' ' Shortcuts\n' + ' _SharedAppModel\n' + ' SharedAppData\n' ' UnmanagedRestorationScope\n' ' RestorationScope\n' ' UnmanagedRestorationScope\n' diff --git a/packages/flutter/test/widgets/shared_app_data_test.dart b/packages/flutter/test/widgets/shared_app_data_test.dart new file mode 100644 index 00000000000..6d57734d4b1 --- /dev/null +++ b/packages/flutter/test/widgets/shared_app_data_test.dart @@ -0,0 +1,205 @@ +// 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/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('SharedAppData basics', (WidgetTester tester) async { + int columnBuildCount = 0; + int child1BuildCount = 0; + int child2BuildCount = 0; + late void Function(BuildContext context) setSharedAppDataValue; + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: SharedAppData( + child: Builder( + builder: (BuildContext context) { + columnBuildCount += 1; + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + setSharedAppDataValue.call(context); + }, + child: Column( + children: [ + Builder( + builder: (BuildContext context) { + child1BuildCount += 1; + return Text(SharedAppData.getValue(context, 'child1Text', () => 'null')); + }, + ), + Builder( + builder: (BuildContext context) { + child2BuildCount += 1; + return Text(SharedAppData.getValue(context, 'child2Text', () => 'null')); + } + ), + ], + ), + ); + }, + ), + ), + ), + ); + + expect(columnBuildCount, 1); + expect(child1BuildCount, 1); + expect(child2BuildCount, 1); + expect(find.text('null').evaluate().length, 2); + + // SharedAppData.setValue(context, 'child1Text', 'child1') + // causes the first Text widget to be rebuilt with its text to be + // set to 'child1'. Nothing else is rebuilt. + setSharedAppDataValue = (BuildContext context) { + SharedAppData.setValue(context, 'child1Text', 'child1'); + }; + await tester.tap(find.byType(GestureDetector)); + await tester.pump(); + expect(columnBuildCount, 1); + expect(child1BuildCount, 2); + expect(child2BuildCount, 1); + expect(find.text('child1'), findsOneWidget); + expect(find.text('null'), findsOneWidget); + + // SharedAppData.setValue(context, 'child2Text', 'child1') + // causes the second Text widget to be rebuilt with its text to be + // set to 'child2'. Nothing else is rebuilt. + setSharedAppDataValue = (BuildContext context) { + SharedAppData.setValue(context, 'child2Text', 'child2'); + }; + await tester.tap(find.byType(GestureDetector)); + await tester.pump(); + expect(columnBuildCount, 1); + expect(child1BuildCount, 2); + expect(child2BuildCount, 2); + expect(find.text('child1'), findsOneWidget); + expect(find.text('child2'), findsOneWidget); + + // Resetting a key's value to the same value does not + // cause any widgets to be rebuilt. + setSharedAppDataValue = (BuildContext context) { + SharedAppData.setValue(context, 'child1Text', 'child1'); + SharedAppData.setValue(context, 'child2Text', 'child2'); + }; + await tester.tap(find.byType(GestureDetector)); + await tester.pump(); + expect(columnBuildCount, 1); + expect(child1BuildCount, 2); + expect(child2BuildCount, 2); + + // More of the same, resetting the values to null.. + + setSharedAppDataValue = (BuildContext context) { + SharedAppData.setValue(context, 'child1Text', 'null'); + }; + await tester.tap(find.byType(GestureDetector)); + await tester.pump(); + expect(columnBuildCount, 1); + expect(child1BuildCount, 3); + expect(child2BuildCount, 2); + expect(find.text('null'), findsOneWidget); + expect(find.text('child2'), findsOneWidget); + + setSharedAppDataValue = (BuildContext context) { + SharedAppData.setValue(context, 'child2Text', 'null'); + }; + await tester.tap(find.byType(GestureDetector)); + await tester.pump(); + expect(columnBuildCount, 1); + expect(child1BuildCount, 3); + expect(child2BuildCount, 3); + expect(find.text('null').evaluate().length, 2); + }); + + testWidgets('WidgetsApp SharedAppData ', (WidgetTester tester) async { + int parentBuildCount = 0; + int childBuildCount = 0; + + await tester.pumpWidget( + WidgetsApp( + color: const Color(0xff00ff00), + builder: (BuildContext context, Widget? child) { + parentBuildCount += 1; + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + SharedAppData.setValue(context, 'childText', 'child'); + }, + child: Center( + child: Builder( + builder: (BuildContext context) { + childBuildCount += 1; + return Text(SharedAppData.getValue(context, 'childText', () => 'null')); + }, + ), + ), + ); + }, + ), + ); + + expect(find.text('null'), findsOneWidget); + expect(parentBuildCount, 1); + expect(childBuildCount, 1); + + await tester.tap(find.byType(GestureDetector)); + await tester.pump(); + expect(parentBuildCount, 1); + expect(childBuildCount, 2); + expect(find.text('child'), findsOneWidget); + }); + + testWidgets('WidgetsApp SharedAppData Shadowing', (WidgetTester tester) async { + int innerTapCount = 0; + int outerTapCount = 0; + + await tester.pumpWidget( + WidgetsApp( + color: const Color(0xff00ff00), + builder: (BuildContext context, Widget? child) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + outerTapCount += 1; + SharedAppData.setValue(context, 'childText', 'child'); + }, + child: Center( + child: SharedAppData( + child: Builder( + builder: (BuildContext context) { + return GestureDetector( + onTap: () { + innerTapCount += 1; + SharedAppData.setValue(context, 'childText', 'child'); + }, + child: Text(SharedAppData.getValue(context, 'childText', () => 'null')), + ); + }, + ), + ), + ), + ); + }, + ), + ); + + expect(find.text('null'), findsOneWidget); + + await tester.tapAt(const Offset(10, 10)); + await tester.pump(); + expect(outerTapCount, 1); + expect(innerTapCount, 0); + expect(find.text('null'), findsOneWidget); + + await tester.tap(find.text('null')); + await tester.pump(); + expect(outerTapCount, 1); + expect(innerTapCount, 1); + expect(find.text('child'), findsOneWidget); + }); +}