From 9dd30878d9333702096aee8a54ec0a963e6c9e78 Mon Sep 17 00:00:00 2001 From: Michael Goderbauer Date: Fri, 9 Dec 2022 15:48:00 -0800 Subject: [PATCH] Add LookupBoundary to Material (#116736) --- packages/flutter/lib/src/material/debug.dart | 14 +- .../flutter/lib/src/material/material.dart | 16 ++- .../lib/src/widgets/lookup_boundary.dart | 47 +++++++ .../flutter/test/material/debug_test.dart | 3 +- .../flutter/test/material/material_test.dart | 95 ++++++++++++++ .../test/widgets/lookup_boundary_test.dart | 124 ++++++++++++++++++ 6 files changed, 291 insertions(+), 8 deletions(-) diff --git a/packages/flutter/lib/src/material/debug.dart b/packages/flutter/lib/src/material/debug.dart index 7afa028abe01..dbd43a75ae0c 100644 --- a/packages/flutter/lib/src/material/debug.dart +++ b/packages/flutter/lib/src/material/debug.dart @@ -11,7 +11,8 @@ import 'scaffold.dart' show Scaffold, ScaffoldMessenger; // Examples can assume: // late BuildContext context; -/// Asserts that the given context has a [Material] ancestor. +/// Asserts that the given context has a [Material] ancestor within the closest +/// [LookupBoundary]. /// /// Used by many Material Design widgets to make sure that they are /// only used in contexts where they can print ink onto some material. @@ -32,12 +33,17 @@ import 'scaffold.dart' show Scaffold, ScaffoldMessenger; /// Does nothing if asserts are disabled. Always returns true. bool debugCheckHasMaterial(BuildContext context) { assert(() { - if (context.widget is! Material && context.findAncestorWidgetOfExactType() == null) { + if (LookupBoundary.findAncestorWidgetOfExactType(context) == null) { + final bool hiddenByBoundary = LookupBoundary.debugIsHidingAncestorWidgetOfExactType(context); throw FlutterError.fromParts([ - ErrorSummary('No Material widget found.'), + ErrorSummary('No Material widget found${hiddenByBoundary ? ' within the closest LookupBoundary' : ''}.'), + if (hiddenByBoundary) + ErrorDescription( + 'There is an ancestor Material widget, but it is hidden by a LookupBoundary.' + ), ErrorDescription( '${context.widget.runtimeType} widgets require a Material ' - 'widget ancestor.\n' + 'widget ancestor within the closest LookupBoundary.\n' 'In Material Design, most widgets are conceptually "printed" on ' "a sheet of material. In Flutter's material library, that " 'material is represented by the Material widget. It is the ' diff --git a/packages/flutter/lib/src/material/material.dart b/packages/flutter/lib/src/material/material.dart index 6e2a770dea3a..481e58fd6d58 100644 --- a/packages/flutter/lib/src/material/material.dart +++ b/packages/flutter/lib/src/material/material.dart @@ -343,7 +343,7 @@ class Material extends StatefulWidget { final BorderRadiusGeometry? borderRadius; /// The ink controller from the closest instance of this class that - /// encloses the given context. + /// encloses the given context within the closest [LookupBoundary]. /// /// Typical usage is as follows: /// @@ -358,11 +358,11 @@ class Material extends StatefulWidget { /// * [Material.of], which is similar to this method, but asserts if /// no [Material] ancestor is found. static MaterialInkController? maybeOf(BuildContext context) { - return context.findAncestorRenderObjectOfType<_RenderInkFeatures>(); + return LookupBoundary.findAncestorRenderObjectOfType<_RenderInkFeatures>(context); } /// The ink controller from the closest instance of [Material] that encloses - /// the given context. + /// the given context within the closest [LookupBoundary]. /// /// If no [Material] widget ancestor can be found then this method will assert /// in debug mode, and throw an exception in release mode. @@ -383,6 +383,16 @@ class Material extends StatefulWidget { final MaterialInkController? controller = maybeOf(context); assert(() { if (controller == null) { + if (LookupBoundary.debugIsHidingAncestorRenderObjectOfType<_RenderInkFeatures>(context)) { + throw FlutterError( + 'Material.of() was called with a context that does not have access to a Material widget.\n' + 'The context provided to Material.of() does have a Material widget ancestor, but it is ' + 'hidden by a LookupBoundary. This can happen because you are using a widget that looks ' + 'for a Material ancestor, but no such ancestor exists within the closest LookupBoundary.\n' + 'The context used was:\n' + ' $context', + ); + } throw FlutterError( 'Material.of() was called with a context that does not contain a Material widget.\n' 'No Material widget ancestor could be found starting from the context that was passed to ' diff --git a/packages/flutter/lib/src/widgets/lookup_boundary.dart b/packages/flutter/lib/src/widgets/lookup_boundary.dart index dc903dade470..e839b447c413 100644 --- a/packages/flutter/lib/src/widgets/lookup_boundary.dart +++ b/packages/flutter/lib/src/widgets/lookup_boundary.dart @@ -250,6 +250,53 @@ class LookupBoundary extends InheritedWidget { }); } + /// Returns true if a [LookupBoundary] is hiding the nearest + /// [Widget] of the specified type `T` from the provided [BuildContext]. + /// + /// This method throws when asserts are disabled. + static bool debugIsHidingAncestorWidgetOfExactType(BuildContext context) { + bool? result; + assert(() { + bool hiddenByBoundary = false; + bool ancestorFound = false; + context.visitAncestorElements((Element ancestor) { + if (ancestor.widget.runtimeType == T) { + ancestorFound = true; + return false; + } + hiddenByBoundary = hiddenByBoundary || ancestor.widget.runtimeType == LookupBoundary; + return true; + }); + result = ancestorFound & hiddenByBoundary; + return true; + } ()); + return result!; + } + + /// Returns true if a [LookupBoundary] is hiding the nearest + /// [RenderObjectWidget] with a [RenderObject] of the specified type `T` + /// from the provided [BuildContext]. + /// + /// This method throws when asserts are disabled. + static bool debugIsHidingAncestorRenderObjectOfType(BuildContext context) { + bool? result; + assert(() { + bool hiddenByBoundary = false; + bool ancestorFound = false; + context.visitAncestorElements((Element ancestor) { + if (ancestor is RenderObjectElement && ancestor.renderObject is T) { + ancestorFound = true; + return false; + } + hiddenByBoundary = hiddenByBoundary || ancestor.widget.runtimeType == LookupBoundary; + return true; + }); + result = ancestorFound & hiddenByBoundary; + return true; + } ()); + return result!; + } + @override bool updateShouldNotify(covariant InheritedWidget oldWidget) => false; } diff --git a/packages/flutter/test/material/debug_test.dart b/packages/flutter/test/material/debug_test.dart index 208ee587989f..ce7b4dec45ba 100644 --- a/packages/flutter/test/material/debug_test.dart +++ b/packages/flutter/test/material/debug_test.dart @@ -28,7 +28,8 @@ void main() { error.toStringDeep(), 'FlutterError\n' ' No Material widget found.\n' - ' Chip widgets require a Material widget ancestor.\n' + ' Chip widgets require a Material widget ancestor within the\n' + ' closest LookupBoundary.\n' ' In Material Design, most widgets are conceptually "printed" on a\n' " sheet of material. In Flutter's material library, that material\n" ' is represented by the Material widget. It is the Material widget\n' diff --git a/packages/flutter/test/material/material_test.dart b/packages/flutter/test/material/material_test.dart index ca4a6af5df17..1ad380d440c1 100644 --- a/packages/flutter/test/material/material_test.dart +++ b/packages/flutter/test/material/material_test.dart @@ -1034,6 +1034,101 @@ void main() { materialKey.currentContext!.findRenderObject()!.paint(PaintingContext(ContainerLayer(), Rect.largest), Offset.zero); expect(tracker.paintCount, 2); }); + + group('LookupBoundary', () { + testWidgets('hides Material from Material.maybeOf', (WidgetTester tester) async { + MaterialInkController? material; + + await tester.pumpWidget( + Material( + child: LookupBoundary( + child: Builder( + builder: (BuildContext context) { + material = Material.maybeOf(context); + return Container(); + }, + ), + ), + ), + ); + + expect(material, isNull); + }); + + testWidgets('hides Material from Material.of', (WidgetTester tester) async { + await tester.pumpWidget( + Material( + child: LookupBoundary( + child: Builder( + builder: (BuildContext context) { + Material.of(context); + return Container(); + }, + ), + ), + ), + ); + final Object? exception = tester.takeException(); + expect(exception, isFlutterError); + final FlutterError error = exception! as FlutterError; + + expect( + error.toStringDeep(), + 'FlutterError\n' + ' Material.of() was called with a context that does not have access\n' + ' to a Material widget.\n' + ' The context provided to Material.of() does have a Material widget\n' + ' ancestor, but it is hidden by a LookupBoundary. This can happen\n' + ' because you are using a widget that looks for a Material\n' + ' ancestor, but no such ancestor exists within the closest\n' + ' LookupBoundary.\n' + ' The context used was:\n' + ' Builder(dirty)\n' + ); + }); + + testWidgets('hides Material from debugCheckHasMaterial', (WidgetTester tester) async { + await tester.pumpWidget( + Material( + child: LookupBoundary( + child: Builder( + builder: (BuildContext context) { + debugCheckHasMaterial(context); + return Container(); + }, + ), + ), + ), + ); + final Object? exception = tester.takeException(); + expect(exception, isFlutterError); + final FlutterError error = exception! as FlutterError; + + expect( + error.toStringDeep(), startsWith( + 'FlutterError\n' + ' No Material widget found within the closest LookupBoundary.\n' + ' There is an ancestor Material widget, but it is hidden by a\n' + ' LookupBoundary.\n' + ' Builder widgets require a Material widget ancestor within the\n' + ' closest LookupBoundary.\n' + ' In Material Design, most widgets are conceptually "printed" on a\n' + " sheet of material. In Flutter's material library, that material\n" + ' is represented by the Material widget. It is the Material widget\n' + ' that renders ink splashes, for instance. Because of this, many\n' + ' material library widgets require that there be a Material widget\n' + ' in the tree above them.\n' + ' To introduce a Material widget, you can either directly include\n' + ' one, or use a widget that contains Material itself, such as a\n' + ' Card, Dialog, Drawer, or Scaffold.\n' + ' The specific widget that could not find a Material ancestor was:\n' + ' Builder\n' + ' The ancestors of this widget were:\n' + ' LookupBoundary\n' + ), + ); + }); + }); } class TrackPaintInkFeature extends InkFeature { diff --git a/packages/flutter/test/widgets/lookup_boundary_test.dart b/packages/flutter/test/widgets/lookup_boundary_test.dart index c5b8bfcb5f1d..41d18f3260f5 100644 --- a/packages/flutter/test/widgets/lookup_boundary_test.dart +++ b/packages/flutter/test/widgets/lookup_boundary_test.dart @@ -958,6 +958,130 @@ void main() { }); }); + + group('LookupBoundary.debugIsHidingAncestorWidgetOfExactType', () { + testWidgets('is hiding', (WidgetTester tester) async { + bool? isHidden; + await tester.pumpWidget(Container( + color: Colors.blue, + child: LookupBoundary( + child: Builder( + builder: (BuildContext context) { + isHidden = LookupBoundary.debugIsHidingAncestorWidgetOfExactType(context); + return Container(); + }, + ), + ), + )); + expect(isHidden, isTrue); + }); + + testWidgets('is not hiding entity within boundary', (WidgetTester tester) async { + bool? isHidden; + await tester.pumpWidget(Container( + color: Colors.blue, + child: LookupBoundary( + child: Container( + color: Colors.red, + child: Builder( + builder: (BuildContext context) { + isHidden = LookupBoundary.debugIsHidingAncestorWidgetOfExactType(context); + return Container(); + }, + ), + ), + ), + )); + expect(isHidden, isFalse); + }); + + testWidgets('is not hiding if no boundary exists', (WidgetTester tester) async { + bool? isHidden; + await tester.pumpWidget(Container( + color: Colors.blue, + child: Builder( + builder: (BuildContext context) { + isHidden = LookupBoundary.debugIsHidingAncestorWidgetOfExactType(context); + return Container(); + }, + ), + )); + expect(isHidden, isFalse); + }); + + testWidgets('is not hiding if no boundary and no entity exists', (WidgetTester tester) async { + bool? isHidden; + await tester.pumpWidget(Builder( + builder: (BuildContext context) { + isHidden = LookupBoundary.debugIsHidingAncestorWidgetOfExactType(context); + return Container(); + }, + )); + expect(isHidden, isFalse); + }); + }); + + group('LookupBoundary.debugIsHidingAncestorRenderObjectOfType', () { + testWidgets('is hiding', (WidgetTester tester) async { + bool? isHidden; + await tester.pumpWidget(Padding( + padding: EdgeInsets.zero, + child: LookupBoundary( + child: Builder( + builder: (BuildContext context) { + isHidden = LookupBoundary.debugIsHidingAncestorRenderObjectOfType(context); + return Container(); + }, + ), + ), + )); + expect(isHidden, isTrue); + }); + + testWidgets('is not hiding entity within boundary', (WidgetTester tester) async { + bool? isHidden; + await tester.pumpWidget(Padding( + padding: EdgeInsets.zero, + child: LookupBoundary( + child: Padding( + padding: EdgeInsets.zero, + child: Builder( + builder: (BuildContext context) { + isHidden = LookupBoundary.debugIsHidingAncestorRenderObjectOfType(context); + return Container(); + }, + ), + ), + ), + )); + expect(isHidden, isFalse); + }); + + testWidgets('is not hiding if no boundary exists', (WidgetTester tester) async { + bool? isHidden; + await tester.pumpWidget(Padding( + padding: EdgeInsets.zero, + child: Builder( + builder: (BuildContext context) { + isHidden = LookupBoundary.debugIsHidingAncestorRenderObjectOfType(context); + return Container(); + }, + ), + )); + expect(isHidden, isFalse); + }); + + testWidgets('is not hiding if no boundary and no entity exists', (WidgetTester tester) async { + bool? isHidden; + await tester.pumpWidget(Builder( + builder: (BuildContext context) { + isHidden = LookupBoundary.debugIsHidingAncestorRenderObjectOfType(context); + return Container(); + }, + )); + expect(isHidden, isFalse); + }); + }); } class MyStatefulContainer extends StatefulWidget {