From 671d8eaf715e0dfb081a84ed92c22c9944b121af Mon Sep 17 00:00:00 2001 From: Michael Goderbauer Date: Fri, 26 Jan 2024 15:05:53 -0800 Subject: [PATCH] Relands "Add runWidget to bootstrap a widget tree without a default View" (#142344) Reverts flutter/flutter#142339 In the original change one of the tests included the same view twice which resulted in a different failure than the expected one. The second commit contains the fix for this. I don't understand how this wasn't caught presubmit on CI. --- packages/flutter/lib/src/widgets/binding.dart | 100 +++++++++- .../flutter/lib/src/widgets/framework.dart | 18 +- .../test/rendering/view_constraints_test.dart | 12 +- packages/flutter/test/widgets/debug_test.dart | 12 +- .../test/widgets/media_query_test.dart | 30 ++- .../test/widgets/multi_view_binding_test.dart | 50 ++++- .../widgets/multi_view_tree_updates_test.dart | 36 ++-- .../flutter/test/widgets/tree_shape_test.dart | 186 +++++++++--------- packages/flutter/test/widgets/view_test.dart | 60 +++--- .../flutter_test/lib/src/widget_tester.dart | 10 +- .../test/multi_view_accessibility_test.dart | 5 +- .../test/multi_view_controller_test.dart | 5 +- .../flutter_test/test/widget_tester_test.dart | 52 +++++ 13 files changed, 367 insertions(+), 209 deletions(-) diff --git a/packages/flutter/lib/src/widgets/binding.dart b/packages/flutter/lib/src/widgets/binding.dart index 44a6b3189e..f58b3ad069 100644 --- a/packages/flutter/lib/src/widgets/binding.dart +++ b/packages/flutter/lib/src/widgets/binding.dart @@ -24,6 +24,10 @@ import 'widget_inspector.dart'; export 'dart:ui' show AppLifecycleState, Locale; +// Examples can assume: +// late FlutterView myFlutterView; +// class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) => const Placeholder(); } + /// Interface for classes that register with the Widgets layer binding. /// /// This can be used by any class, not just widgets. It provides an interface @@ -1152,14 +1156,20 @@ mixin WidgetsBinding on BindingBase, ServicesBinding, SchedulerBinding, GestureB } } -/// Inflate the given widget and attach it to the screen. +/// Inflate the given widget and attach it to the view. +/// +// TODO(goderbauer): Update the paragraph below to include the Window widget once that exists. +/// The [runApp] method renders the provided `app` widget into the +/// [PlatformDispatcher.implicitView] by wrapping it in a [View] widget, which +/// will bootstrap the render tree for the app. Apps that want to control which +/// [FlutterView] they render into can use [runWidget] instead. /// /// The widget is given constraints during layout that force it to fill the -/// entire screen. If you wish to align your widget to one side of the screen +/// entire view. If you wish to align your widget to one side of the view /// (e.g., the top), consider using the [Align] widget. If you wish to center /// your widget, you can also use the [Center] widget. /// -/// Calling [runApp] again will detach the previous root widget from the screen +/// Calling [runApp] again will detach the previous root widget from the view /// and attach the given widget in its place. The new widget tree is compared /// against the previous widget tree and any differences are applied to the /// underlying render tree, similar to what happens when a [StatefulWidget] @@ -1167,6 +1177,7 @@ mixin WidgetsBinding on BindingBase, ServicesBinding, SchedulerBinding, GestureB /// /// Initializes the binding using [WidgetsFlutterBinding] if necessary. /// +/// {@template flutter.widgets.runApp.shutdown} /// ## Application shutdown /// /// This widget tree is not torn down when the application shuts down, because @@ -1178,29 +1189,32 @@ mixin WidgetsBinding on BindingBase, ServicesBinding, SchedulerBinding, GestureB /// Applications are responsible for ensuring that they are well-behaved /// even in the face of a rapid unscheduled termination. /// +/// To listen for platform shutdown messages (and other lifecycle changes), +/// consider the [AppLifecycleListener] API. +/// {@endtemplate} +/// /// To artificially cause the entire widget tree to be disposed, consider /// calling [runApp] with a widget such as [SizedBox.shrink]. /// -/// To listen for platform shutdown messages (and other lifecycle changes), -/// consider the [AppLifecycleListener] API. -/// +/// {@template flutter.widgets.runApp.dismissal} /// ## Dismissing Flutter UI via platform native methods /// -/// {@template flutter.widgets.runApp.dismissal} /// An application may have both Flutter and non-Flutter UI in it. If the /// application calls non-Flutter methods to remove Flutter based UI such as /// platform native API to manipulate the platform native navigation stack, /// the framework does not know if the developer intends to eagerly free /// resources or not. The widget tree remains mounted and ready to render /// as soon as it is displayed again. +/// {@endtemplate} /// /// To release resources more eagerly, establish a [platform channel](https://flutter.dev/platform-channels/) /// and use it to call [runApp] with a widget such as [SizedBox.shrink] when /// the framework should dispose of the active widget tree. -/// {@endtemplate} /// /// See also: /// +/// * [runWidget], which bootstraps a widget tree without assuming the +/// [FlutterView] into which it will be rendered. /// * [WidgetsBinding.attachRootWidget], which creates the root widget for the /// widget hierarchy. /// * [RenderObjectToWidgetAdapter.attachToRenderTree], which creates the root @@ -1209,9 +1223,75 @@ mixin WidgetsBinding on BindingBase, ServicesBinding, SchedulerBinding, GestureB /// ensure the widget, element, and render trees are all built. void runApp(Widget app) { final WidgetsBinding binding = WidgetsFlutterBinding.ensureInitialized(); - assert(binding.debugCheckZone('runApp')); + _runWidget(binding.wrapWithDefaultView(app), binding, 'runApp'); +} + +/// Inflate the given widget and bootstrap the widget tree. +/// +// TODO(goderbauer): Update the paragraph below to include the Window widget once that exists. +/// Unlike [runApp], this method does not define a [FlutterView] into which the +/// provided `app` widget is rendered into. It is up to the caller to include at +/// least one [View] widget in the provided `app` widget that will bootstrap a +/// render tree and define the [FlutterView] into which content is rendered. +/// [RenderObjectWidget]s without an ancestor [View] widget will result in an +/// exception. Apps that want to render into the default view without dealing +/// with view management should consider calling [runApp] instead. +/// +/// {@tool snippet} +/// The sample shows how to utilize [runWidget] to specify the [FlutterView] +/// into which the `MyApp` widget will be drawn: +/// +/// ```dart +/// runWidget( +/// View( +/// view: myFlutterView, +/// child: const MyApp(), +/// ), +/// ); +/// ``` +/// {@end-tool} +/// +/// Calling [runWidget] again will detach the previous root widget and attach +/// the given widget in its place. The new widget tree is compared against the +/// previous widget tree and any differences are applied to the underlying +/// render tree, similar to what happens when a [StatefulWidget] rebuilds after +/// calling [State.setState]. +/// +/// Initializes the binding using [WidgetsFlutterBinding] if necessary. +/// +/// {@macro flutter.widgets.runApp.shutdown} +/// +/// To artificially cause the entire widget tree to be disposed, consider +/// calling [runWidget] with a [ViewCollection] that does not specify any +/// [ViewCollection.views]. +/// +/// ## Dismissing Flutter UI via platform native methods +/// +/// {@macro flutter.widgets.runApp.dismissal} +/// +/// To release resources more eagerly, establish a [platform channel](https://flutter.dev/platform-channels/) +/// and use it to remove the [View] whose widget resources should be released +/// from the `app` widget tree provided to [runWidget]. +/// +/// See also: +/// +/// * [runApp], which bootstraps a widget tree and renders it into a default +/// [FlutterView]. +/// * [WidgetsBinding.attachRootWidget], which creates the root widget for the +/// widget hierarchy. +/// * [RenderObjectToWidgetAdapter.attachToRenderTree], which creates the root +/// element for the element hierarchy. +/// * [WidgetsBinding.handleBeginFrame], which pumps the widget pipeline to +/// ensure the widget, element, and render trees are all built. +void runWidget(Widget app) { + final WidgetsBinding binding = WidgetsFlutterBinding.ensureInitialized(); + _runWidget(app, binding, 'runWidget'); +} + +void _runWidget(Widget app, WidgetsBinding binding, String debugEntryPoint) { + assert(binding.debugCheckZone(debugEntryPoint)); binding - ..scheduleAttachRootWidget(binding.wrapWithDefaultView(app)) + ..scheduleAttachRootWidget(app) ..scheduleWarmUpFrame(); } diff --git a/packages/flutter/lib/src/widgets/framework.dart b/packages/flutter/lib/src/widgets/framework.dart index 2e2c711310..8b706a638f 100644 --- a/packages/flutter/lib/src/widgets/framework.dart +++ b/packages/flutter/lib/src/widgets/framework.dart @@ -1325,10 +1325,11 @@ abstract class State with Diagnosticable { /// To listen for platform shutdown messages (and other lifecycle changes), /// consider the [AppLifecycleListener] API. /// - /// ### Dismissing Flutter UI via platform native methods - /// /// {@macro flutter.widgets.runApp.dismissal} /// + /// See the method used to bootstrap the app (e.g. [runApp] or [runWidget]) + /// for suggestions on how to release resources more eagerly. + /// /// See also: /// /// * [deactivate], which is called prior to [dispose]. @@ -6970,9 +6971,16 @@ abstract class RenderTreeRootElement extends RenderObjectElement { 'however, expects that a child will be attached.', ), ErrorHint( - 'Try moving the subtree that contains the ${toStringShort()} widget into the ' - 'view property of a ViewAnchor widget or to the root of the widget tree, where ' - 'it is not expected to attach its RenderObject to a parent.', + 'Try moving the subtree that contains the ${toStringShort()} widget ' + 'to a location where it is not expected to attach its RenderObject ' + 'to a parent. This could mean moving the subtree into the view ' + 'property of a "ViewAnchor" widget or - if the subtree is the root of ' + 'your widget tree - passing it to "runWidget" instead of "runApp".', + ), + ErrorHint( + 'If you are seeing this error in a test and the subtree containing ' + 'the ${toStringShort()} widget is passed to "WidgetTester.pumpWidget", ' + 'consider setting the "wrapWithView" parameter of that method to false.' ), ], ); diff --git a/packages/flutter/test/rendering/view_constraints_test.dart b/packages/flutter/test/rendering/view_constraints_test.dart index b5812819a7..ff858de894 100644 --- a/packages/flutter/test/rendering/view_constraints_test.dart +++ b/packages/flutter/test/rendering/view_constraints_test.dart @@ -13,9 +13,9 @@ void main() { ..physicalConstraints = ViewConstraints.tight(const Size(1008.0, 2198.0)) ..devicePixelRatio = 1.912500023841858; - await pumpWidgetWithoutViewWrapper( - tester: tester, - widget: View( + await tester.pumpWidget( + wrapWithView: false, + View( view: view, child: const SizedBox(), ), @@ -35,9 +35,3 @@ class FlutterViewSpy extends TestFlutterView { sizes.add(size); } } - -Future pumpWidgetWithoutViewWrapper({required WidgetTester tester, required Widget widget}) { - tester.binding.attachRootWidget(widget); - tester.binding.scheduleFrame(); - return tester.binding.pump(); -} diff --git a/packages/flutter/test/widgets/debug_test.dart b/packages/flutter/test/widgets/debug_test.dart index 8ae59d0aa0..7df9c7a3b5 100644 --- a/packages/flutter/test/widgets/debug_test.dart +++ b/packages/flutter/test/widgets/debug_test.dart @@ -98,9 +98,9 @@ void main() { testWidgets('debugCheckHasMediaQuery control test', (WidgetTester tester) async { // Cannot use tester.pumpWidget here because it wraps the widget in a View, // which introduces a MediaQuery ancestor. - await pumpWidgetWithoutViewWrapper( - tester: tester, - widget: Builder( + await tester.pumpWidget( + wrapWithView: false, + Builder( builder: (BuildContext context) { late FlutterError error; try { @@ -343,9 +343,3 @@ void main() { expect(renderObject.debugLayer?.debugCreator, isNotNull); }); } - -Future pumpWidgetWithoutViewWrapper({required WidgetTester tester, required Widget widget}) { - tester.binding.attachRootWidget(widget); - tester.binding.scheduleFrame(); - return tester.binding.pump(); -} diff --git a/packages/flutter/test/widgets/media_query_test.dart b/packages/flutter/test/widgets/media_query_test.dart index 1c6315378f..e33975262b 100644 --- a/packages/flutter/test/widgets/media_query_test.dart +++ b/packages/flutter/test/widgets/media_query_test.dart @@ -47,9 +47,9 @@ void main() { late final FlutterError error; // Cannot use tester.pumpWidget here because it wraps the widget in a View, // which introduces a MediaQuery ancestor. - await pumpWidgetWithoutViewWrapper( - tester: tester, - widget: Builder( + await tester.pumpWidget( + wrapWithView: false, + Builder( builder: (BuildContext context) { try { MediaQuery.of(context); @@ -111,9 +111,9 @@ void main() { bool tested = false; // Cannot use tester.pumpWidget here because it wraps the widget in a View, // which introduces a MediaQuery ancestor. - await pumpWidgetWithoutViewWrapper( - tester: tester, - widget: Builder( + await tester.pumpWidget( + wrapWithView: false, + Builder( builder: (BuildContext context) { final MediaQueryData? data = MediaQuery.maybeOf(context); expect(data, isNull); @@ -287,9 +287,9 @@ void main() { late MediaQueryData data; MediaQueryData? outerData; - await pumpWidgetWithoutViewWrapper( - tester: tester, - widget: Builder( + await tester.pumpWidget( + wrapWithView: false, + Builder( builder: (BuildContext context) { outerData = MediaQuery.maybeOf(context); return MediaQuery.fromView( @@ -342,9 +342,9 @@ void main() { late MediaQueryData data; MediaQueryData? outerData; int rebuildCount = 0; - await pumpWidgetWithoutViewWrapper( - tester: tester, - widget: Builder( + await tester.pumpWidget( + wrapWithView: false, + Builder( builder: (BuildContext context) { outerData = MediaQuery.maybeOf(context); return MediaQuery.fromView( @@ -1531,9 +1531,3 @@ void main() { ] )); } - -Future pumpWidgetWithoutViewWrapper({required WidgetTester tester, required Widget widget}) { - tester.binding.attachRootWidget(widget); - tester.binding.scheduleFrame(); - return tester.binding.pump(); -} diff --git a/packages/flutter/test/widgets/multi_view_binding_test.dart b/packages/flutter/test/widgets/multi_view_binding_test.dart index 6c7c1d09de..25acc05e08 100644 --- a/packages/flutter/test/widgets/multi_view_binding_test.dart +++ b/packages/flutter/test/widgets/multi_view_binding_test.dart @@ -26,7 +26,7 @@ void main() { final RootWidget rootWidget = RootWidget( child: View( - view: tester.view, + view: FakeFlutterView(tester.view), child: const ColoredBox(color: Colors.orange), ), ); @@ -36,4 +36,52 @@ void main() { expect(tester.binding.rootElement!.widget, equals(rootWidget)); expect(tester.element(find.byType(ColoredBox)).owner, equals(tester.binding.buildOwner)); }); + + testWidgets('runApp throws if given a View', (WidgetTester tester) async { + runApp( + View( + view: FakeFlutterView(tester.view), + child: const SizedBox.shrink(), + ), + ); + expect( + tester.takeException(), + isFlutterError.having( + (FlutterError e) => e.message, + 'message', + contains('passing it to "runWidget" instead of "runApp"'), + ), + ); + }); + + testWidgets('runWidget throws if not given a View', (WidgetTester tester) async { + runWidget(const SizedBox.shrink()); + expect( + tester.takeException(), + isFlutterError.having( + (FlutterError e) => e.message, + 'message', + contains('Try wrapping your widget in a View widget'), + ), + ); + }); + + testWidgets('runWidget does not throw if given a View', (WidgetTester tester) async { + runWidget( + View( + view: FakeFlutterView(tester.view), + child: const SizedBox.shrink(), + ), + ); + expect(find.byType(View), findsOne); + }); + + testWidgets('can call runWidget with an empty ViewCollection', (WidgetTester tester) async { + runWidget(const ViewCollection(views: [])); + expect(find.byType(ViewCollection), findsOne); + }); +} + +class FakeFlutterView extends TestFlutterView { + FakeFlutterView(TestFlutterView view) : super(view: view, display: view.display, platformDispatcher: view.platformDispatcher); } diff --git a/packages/flutter/test/widgets/multi_view_tree_updates_test.dart b/packages/flutter/test/widgets/multi_view_tree_updates_test.dart index a30b350213..210346ec48 100644 --- a/packages/flutter/test/widgets/multi_view_tree_updates_test.dart +++ b/packages/flutter/test/widgets/multi_view_tree_updates_test.dart @@ -15,9 +15,9 @@ void main() { child: const TestWidget(), ); - await pumpWidgetWithoutViewWrapper( - tester: tester, - widget: widget, + await tester.pumpWidget( + wrapWithView: false, + widget, ); expect(find.text('Hello'), findsOneWidget); @@ -29,9 +29,9 @@ void main() { expect(find.text('World'), findsOneWidget); expect(tester.renderObject(find.byType(Text)).text.toPlainText(), 'World'); - await pumpWidgetWithoutViewWrapper( - tester: tester, - widget: ViewCollection( + await tester.pumpWidget( + wrapWithView: false, + ViewCollection( views: [widget], ), ); @@ -40,9 +40,9 @@ void main() { expect(tester.renderObject(find.byType(Text)).text.toPlainText(), 'World'); tester.state(find.byType(TestWidget)).text = 'FooBar'; - await pumpWidgetWithoutViewWrapper( - tester: tester, - widget: widget, + await tester.pumpWidget( + wrapWithView: false, + widget, ); expect(find.text('World'), findsNothing); expect(find.text('FooBar'), findsOneWidget); @@ -65,9 +65,9 @@ void main() { child: TestWidget(key: key2), ); - await pumpWidgetWithoutViewWrapper( - tester: tester, - widget: ViewCollection( + await tester.pumpWidget( + wrapWithView: false, + ViewCollection( views: [view1, view2], ), ); @@ -91,9 +91,9 @@ void main() { expect(renderParagraphTexts(), ['Guten', 'Abend']); tester.state(find.byKey(key2)).text = 'Morgen'; - await pumpWidgetWithoutViewWrapper( - tester: tester, - widget: ViewCollection( + await tester.pumpWidget( + wrapWithView: false, + ViewCollection( views: [view1, ViewCollection(views: [view2])], ), ); @@ -202,9 +202,3 @@ class TestWidgetState extends State { return Text(text, textDirection: TextDirection.ltr); } } - -Future pumpWidgetWithoutViewWrapper({required WidgetTester tester, required Widget widget}) { - tester.binding.attachRootWidget(widget); - tester.binding.scheduleFrame(); - return tester.binding.pump(); -} diff --git a/packages/flutter/test/widgets/tree_shape_test.dart b/packages/flutter/test/widgets/tree_shape_test.dart index 7c9576d7ee..86d977ddcb 100644 --- a/packages/flutter/test/widgets/tree_shape_test.dart +++ b/packages/flutter/test/widgets/tree_shape_test.dart @@ -13,9 +13,9 @@ import 'multi_view_testing.dart'; void main() { testWidgets('Providing a RenderObjectWidget directly to the RootWidget fails', (WidgetTester tester) async { // No render tree exists to attach the RenderObjectWidget to. - await pumpWidgetWithoutViewWrapper( - tester: tester, - widget: const ColoredBox(color: Colors.red), + await tester.pumpWidget( + wrapWithView: false, + const ColoredBox(color: Colors.red), ); expect(tester.takeException(), isFlutterError.having( @@ -31,18 +31,18 @@ void main() { color: Colors.red, ); - await pumpWidgetWithoutViewWrapper( - tester: tester, - widget: View( + await tester.pumpWidget( + wrapWithView: false, + View( view: tester.view, child: globalKeyedWidget, ), ); expect(tester.takeException(), isNull); - await pumpWidgetWithoutViewWrapper( - tester: tester, - widget: globalKeyedWidget, + await tester.pumpWidget( + wrapWithView: false, + globalKeyedWidget, ); expect(tester.takeException(), isFlutterError.having( @@ -91,15 +91,15 @@ void main() { child: const ColoredBox(color: Colors.red), ); - await pumpWidgetWithoutViewWrapper( - tester: tester, - widget: globalKeyedView, + await tester.pumpWidget( + wrapWithView: false, + globalKeyedView, ); expect(tester.takeException(), isNull); - await pumpWidgetWithoutViewWrapper( - tester: tester, - widget: View( + await tester.pumpWidget( + wrapWithView: false, + View( view: tester.view, child: globalKeyedView, ), @@ -155,9 +155,9 @@ void main() { }); testWidgets('ViewAnchor cannot be used at the top of the widget tree (outside of View)', (WidgetTester tester) async { - await pumpWidgetWithoutViewWrapper( - tester: tester, - widget: const ViewAnchor( + await tester.pumpWidget( + wrapWithView: false, + const ViewAnchor( child: SizedBox(), ), ); @@ -175,18 +175,18 @@ void main() { child: const SizedBox(), ); - await pumpWidgetWithoutViewWrapper( - tester: tester, - widget: View( + await tester.pumpWidget( + wrapWithView: false, + View( view: tester.view, child: globalKeyedViewAnchor, ), ); expect(tester.takeException(), isNull); - await pumpWidgetWithoutViewWrapper( - tester: tester, - widget: globalKeyedViewAnchor, + await tester.pumpWidget( + wrapWithView: false, + globalKeyedViewAnchor, ); expect(tester.takeException(), isFlutterError.having( @@ -197,9 +197,9 @@ void main() { }); testWidgets('View can be used at the top of the widget tree', (WidgetTester tester) async { - await pumpWidgetWithoutViewWrapper( - tester: tester, - widget: View( + await tester.pumpWidget( + wrapWithView: false, + View( view: tester.view, child: Container(), ), @@ -214,9 +214,9 @@ void main() { child: const ColoredBox(color: Colors.red), ); - await pumpWidgetWithoutViewWrapper( - tester: tester, - widget: View( + await tester.pumpWidget( + wrapWithView: false, + View( view: tester.view, child: ViewAnchor( view: globalKeyView, // This one has trouble when deactivating @@ -228,9 +228,9 @@ void main() { expect(find.byType(SizedBox), findsOneWidget); expect(find.byType(ColoredBox), findsOneWidget); - await pumpWidgetWithoutViewWrapper( - tester: tester, - widget: globalKeyView, + await tester.pumpWidget( + wrapWithView: false, + globalKeyView, ); expect(tester.takeException(), isNull); expect(find.byType(SizedBox), findsNothing); @@ -238,9 +238,9 @@ void main() { }); testWidgets('ViewCollection can be used at the top of the widget tree', (WidgetTester tester) async { - await pumpWidgetWithoutViewWrapper( - tester: tester, - widget: ViewCollection( + await tester.pumpWidget( + wrapWithView: false, + ViewCollection( views: [ View( view: tester.view, @@ -291,9 +291,9 @@ void main() { }); testWidgets('ViewCollection cannot have render object widgets as children', (WidgetTester tester) async { - await pumpWidgetWithoutViewWrapper( - tester: tester, - widget: const ViewCollection( + await tester.pumpWidget( + wrapWithView: false, + const ViewCollection( views: [ ColoredBox(color: Colors.red), ], @@ -319,9 +319,9 @@ void main() { child: const ColoredBox(color: Colors.red), ); - await pumpWidgetWithoutViewWrapper( - tester: tester, - widget: ViewCollection( + await tester.pumpWidget( + wrapWithView: false, + ViewCollection( views: [ greenView, ViewCollection( @@ -335,9 +335,9 @@ void main() { expect(tester.takeException(), isNull); expect(find.byType(ColoredBox), findsNWidgets(2)); - await pumpWidgetWithoutViewWrapper( - tester: tester, - widget: ViewCollection( + await tester.pumpWidget( + wrapWithView: false, + ViewCollection( views: [ redView, ViewCollection( @@ -371,9 +371,9 @@ void main() { return result; } - await pumpWidgetWithoutViewWrapper( - tester: tester, - widget: ViewCollection( + await tester.pumpWidget( + wrapWithView: false, + ViewCollection( views: [ View( view: greenView, @@ -412,9 +412,9 @@ void main() { expect(leafRenderObject[redView.viewId], isNot(isA())); // Move the child. - await pumpWidgetWithoutViewWrapper( - tester: tester, - widget: ViewCollection( + await tester.pumpWidget( + wrapWithView: false, + ViewCollection( views: [ View( view: greenView, @@ -476,9 +476,9 @@ void main() { return result; } - await pumpWidgetWithoutViewWrapper( - tester: tester, - widget: ViewCollection( + await tester.pumpWidget( + wrapWithView: false, + ViewCollection( views: [ View( view: greenView, @@ -517,9 +517,9 @@ void main() { expect(leafRenderObject[greenView.viewId], isNot(isA())); // Move the child. - await pumpWidgetWithoutViewWrapper( - tester: tester, - widget: ViewCollection( + await tester.pumpWidget( + wrapWithView: false, + ViewCollection( views: [ View( view: greenView, @@ -571,9 +571,9 @@ void main() { key: GlobalKey(), ); - await pumpWidgetWithoutViewWrapper( - tester: tester, - widget: ViewCollection( + await tester.pumpWidget( + wrapWithView: false, + ViewCollection( views: [ View( key: greenKey, @@ -610,9 +610,9 @@ void main() { final RenderObject boxWithGlobalKey = tester.renderObject(find.byKey(globalKeyChild.key!)); // Move the child and remove its view. - await pumpWidgetWithoutViewWrapper( - tester: tester, - widget: ViewCollection( + await tester.pumpWidget( + wrapWithView: false, + ViewCollection( views: [ View( key: greenKey, @@ -652,9 +652,9 @@ void main() { key: GlobalKey(), ); - await pumpWidgetWithoutViewWrapper( - tester: tester, - widget: ViewCollection( + await tester.pumpWidget( + wrapWithView: false, + ViewCollection( views: [ View( key: greenKey, @@ -691,9 +691,9 @@ void main() { final RenderObject boxWithGlobalKey = tester.renderObject(find.byKey(globalKeyChild.key!)); // Move the child and remove its view. - await pumpWidgetWithoutViewWrapper( - tester: tester, - widget: ViewCollection( + await tester.pumpWidget( + wrapWithView: false, + ViewCollection( views: [ View( key: redKey, @@ -1009,9 +1009,9 @@ void main() { final FlutterView redView = tester.view; final FlutterView greenView = FakeView(tester.view); - await pumpWidgetWithoutViewWrapper( - tester: tester, - widget: ViewCollection( + await tester.pumpWidget( + wrapWithView: false, + ViewCollection( views: [ View( view: redView, @@ -1029,9 +1029,9 @@ void main() { expect(findsColoredBox(Colors.red), findsOneWidget); final RenderObject box = tester.renderObject(findsColoredBox(Colors.green)); - await pumpWidgetWithoutViewWrapper( - tester: tester, - widget: ViewCollection( + await tester.pumpWidget( + wrapWithView: false, + ViewCollection( views: [ View( view: redView, @@ -1052,17 +1052,17 @@ void main() { child: const SizedBox(), ); - await pumpWidgetWithoutViewWrapper( - tester: tester, - widget: view, + await tester.pumpWidget( + wrapWithView: false, + view, ); final RenderObject renderView = tester.renderObject(find.byType(View)); final RenderObject renderSizedBox = tester.renderObject(find.byType(SizedBox)); - await pumpWidgetWithoutViewWrapper( - tester: tester, - widget: ViewCollection( + await tester.pumpWidget( + wrapWithView: false, + ViewCollection( views: [view], ), ); @@ -1070,9 +1070,9 @@ void main() { expect(tester.renderObject(find.byType(View)), same(renderView)); expect(tester.renderObject(find.byType(SizedBox)), same(renderSizedBox)); - await pumpWidgetWithoutViewWrapper( - tester: tester, - widget: view, + await tester.pumpWidget( + wrapWithView: false, + view, ); expect(tester.renderObject(find.byType(View)), same(renderView)); @@ -1114,9 +1114,9 @@ void main() { child: const SizedBox(), ) ); - await pumpWidgetWithoutViewWrapper( - tester: tester, - widget: view, + await tester.pumpWidget( + wrapWithView: false, + view, ); final RenderObject renderSemantics = tester.renderObject(find.bySemanticsLabel('Hello')); @@ -1124,9 +1124,9 @@ void main() { expect(semantics.id, 1); expect(renderSemantics.debugSemantics, same(semantics)); - await pumpWidgetWithoutViewWrapper( - tester: tester, - widget: ViewCollection( + await tester.pumpWidget( + wrapWithView: false, + ViewCollection( views: [ view, ], @@ -1144,9 +1144,3 @@ void main() { Finder findsColoredBox(Color color) { return find.byWidgetPredicate((Widget widget) => widget is ColoredBox && widget.color == color); } - -Future pumpWidgetWithoutViewWrapper({required WidgetTester tester, required Widget widget}) { - tester.binding.attachRootWidget(widget); - tester.binding.scheduleFrame(); - return tester.binding.pump(); -} diff --git a/packages/flutter/test/widgets/view_test.dart b/packages/flutter/test/widgets/view_test.dart index 7f93176504..547dc560cc 100644 --- a/packages/flutter/test/widgets/view_test.dart +++ b/packages/flutter/test/widgets/view_test.dart @@ -77,9 +77,9 @@ void main() { PipelineOwner? outsideParent; PipelineOwner? insideParent; - await pumpWidgetWithoutViewWrapper( - tester: tester, - widget: Builder( + await tester.pumpWidget( + wrapWithView: false, + Builder( builder: (BuildContext context) { outsideView = View.maybeOf(context); outsideParent = View.pipelineOwnerOf(context); @@ -114,9 +114,9 @@ void main() { }); testWidgets('cannot have multiple views with same FlutterView', (WidgetTester tester) async { - await pumpWidgetWithoutViewWrapper( - tester: tester, - widget: ViewCollection( + await tester.pumpWidget( + wrapWithView: false, + ViewCollection( views: [ View( view: tester.view, @@ -224,9 +224,9 @@ void main() { }); testWidgets('visitChildren of ViewCollection visits all children', (WidgetTester tester) async { - await pumpWidgetWithoutViewWrapper( - tester: tester, - widget: ViewCollection( + await tester.pumpWidget( + wrapWithView: false, + ViewCollection( views: [ View( view: tester.view, @@ -250,9 +250,9 @@ void main() { }); expect(children, hasLength(3)); - await pumpWidgetWithoutViewWrapper( - tester: tester, - widget: ViewCollection( + await tester.pumpWidget( + wrapWithView: false, + ViewCollection( views: [ View( view: tester.view, @@ -271,9 +271,9 @@ void main() { group('renderObject getter', () { testWidgets('ancestors of view see RenderView as renderObject', (WidgetTester tester) async { late BuildContext builderContext; - await pumpWidgetWithoutViewWrapper( - tester: tester, - widget: Builder( + await tester.pumpWidget( + wrapWithView: false, + Builder( builder: (BuildContext context) { builderContext = context; return View( @@ -293,9 +293,9 @@ void main() { testWidgets('ancestors of ViewCollection get null for renderObject', (WidgetTester tester) async { late BuildContext builderContext; - await pumpWidgetWithoutViewWrapper( - tester: tester, - widget: Builder( + await tester.pumpWidget( + wrapWithView: false, + Builder( builder: (BuildContext context) { builderContext = context; return ViewCollection( @@ -345,9 +345,9 @@ void main() { }); testWidgets('correctly switches between view configurations', (WidgetTester tester) async { - await pumpWidgetWithoutViewWrapper( - tester: tester, - widget: View( + await tester.pumpWidget( + wrapWithView: false, + View( view: tester.view, deprecatedDoNotUseWillBeRemovedWithoutNoticePipelineOwner: tester.binding.pipelineOwner, deprecatedDoNotUseWillBeRemovedWithoutNoticeRenderView: tester.binding.renderView, @@ -359,9 +359,9 @@ void main() { expect(renderView.owner, same(tester.binding.pipelineOwner)); expect(tester.renderObject(find.byType(SizedBox)).owner, same(tester.binding.pipelineOwner)); - await pumpWidgetWithoutViewWrapper( - tester: tester, - widget: View( + await tester.pumpWidget( + wrapWithView: false, + View( view: tester.view, child: const SizedBox(), ), @@ -371,9 +371,9 @@ void main() { expect(renderView.owner, isNot(same(tester.binding.pipelineOwner))); expect(tester.renderObject(find.byType(SizedBox)).owner, isNot(same(tester.binding.pipelineOwner))); - await pumpWidgetWithoutViewWrapper( - tester: tester, - widget: View( + await tester.pumpWidget( + wrapWithView: false, + View( view: tester.view, deprecatedDoNotUseWillBeRemovedWithoutNoticePipelineOwner: tester.binding.pipelineOwner, deprecatedDoNotUseWillBeRemovedWithoutNoticeRenderView: tester.binding.renderView, @@ -513,12 +513,6 @@ void main() { }); } -Future pumpWidgetWithoutViewWrapper({required WidgetTester tester, required Widget widget}) { - tester.binding.attachRootWidget(widget); - tester.binding.scheduleFrame(); - return tester.binding.pump(); -} - class SpyRenderWidget extends SizedBox { const SpyRenderWidget({super.key, required this.label, required this.log, super.child}); diff --git a/packages/flutter_test/lib/src/widget_tester.dart b/packages/flutter_test/lib/src/widget_tester.dart index 7d8799cda5..cf1412b670 100644 --- a/packages/flutter_test/lib/src/widget_tester.dart +++ b/packages/flutter_test/lib/src/widget_tester.dart @@ -581,15 +581,23 @@ class WidgetTester extends WidgetController implements HitTestDispatcher, Ticker /// ``` /// {@end-tool} /// + /// By default, the provided `widget` is rendered into [WidgetTester.view], + /// whose properties tests can modify to simulate different scenarios (e.g. + /// running on a large/small screen). Tests that want to control the + /// [FlutterView] into which content is rendered can set `wrapWithView` to + /// false and use [View] widgets in the provided `widget` tree to specify the + /// desired [FlutterView]s. + /// /// See also [LiveTestWidgetsFlutterBindingFramePolicy], which affects how /// this method works when the test is run with `flutter run`. Future pumpWidget( Widget widget, { Duration? duration, EnginePhase phase = EnginePhase.sendSemanticsUpdate, + bool wrapWithView = true, }) { return TestAsyncUtils.guard(() { - binding.attachRootWidget(binding.wrapWithDefaultView(widget)); + binding.attachRootWidget(wrapWithView ? binding.wrapWithDefaultView(widget) : widget); binding.scheduleFrame(); return binding.pump(duration, phase); }); diff --git a/packages/flutter_test/test/multi_view_accessibility_test.dart b/packages/flutter_test/test/multi_view_accessibility_test.dart index f93ba4b756..3cd5596da0 100644 --- a/packages/flutter_test/test/multi_view_accessibility_test.dart +++ b/packages/flutter_test/test/multi_view_accessibility_test.dart @@ -112,7 +112,8 @@ Future pumpViews({required WidgetTester tester, required List vie ), ]; - tester.binding.attachRootWidget( + return tester.pumpWidget( + wrapWithView: false, Directionality( textDirection: TextDirection.ltr, child: ViewCollection( @@ -120,6 +121,4 @@ Future pumpViews({required WidgetTester tester, required List vie ), ), ); - tester.binding.scheduleFrame(); - return tester.binding.pump(); } diff --git a/packages/flutter_test/test/multi_view_controller_test.dart b/packages/flutter_test/test/multi_view_controller_test.dart index a9488933d7..fc9ec17609 100644 --- a/packages/flutter_test/test/multi_view_controller_test.dart +++ b/packages/flutter_test/test/multi_view_controller_test.dart @@ -208,7 +208,8 @@ Future pumpViews({required WidgetTester tester}) { ), ]; - tester.binding.attachRootWidget( + return tester.pumpWidget( + wrapWithView: false, Directionality( textDirection: TextDirection.ltr, child: ViewCollection( @@ -216,6 +217,4 @@ Future pumpViews({required WidgetTester tester}) { ), ), ); - tester.binding.scheduleFrame(); - return tester.binding.pump(); } diff --git a/packages/flutter_test/test/widget_tester_test.dart b/packages/flutter_test/test/widget_tester_test.dart index 846994a053..81b8078994 100644 --- a/packages/flutter_test/test/widget_tester_test.dart +++ b/packages/flutter_test/test/widget_tester_test.dart @@ -16,6 +16,8 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:matcher/expect.dart' as matcher; import 'package:matcher/src/expect/async_matcher.dart'; +import 'multi_view_testing.dart'; + void main() { group('expectLater', () { testWidgets('completes when matcher completes', (WidgetTester tester) async { @@ -638,6 +640,56 @@ void main() { .checkMockMessageHandler(SystemChannels.accessibility.name, null), isTrue); }); }); + + testWidgets('wrapWithView: false does not include View', (WidgetTester tester) async { + FlutterView? flutterView; + View? view; + int builderCount = 0; + await tester.pumpWidget( + wrapWithView: false, + Builder( + builder: (BuildContext context) { + builderCount++; + flutterView = View.maybeOf(context); + view = context.findAncestorWidgetOfExactType(); + return const ViewCollection(views: []); + }, + ), + ); + + expect(builderCount, 1); + expect(view, isNull); + expect(flutterView, isNull); + expect(find.byType(View), findsNothing); + }); + + testWidgets('passing a view to pumpWidget with wrapWithView: true throws', (WidgetTester tester) async { + await tester.pumpWidget( + View( + view: FakeView(tester.view), + child: const SizedBox.shrink(), + ), + ); + expect( + tester.takeException(), + isFlutterError.having( + (FlutterError e) => e.message, + 'message', + contains('consider setting the "wrapWithView" parameter of that method to false'), + ), + ); + }); + + testWidgets('can pass a View to pumpWidget when wrapWithView: false', (WidgetTester tester) async { + await tester.pumpWidget( + wrapWithView: false, + View( + view: tester.view, + child: const SizedBox.shrink(), + ), + ); + expect(find.byType(View), findsOne); + }); } class FakeMatcher extends AsyncMatcher {