Skip to content

Commit

Permalink
Add LookupBoundary to Material (#116736)
Browse files Browse the repository at this point in the history
  • Loading branch information
goderbauer authored Dec 9, 2022
1 parent 332032d commit 9dd3087
Show file tree
Hide file tree
Showing 6 changed files with 291 additions and 8 deletions.
14 changes: 10 additions & 4 deletions packages/flutter/lib/src/material/debug.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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<Material>() == null) {
if (LookupBoundary.findAncestorWidgetOfExactType<Material>(context) == null) {
final bool hiddenByBoundary = LookupBoundary.debugIsHidingAncestorWidgetOfExactType<Material>(context);
throw FlutterError.fromParts(<DiagnosticsNode>[
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 '
Expand Down
16 changes: 13 additions & 3 deletions packages/flutter/lib/src/material/material.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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:
///
Expand All @@ -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.
Expand All @@ -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 '
Expand Down
47 changes: 47 additions & 0 deletions packages/flutter/lib/src/widgets/lookup_boundary.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<T extends Widget>(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<T extends RenderObject>(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;
}
3 changes: 2 additions & 1 deletion packages/flutter/test/material/debug_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
95 changes: 95 additions & 0 deletions packages/flutter/test/material/material_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
124 changes: 124 additions & 0 deletions packages/flutter/test/widgets/lookup_boundary_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<Container>(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<Container>(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<Container>(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<Container>(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<RenderPadding>(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<RenderPadding>(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<RenderPadding>(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<RenderPadding>(context);
return Container();
},
));
expect(isHidden, isFalse);
});
});
}

class MyStatefulContainer extends StatefulWidget {
Expand Down

0 comments on commit 9dd3087

Please sign in to comment.