From 6f09064e785b2bb600a390fe6d8be4ac6775b82b Mon Sep 17 00:00:00 2001 From: Michael Goderbauer Date: Mon, 17 Jul 2023 09:14:08 -0700 Subject: [PATCH] Stand-alone widget tree with multiple render trees to enable multi-view rendering (#125003) This change enables Flutter to generate multiple Scenes to be rendered into separate FlutterViews from a single widget tree. Each Scene is described by a separate render tree, which are all associated with the single widget tree. This PR implements the framework-side mechanisms to describe the content to be rendered into multiple views. Separate engine-side changes are necessary to provide these views to the framework and to draw the framework-generated Scene into them. ## Summary of changes The details of this change are described in [flutter.dev/go/multiple-views](https://flutter.dev/go/multiple-views). Below is a high-level summary organized by layers. ### Rendering layer changes * The `RendererBinding` no longer owns a single `renderView`. In fact, it doesn't OWN any `RenderView`s at all anymore. Instead, it offers an API (`addRenderView`/`removeRenderView`) to add and remove `RenderView`s that then will be MANAGED by the binding. The `RenderView` itself is now owned by a higher-level abstraction (e.g. the `RawView` Element of the widgets layer, see below), who is also in charge of adding it to the binding. When added, the binding will interact with the `RenderView` to produce a frame (e.g. by calling `compositeFrame` on it) and to perform hit tests for incoming pointer events. Multiple `RenderView`s can be added to the binding (typically one per `FlutterView`) to produce multiple Scenes. * Instead of owning a single `pipelineOwner`, the `RendererBinding` now owns the root of the `PipelineOwner` tree (exposed as `rootPipelineOwner` on the binding). Each `PipelineOwner` in that tree (except for the root) typically manages its own render tree typically rooted in one of the `RenderView`s mentioned in the previous bullet. During frame production, the binding will instruct each `PipelineOwner` of that tree to flush layout, paint, semantics etc. A higher-level abstraction (e.g. the widgets layer, see below) is in charge of adding `PipelineOwner`s to this tree. * Backwards compatibility: The old `renderView` and `pipelineOwner` properties of the `RendererBinding` are retained, but marked as deprecated. Care has been taken to keep their original behavior for the deprecation period, i.e. if you just call `runApp`, the render tree bootstrapped by this call is rooted in the deprecated `RendererBinding.renderView` and managed by the deprecated `RendererBinding.pipelineOwner`. ### Widgets layer changes * The `WidgetsBinding` no longer attaches the widget tree to an existing render tree. Instead, it bootstraps a stand-alone widget tree that is not backed by a render tree. For this, `RenderObjectToWidgetAdapter` has been replaced by `RootWidget`. * Multiple render trees can be bootstrapped and attached to the widget tree with the help of the `View` widget, which internally is backed by a `RawView` widget. Configured with a `FlutterView` to render into, the `RawView` creates a new `PipelineOwner` and a new `RenderView` for the new render tree. It adds the new `RenderView` to the `RendererBinding` and its `PipelineOwner` to the pipeline owner tree. * The `View` widget can only appear in certain well-defined locations in the widget tree since it bootstraps a new render tree and does not insert a `RenderObject` into an ancestor. However, almost all Elements expect that their children insert `RenderObject`s, otherwise they will not function properly. To produce a good error message when the `View` widget is used in an illegal location, the `debugMustInsertRenderObjectIntoSlot` method has been added to Element, where a child can ask whether a given slot must insert a RenderObject into its ancestor or not. In practice, the `View` widget can be used as a child of the `RootWidget`, inside the `view` slot of the `ViewAnchor` (see below) and inside a `ViewCollection` (see below). In those locations, the `View` widget may be wrapped in other non-RenderObjectWidgets (e.g. InheritedWidgets). * The new `ViewAnchor` can be used to create a side-view inside a parent `View`. The `child` of the `ViewAnchor` widget renders into the parent `View` as usual, but the `view` slot can take on another `View` widget, which has access to all inherited widgets above the `ViewAnchor`. Metaphorically speaking, the view is anchored to the location of the `ViewAnchor` in the widget tree. * The new `ViewCollection` widget allows for multiple sibling views as it takes a list of `View`s as children. It can be used in all the places that accept a `View` widget. ## Google3 As of July 5, 2023 this change passed a TAP global presubmit (TGP) in google3: tap/OCL:544707016:BASE:545809771:1688597935864:e43dd651 ## Note to reviewers This change is big (sorry). I suggest focusing the initial review on the changes inside of `packages/flutter` first. The majority of the changes describe above are implemented in (listed in suggested review order): * `rendering/binding.dart` * `widgets/binding.dart` * `widgets/view.dart` * `widgets/framework.dart` All other changes included in the PR are basically the fallout of what's implemented in those files. Also note that a lot of the lines added in this PR are documentation and tests. I am also very happy to walk reviewers through the code in person or via video call, if that is helpful. I appreciate any feedback. ## Feedback to address before submitting ("TODO") --- .../lib/stocks/layout_bench.dart | 2 +- dev/bots/test.dart | 2 +- .../flutter_gallery/test/smoke_test.dart | 4 +- .../rendering/custom_coordinate_systems.dart | 3 +- examples/layers/rendering/flex_layout.dart | 3 +- examples/layers/rendering/hello_world.dart | 6 +- .../layers/rendering/spinning_square.dart | 4 +- examples/layers/rendering/src/binding.dart | 69 + examples/layers/rendering/touch_input.dart | 4 +- examples/layers/widgets/spinning_mixed.dart | 18 +- .../flutter/lib/src/rendering/binding.dart | 463 +++++-- .../flutter/lib/src/rendering/object.dart | 59 +- packages/flutter/lib/src/rendering/view.dart | 39 +- packages/flutter/lib/src/widgets/adapter.dart | 177 +++ packages/flutter/lib/src/widgets/binding.dart | 177 +-- .../flutter/lib/src/widgets/framework.dart | 202 ++- .../lib/src/widgets/semantics_debugger.dart | 36 +- packages/flutter/lib/src/widgets/view.dart | 664 +++++++++- .../lib/src/widgets/widget_inspector.dart | 3 +- packages/flutter/lib/widgets.dart | 1 + .../foundation/service_extensions_test.dart | 38 +- .../binding_pipeline_manifold_test.dart | 16 +- .../flutter/test/rendering/binding_test.dart | 31 +- .../rendering/mouse_tracker_test_utils.dart | 15 +- .../rendering/multi_view_binding_test.dart | 208 +++ .../rendering/pipeline_owner_tree_test.dart | 63 +- .../test/rendering/platform_view_test.dart | 8 +- .../test/rendering/rendering_tester.dart | 51 +- .../flutter/test/rendering/view_test.dart | 10 + .../test/scheduler/benchmarks_test.dart | 2 +- .../flutter/test/widgets/container_test.dart | 91 +- .../custom_multi_child_layout_test.dart | 5 +- packages/flutter/test/widgets/debug_test.dart | 5 +- .../test/widgets/focus_manager_test.dart | 5 +- .../flutter/test/widgets/init_state_test.dart | 2 +- .../flutter/test/widgets/keep_alive_test.dart | 4 +- .../test/widgets/media_query_test.dart | 36 +- .../test/widgets/multi_view_binding_test.dart | 39 + .../widgets/multi_view_tree_updates_test.dart | 221 ++++ .../slotted_render_object_widget_test.dart | 15 +- .../test/widgets/stateful_component_test.dart | 3 +- .../flutter/test/widgets/tree_shape_test.dart | 1161 +++++++++++++++++ packages/flutter/test/widgets/view_test.dart | 435 +++++- .../test/widgets/widget_inspector_test.dart | 3 +- .../widgets/memory_allocations_test.dart | 2 +- .../lib/src/common/handler_factory.dart | 13 +- .../flutter_test/lib/src/_matchers_web.dart | 4 +- .../flutter_test/lib/src/accessibility.dart | 37 +- packages/flutter_test/lib/src/binding.dart | 135 +- packages/flutter_test/lib/src/controller.dart | 95 +- packages/flutter_test/lib/src/finders.dart | 4 +- .../flutter_test/lib/src/widget_tester.dart | 44 +- .../test/multi_view_accessibility_test.dart | 135 ++ .../test/multi_view_controller_test.dart | 232 ++++ .../flutter_test_config.dart | 6 +- .../test/widget_tester_live_device_test.dart | 4 +- .../lib/integration_test.dart | 17 +- 57 files changed, 4596 insertions(+), 535 deletions(-) create mode 100644 examples/layers/rendering/src/binding.dart create mode 100644 packages/flutter/lib/src/widgets/adapter.dart create mode 100644 packages/flutter/test/rendering/multi_view_binding_test.dart create mode 100644 packages/flutter/test/widgets/multi_view_binding_test.dart create mode 100644 packages/flutter/test/widgets/multi_view_tree_updates_test.dart create mode 100644 packages/flutter/test/widgets/tree_shape_test.dart create mode 100644 packages/flutter_test/test/multi_view_accessibility_test.dart create mode 100644 packages/flutter_test/test/multi_view_controller_test.dart diff --git a/dev/benchmarks/microbenchmarks/lib/stocks/layout_bench.dart b/dev/benchmarks/microbenchmarks/lib/stocks/layout_bench.dart index e42211d594c..9d72b87aa3a 100644 --- a/dev/benchmarks/microbenchmarks/lib/stocks/layout_bench.dart +++ b/dev/benchmarks/microbenchmarks/lib/stocks/layout_bench.dart @@ -39,7 +39,7 @@ Future main() async { size: const Size(355.0, 635.0), view: tester.view, ); - final RenderView renderView = WidgetsBinding.instance.renderView; + final RenderView renderView = WidgetsBinding.instance.renderViews.single; binding.framePolicy = LiveTestWidgetsFlutterBindingFramePolicy.benchmark; watch.start(); diff --git a/dev/bots/test.dart b/dev/bots/test.dart index c994041f783..d4aa4e7d232 100644 --- a/dev/bots/test.dart +++ b/dev/bots/test.dart @@ -1361,7 +1361,7 @@ Future _runWebTreeshakeTest() async { final String javaScript = mainDartJs.readAsStringSync(); // Check that we're not looking at minified JS. Otherwise this test would result in false positive. - expect(javaScript.contains('RenderObjectToWidgetElement'), true); + expect(javaScript.contains('RootElement'), true); const String word = 'debugFillProperties'; int count = 0; diff --git a/dev/integration_tests/flutter_gallery/test/smoke_test.dart b/dev/integration_tests/flutter_gallery/test/smoke_test.dart index 98f473880dd..cd49804f804 100644 --- a/dev/integration_tests/flutter_gallery/test/smoke_test.dart +++ b/dev/integration_tests/flutter_gallery/test/smoke_test.dart @@ -79,8 +79,8 @@ Future smokeDemo(WidgetTester tester, GalleryDemo demo) async { // Verify that the dumps are pretty. final String routeName = demo.routeName; verifyToStringOutput('debugDumpApp', routeName, WidgetsBinding.instance.rootElement!.toStringDeep()); - verifyToStringOutput('debugDumpRenderTree', routeName, RendererBinding.instance.renderView.toStringDeep()); - verifyToStringOutput('debugDumpLayerTree', routeName, RendererBinding.instance.renderView.debugLayer?.toStringDeep() ?? ''); + verifyToStringOutput('debugDumpRenderTree', routeName, RendererBinding.instance.renderViews.single.toStringDeep()); + verifyToStringOutput('debugDumpLayerTree', routeName, RendererBinding.instance.renderViews.single.debugLayer?.toStringDeep() ?? ''); verifyToStringOutput('debugDumpFocusTree', routeName, WidgetsBinding.instance.focusManager.toStringDeep()); // Scroll the demo around a bit more. diff --git a/examples/layers/rendering/custom_coordinate_systems.dart b/examples/layers/rendering/custom_coordinate_systems.dart index 28aa4137892..d7fd16186e5 100644 --- a/examples/layers/rendering/custom_coordinate_systems.dart +++ b/examples/layers/rendering/custom_coordinate_systems.dart @@ -6,6 +6,7 @@ // system. Most of the guts of this examples are in src/sector_layout.dart. import 'package:flutter/rendering.dart'; +import 'src/binding.dart'; import 'src/sector_layout.dart'; RenderBox buildSectorExample() { @@ -21,5 +22,5 @@ RenderBox buildSectorExample() { } void main() { - RenderingFlutterBinding(root: buildSectorExample()).scheduleFrame(); + ViewRenderingFlutterBinding(root: buildSectorExample()).scheduleFrame(); } diff --git a/examples/layers/rendering/flex_layout.dart b/examples/layers/rendering/flex_layout.dart index 43c29509fc9..845a232fa18 100644 --- a/examples/layers/rendering/flex_layout.dart +++ b/examples/layers/rendering/flex_layout.dart @@ -7,6 +7,7 @@ import 'package:flutter/rendering.dart'; +import 'src/binding.dart'; import 'src/solid_color_box.dart'; void main() { @@ -86,5 +87,5 @@ void main() { child: RenderPadding(child: table, padding: const EdgeInsets.symmetric(vertical: 50.0)), ); - RenderingFlutterBinding(root: root).scheduleFrame(); + ViewRenderingFlutterBinding(root: root).scheduleFrame(); } diff --git a/examples/layers/rendering/hello_world.dart b/examples/layers/rendering/hello_world.dart index 03b4801e282..cb49f94d373 100644 --- a/examples/layers/rendering/hello_world.dart +++ b/examples/layers/rendering/hello_world.dart @@ -7,9 +7,11 @@ import 'package:flutter/rendering.dart'; +import 'src/binding.dart'; + void main() { - // We use RenderingFlutterBinding to attach the render tree to the window. - RenderingFlutterBinding( + // We use ViewRenderingFlutterBinding to attach the render tree to the window. + ViewRenderingFlutterBinding( // The root of our render tree is a RenderPositionedBox, which centers its // child both vertically and horizontally. root: RenderPositionedBox( diff --git a/examples/layers/rendering/spinning_square.dart b/examples/layers/rendering/spinning_square.dart index 68b13591393..cb833069fde 100644 --- a/examples/layers/rendering/spinning_square.dart +++ b/examples/layers/rendering/spinning_square.dart @@ -11,6 +11,8 @@ import 'package:flutter/animation.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/scheduler.dart'; +import 'src/binding.dart'; + class NonStopVSync implements TickerProvider { const NonStopVSync(); @override @@ -42,7 +44,7 @@ void main() { child: spin, ); // and attach it to the window. - RenderingFlutterBinding(root: root); + ViewRenderingFlutterBinding(root: root); // To make the square spin, we use an animation that repeats every 1800 // milliseconds. diff --git a/examples/layers/rendering/src/binding.dart b/examples/layers/rendering/src/binding.dart new file mode 100644 index 00000000000..ae8cedcc90d --- /dev/null +++ b/examples/layers/rendering/src/binding.dart @@ -0,0 +1,69 @@ +// 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 'dart:ui'; + +import 'package:flutter/rendering.dart'; + +/// An extension of [RenderingFlutterBinding] that owns and manages a +/// [renderView]. +/// +/// Unlike [RenderingFlutterBinding], this binding also creates and owns a +/// [renderView] to simplify bootstrapping for apps that have a dedicated main +/// view. +class ViewRenderingFlutterBinding extends RenderingFlutterBinding { + /// Creates a binding for the rendering layer. + /// + /// The `root` render box is attached directly to the [renderView] and is + /// given constraints that require it to fill the window. The [renderView] + /// itself is attached to the [rootPipelineOwner]. + /// + /// This binding does not automatically schedule any frames. Callers are + /// responsible for deciding when to first call [scheduleFrame]. + ViewRenderingFlutterBinding({ RenderBox? root }) : _root = root; + + @override + void initInstances() { + super.initInstances(); + // TODO(goderbauer): Create window if embedder doesn't provide an implicit view. + assert(PlatformDispatcher.instance.implicitView != null); + _renderView = initRenderView(PlatformDispatcher.instance.implicitView!); + _renderView.child = _root; + _root = null; + } + + RenderBox? _root; + + @override + RenderView get renderView => _renderView; + late RenderView _renderView; + + /// Creates a [RenderView] object to be the root of the + /// [RenderObject] rendering tree, and initializes it so that it + /// will be rendered when the next frame is requested. + /// + /// Called automatically when the binding is created. + RenderView initRenderView(FlutterView view) { + final RenderView renderView = RenderView(view: view); + rootPipelineOwner.rootNode = renderView; + addRenderView(renderView); + renderView.prepareInitialFrame(); + return renderView; + } + + @override + PipelineOwner createRootPipelineOwner() { + return PipelineOwner( + onSemanticsOwnerCreated: () { + renderView.scheduleInitialSemantics(); + }, + onSemanticsUpdate: (SemanticsUpdate update) { + renderView.updateSemantics(update); + }, + onSemanticsOwnerDisposed: () { + renderView.clearSemantics(); + }, + ); + } +} diff --git a/examples/layers/rendering/touch_input.dart b/examples/layers/rendering/touch_input.dart index 3747eb43dfc..37a94452844 100644 --- a/examples/layers/rendering/touch_input.dart +++ b/examples/layers/rendering/touch_input.dart @@ -8,6 +8,8 @@ import 'package:flutter/material.dart'; // Imported just for its color palette. import 'package:flutter/rendering.dart'; +import 'src/binding.dart'; + // Material design colors. :p List _kColors = [ Colors.teal, @@ -133,5 +135,5 @@ void main() { ..left = 20.0; // Finally, we attach the render tree we've built to the screen. - RenderingFlutterBinding(root: stack).scheduleFrame(); + ViewRenderingFlutterBinding(root: stack).scheduleFrame(); } diff --git a/examples/layers/widgets/spinning_mixed.dart b/examples/layers/widgets/spinning_mixed.dart index cdde8f7b6e3..6d3223c7b00 100644 --- a/examples/layers/widgets/spinning_mixed.dart +++ b/examples/layers/widgets/spinning_mixed.dart @@ -52,10 +52,10 @@ void attachWidgetTreeToRenderTree(RenderProxyBox container) { mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ ElevatedButton( - child: Row( + child: const Row( children: [ - Image.network('https://flutter.dev/images/favicon.png'), - const Text('PRESS ME'), + FlutterLogo(), + Text('PRESS ME'), ], ), onPressed: () { @@ -102,6 +102,16 @@ void main() { transformBox = RenderTransform(child: flexRoot, transform: Matrix4.identity(), alignment: Alignment.center); final RenderPadding root = RenderPadding(padding: const EdgeInsets.all(80.0), child: transformBox); - binding.renderView.child = root; + // TODO(goderbauer): Create a window if embedder doesn't provide an implicit view to draw into. + assert(binding.platformDispatcher.implicitView != null); + final RenderView view = RenderView( + view: binding.platformDispatcher.implicitView!, + child: root, + ); + final PipelineOwner pipelineOwner = PipelineOwner()..rootNode = view; + binding.rootPipelineOwner.adoptChild(pipelineOwner); + binding.addRenderView(view); + view.prepareInitialFrame(); + binding.addPersistentFrameCallback(rotate); } diff --git a/packages/flutter/lib/src/rendering/binding.dart b/packages/flutter/lib/src/rendering/binding.dart index a90e062c421..8b14588b78d 100644 --- a/packages/flutter/lib/src/rendering/binding.dart +++ b/packages/flutter/lib/src/rendering/binding.dart @@ -10,7 +10,6 @@ import 'package:flutter/scheduler.dart'; import 'package:flutter/semantics.dart'; import 'package:flutter/services.dart'; -import 'box.dart'; import 'debug.dart'; import 'mouse_tracker.dart'; import 'object.dart'; @@ -22,28 +21,34 @@ export 'package:flutter/gestures.dart' show HitTestResult; // Examples can assume: // late BuildContext context; -/// The glue between the render tree and the Flutter engine. +/// The glue between the render trees and the Flutter engine. +/// +/// The [RendererBinding] manages multiple independent render trees. Each render +/// tree is rooted in a [RenderView] that must be added to the binding via +/// [addRenderView] to be considered during frame production, hit testing, etc. +/// Furthermore, the render tree must be managed by a [PipelineOwner] that is +/// part of the pipeline owner tree rooted at [rootPipelineOwner]. +/// +/// Adding [PipelineOwner]s and [RenderView]s to this binding in the way +/// described above is left as a responsibility for a higher level abstraction. +/// The widgets library, for example, introduces the [View] widget, which +/// registers its [RenderView] and [PipelineOwner] with this binding. mixin RendererBinding on BindingBase, ServicesBinding, SchedulerBinding, GestureBinding, SemanticsBinding, HitTestable { @override void initInstances() { super.initInstances(); _instance = this; - _pipelineOwner = PipelineOwner( - onSemanticsOwnerCreated: _handleSemanticsOwnerCreated, - onSemanticsUpdate: _handleSemanticsUpdate, - onSemanticsOwnerDisposed: _handleSemanticsOwnerDisposed, - ); + _rootPipelineOwner = createRootPipelineOwner(); platformDispatcher ..onMetricsChanged = handleMetricsChanged ..onTextScaleFactorChanged = handleTextScaleFactorChanged ..onPlatformBrightnessChanged = handlePlatformBrightnessChanged; - initRenderView(); addPersistentFrameCallback(_handlePersistentFrameCallback); initMouseTracker(); if (kIsWeb) { addPostFrameCallback(_handleWebFirstFrame); } - _pipelineOwner.attach(_manifold); + rootPipelineOwner.attach(_manifold); } /// The current [RendererBinding], if one has been created. @@ -108,9 +113,8 @@ mixin RendererBinding on BindingBase, ServicesBinding, SchedulerBinding, Gesture registerServiceExtension( name: RenderingServiceExtensions.debugDumpLayerTree.name, callback: (Map parameters) async { - final String data = RendererBinding.instance.renderView.debugLayer?.toStringDeep() ?? 'Layer tree unavailable.'; return { - 'data': data, + 'data': _debugCollectLayerTrees(), }; }, ); @@ -155,9 +159,8 @@ mixin RendererBinding on BindingBase, ServicesBinding, SchedulerBinding, Gesture registerServiceExtension( name: RenderingServiceExtensions.debugDumpRenderTree.name, callback: (Map parameters) async { - final String data = RendererBinding.instance.renderView.toStringDeep(); return { - 'data': data, + 'data': _debugCollectRenderTrees(), }; }, ); @@ -165,7 +168,7 @@ mixin RendererBinding on BindingBase, ServicesBinding, SchedulerBinding, Gesture name: RenderingServiceExtensions.debugDumpSemanticsTreeInTraversalOrder.name, callback: (Map parameters) async { return { - 'data': _generateSemanticsTree(DebugSemanticsDumpOrder.traversalOrder), + 'data': _debugCollectSemanticsTrees(DebugSemanticsDumpOrder.traversalOrder), }; }, ); @@ -173,7 +176,7 @@ mixin RendererBinding on BindingBase, ServicesBinding, SchedulerBinding, Gesture name: RenderingServiceExtensions.debugDumpSemanticsTreeInInverseHitTestOrder.name, callback: (Map parameters) async { return { - 'data': _generateSemanticsTree(DebugSemanticsDumpOrder.inverseHitTest), + 'data': _debugCollectSemanticsTrees(DebugSemanticsDumpOrder.inverseHitTest), }; }, ); @@ -200,38 +203,156 @@ mixin RendererBinding on BindingBase, ServicesBinding, SchedulerBinding, Gesture late final PipelineManifold _manifold = _BindingPipelineManifold(this); - /// Creates a [RenderView] object to be the root of the - /// [RenderObject] rendering tree, and initializes it so that it - /// will be rendered when the next frame is requested. - /// - /// Called automatically when the binding is created. - void initRenderView() { - assert(!_debugIsRenderViewInitialized); - assert(() { - _debugIsRenderViewInitialized = true; - return true; - }()); - renderView = RenderView(configuration: createViewConfiguration(), view: platformDispatcher.implicitView!); - renderView.prepareInitialFrame(); - } - bool _debugIsRenderViewInitialized = false; - /// The object that manages state about currently connected mice, for hover /// notification. MouseTracker get mouseTracker => _mouseTracker!; MouseTracker? _mouseTracker; - /// The render tree's owner, which maintains dirty state for layout, - /// composite, paint, and accessibility semantics. - PipelineOwner get pipelineOwner => _pipelineOwner; - late PipelineOwner _pipelineOwner; + /// Deprecated. Will be removed in a future version of Flutter. + /// + /// This is typically the owner of the render tree bootstrapped by [runApp] + /// and rooted in [renderView]. It maintains dirty state for layout, + /// composite, paint, and accessibility semantics for that tree. + /// + /// However, by default, the [pipelineOwner] does not participate in frame + /// production because it is not automatically attached to the + /// [rootPipelineOwner] or any of its descendants. It is also not + /// automatically associated with the [renderView]. This is left as a + /// responsibility for a higher level abstraction. The [WidgetsBinding], for + /// example, wires this up in [WidgetsBinding.wrapWithDefaultView], which is + /// called indirectly from [runApp]. + /// + /// Apps, that don't use the [WidgetsBinding] or don't call [runApp] (or + /// [WidgetsBinding.wrapWithDefaultView]) must manually add this pipeline owner + /// to the pipeline owner tree rooted at [rootPipelineOwner] and assign a + /// [RenderView] to it if the they want to use this deprecated property. + /// + /// Instead of accessing this deprecated property, consider interacting with + /// the root of the [PipelineOwner] tree (exposed in [rootPipelineOwner]) or + /// instead of accessing the [SemanticsOwner] of any [PipelineOwner] consider + /// interacting with the [SemanticsBinding] (exposed via + /// [SemanticsBinding.instance]) directly. + @Deprecated( + 'Interact with the pipelineOwner tree rooted at RendererBinding.rootPipelineOwner instead. ' + 'Or instead of accessing the SemanticsOwner of any PipelineOwner interact with the SemanticsBinding directly. ' + 'This feature was deprecated after v3.10.0-12.0.pre.' + ) + late final PipelineOwner pipelineOwner = PipelineOwner( + onSemanticsOwnerCreated: () { + (pipelineOwner.rootNode as RenderView?)?.scheduleInitialSemantics(); + }, + onSemanticsUpdate: (ui.SemanticsUpdate update) { + (pipelineOwner.rootNode as RenderView?)?.updateSemantics(update); + }, + onSemanticsOwnerDisposed: () { + (pipelineOwner.rootNode as RenderView?)?.clearSemantics(); + } + ); - /// The render tree that's attached to the output surface. - RenderView get renderView => _pipelineOwner.rootNode! as RenderView; - /// Sets the given [RenderView] object (which must not be null), and its tree, to - /// be the new render tree to display. The previous tree, if any, is detached. - set renderView(RenderView value) { - _pipelineOwner.rootNode = value; + /// Deprecated. Will be removed in a future version of Flutter. + /// + /// This is typically the root of the render tree bootstrapped by [runApp]. + /// + /// However, by default this render view is not associated with any + /// [PipelineOwner] and therefore isn't considered during frame production. + /// It is also not registered with this binding via [addRenderView]. + /// Wiring this up is left as a responsibility for a higher level. The + /// [WidgetsBinding], for example, sets this up in + /// [WidgetsBinding.wrapWithDefaultView], which is called indirectly from + /// [runApp]. + /// + /// Apps that don't use the [WidgetsBinding] or don't call [runApp] (or + /// [WidgetsBinding.wrapWithDefaultView]) must manually assign a + /// [PipelineOwner] to this [RenderView], make sure the pipeline owner is part + /// of the pipeline owner tree rooted at [rootPipelineOwner], and call + /// [addRenderView] if they want to use this deprecated property. + /// + /// Instead of interacting with this deprecated property, consider using + /// [renderViews] instead, which contains all [RenderView]s managed by the + /// binding. + @Deprecated( + 'Consider using RendererBinding.renderViews instead as the binding may manage multiple RenderViews. ' + 'This feature was deprecated after v3.10.0-12.0.pre.' + ) + // TODO(goderbauer): When this deprecated property is removed also delete the _ReusableRenderView class. + late final RenderView renderView = _ReusableRenderView( + view: platformDispatcher.implicitView!, + ); + + /// Creates the [PipelineOwner] that serves as the root of the pipeline owner + /// tree ([rootPipelineOwner]). + /// + /// {@template flutter.rendering.createRootPipelineOwner} + /// By default, the root pipeline owner is not setup to manage a render tree + /// and its [PipelineOwner.rootNode] must not be assigned. If necessary, + /// [createRootPipelineOwner] may be overridden to create a root pipeline + /// owner configured to manage its own render tree. + /// + /// In typical use, child pipeline owners are added to the root pipeline owner + /// (via [PipelineOwner.adoptChild]). Those children typically do each manage + /// their own [RenderView] and produce distinct render trees which render + /// their content into the [FlutterView] associated with that [RenderView]. + /// {@endtemplate} + PipelineOwner createRootPipelineOwner() { + return _DefaultRootPipelineOwner(); + } + + /// The [PipelineOwner] that is the root of the PipelineOwner tree. + /// + /// {@macro flutter.rendering.createRootPipelineOwner} + PipelineOwner get rootPipelineOwner => _rootPipelineOwner; + late PipelineOwner _rootPipelineOwner; + + /// The [RenderView]s managed by this binding. + /// + /// A [RenderView] is added by [addRenderView] and removed by [removeRenderView]. + Iterable get renderViews => _viewIdToRenderView.values; + final Map _viewIdToRenderView = {}; + + /// Adds a [RenderView] to this binding. + /// + /// The binding will interact with the [RenderView] in the following ways: + /// + /// * setting and updating [RenderView.configuration], + /// * calling [RenderView.compositeFrame] when it is time to produce a new + /// frame, and + /// * forwarding relevant pointer events to the [RenderView] for hit testing. + /// + /// To remove a [RenderView] from the binding, call [removeRenderView]. + void addRenderView(RenderView view) { + final Object viewId = view.flutterView.viewId; + assert(!_viewIdToRenderView.containsValue(view)); + assert(!_viewIdToRenderView.containsKey(viewId)); + _viewIdToRenderView[viewId] = view; + view.configuration = createViewConfigurationFor(view); + } + + /// Removes a [RenderView] previously added with [addRenderView] from the + /// binding. + void removeRenderView(RenderView view) { + final Object viewId = view.flutterView.viewId; + assert(_viewIdToRenderView[viewId] == view); + _viewIdToRenderView.remove(viewId); + } + + /// Returns a [ViewConfiguration] configured for the provided [RenderView] + /// based on the current environment. + /// + /// This is called during [addRenderView] and also in response to changes to + /// the system metrics to update all [renderViews] added to the binding. + /// + /// Bindings can override this method to change what size or device pixel + /// ratio the [RenderView] will use. For example, the testing framework uses + /// this to force the display into 800x600 when a test is run on the device + /// using `flutter run`. + @protected + ViewConfiguration createViewConfigurationFor(RenderView renderView) { + final FlutterView view = renderView.flutterView; + final double devicePixelRatio = view.devicePixelRatio; + return ViewConfiguration( + size: view.physicalSize / devicePixelRatio, + devicePixelRatio: devicePixelRatio, + ); } /// Called when the system metrics change. @@ -240,8 +361,12 @@ mixin RendererBinding on BindingBase, ServicesBinding, SchedulerBinding, Gesture @protected @visibleForTesting void handleMetricsChanged() { - renderView.configuration = createViewConfiguration(); - if (renderView.child != null) { + bool forceFrame = false; + for (final RenderView view in renderViews) { + forceFrame = forceFrame || view.child != null; + view.configuration = createViewConfigurationFor(view); + } + if (forceFrame) { scheduleForcedFrame(); } } @@ -288,25 +413,6 @@ mixin RendererBinding on BindingBase, ServicesBinding, SchedulerBinding, Gesture @protected void handlePlatformBrightnessChanged() { } - /// Returns a [ViewConfiguration] configured for the [RenderView] based on the - /// current environment. - /// - /// This is called during construction and also in response to changes to the - /// system metrics. - /// - /// Bindings can override this method to change what size or device pixel - /// ratio the [RenderView] will use. For example, the testing framework uses - /// this to force the display into 800x600 when a test is run on the device - /// using `flutter run`. - ViewConfiguration createViewConfiguration() { - final FlutterView view = platformDispatcher.implicitView!; - final double devicePixelRatio = view.devicePixelRatio; - return ViewConfiguration( - size: view.physicalSize / devicePixelRatio, - devicePixelRatio: devicePixelRatio, - ); - } - /// Creates a [MouseTracker] which manages state about currently connected /// mice, for hover notification. /// @@ -335,19 +441,10 @@ mixin RendererBinding on BindingBase, ServicesBinding, SchedulerBinding, Gesture @override void performSemanticsAction(SemanticsActionEvent action) { - _pipelineOwner.semanticsOwner?.performAction(action.nodeId, action.type, action.arguments); - } - - void _handleSemanticsOwnerCreated() { - renderView.scheduleInitialSemantics(); - } - - void _handleSemanticsUpdate(ui.SemanticsUpdate update) { - renderView.updateSemantics(update); - } - - void _handleSemanticsOwnerDisposed() { - renderView.clearSemantics(); + // Due to the asynchronicity in some screen readers (they may not have + // processed the latest semantics update yet) this code is more forgiving + // and actions for views/nodes that no longer exist are gracefully ignored. + _viewIdToRenderView[action.viewId]?.owner?.semanticsOwner?.performAction(action.nodeId, action.type, action.arguments); } void _handleWebFirstFrame(Duration _) { @@ -491,12 +588,14 @@ mixin RendererBinding on BindingBase, ServicesBinding, SchedulerBinding, Gesture // When editing the above, also update widgets/binding.dart's copy. @protected void drawFrame() { - pipelineOwner.flushLayout(); - pipelineOwner.flushCompositingBits(); - pipelineOwner.flushPaint(); + rootPipelineOwner.flushLayout(); + rootPipelineOwner.flushCompositingBits(); + rootPipelineOwner.flushPaint(); if (sendFramesToEngine) { - renderView.compositeFrame(); // this sends the bits to the GPU - pipelineOwner.flushSemantics(); // this also sends the semantics to the OS. + for (final RenderView renderView in renderViews) { + renderView.compositeFrame(); // this sends the bits to the GPU + } + rootPipelineOwner.flushSemantics(); // this sends the semantics to the OS. _firstFrameSent = true; } } @@ -509,7 +608,9 @@ mixin RendererBinding on BindingBase, ServicesBinding, SchedulerBinding, Gesture FlutterTimeline.startSync('Preparing Hot Reload (layout)'); } try { - renderView.reassemble(); + for (final RenderView renderView in renderViews) { + renderView.reassemble(); + } } finally { if (!kReleaseMode) { FlutterTimeline.finishSync(); @@ -520,18 +621,9 @@ mixin RendererBinding on BindingBase, ServicesBinding, SchedulerBinding, Gesture await endOfFrame; } - late final int _implicitViewId = platformDispatcher.implicitView!.viewId; - @override void hitTestInView(HitTestResult result, Offset position, int viewId) { - // Currently Flutter only supports one view, the implicit view `renderView`. - // TODO(dkwingsmt): After Flutter supports multi-view, look up the correct - // render view for the ID. - // https://github.com/flutter/flutter/issues/121573 - assert(viewId == _implicitViewId, - 'Unexpected view ID $viewId (expecting implicit view ID $_implicitViewId)'); - assert(viewId == renderView.flutterView.viewId); - renderView.hitTest(result, position: position); + _viewIdToRenderView[viewId]?.hitTest(result, position: position); super.hitTestInView(result, position, viewId); } @@ -541,40 +633,93 @@ mixin RendererBinding on BindingBase, ServicesBinding, SchedulerBinding, Gesture child.markNeedsPaint(); child.visitChildren(visitor); }; - instance.renderView.visitChildren(visitor); + for (final RenderView renderView in renderViews) { + renderView.visitChildren(visitor); + } return endOfFrame; } } -/// Prints a textual representation of the entire render tree. +String _debugCollectRenderTrees() { + if (RendererBinding.instance.renderViews.isEmpty) { + return 'No render tree root was added to the binding.'; + } + return [ + for (final RenderView renderView in RendererBinding.instance.renderViews) + renderView.toStringDeep(), + ].join('\n\n'); +} + +/// Prints a textual representation of the render trees. +/// +/// {@template flutter.rendering.debugDumpRenderTree} +/// It prints the trees associated with every [RenderView] in +/// [RendererBinding.renderView], separated by two blank lines. +/// {@endtemplate} void debugDumpRenderTree() { - debugPrint(RendererBinding.instance.renderView.toStringDeep()); + debugPrint(_debugCollectRenderTrees()); } -/// Prints a textual representation of the entire layer tree. +String _debugCollectLayerTrees() { + if (RendererBinding.instance.renderViews.isEmpty) { + return 'No render tree root was added to the binding.'; + } + return [ + for (final RenderView renderView in RendererBinding.instance.renderViews) + renderView.debugLayer?.toStringDeep() ?? 'Layer tree unavailable for $renderView.', + ].join('\n\n'); +} + +/// Prints a textual representation of the layer trees. +/// +/// {@macro flutter.rendering.debugDumpRenderTree} void debugDumpLayerTree() { - debugPrint(RendererBinding.instance.renderView.debugLayer?.toStringDeep()); + debugPrint(_debugCollectLayerTrees()); } -/// Prints a textual representation of the entire semantics tree. -/// This will only work if there is a semantics client attached. -/// Otherwise, a notice that no semantics are available will be printed. +String _debugCollectSemanticsTrees(DebugSemanticsDumpOrder childOrder) { + if (RendererBinding.instance.renderViews.isEmpty) { + return 'No render tree root was added to the binding.'; + } + const String explanation = 'For performance reasons, the framework only generates semantics when asked to do so by the platform.\n' + 'Usually, platforms only ask for semantics when assistive technologies (like screen readers) are running.\n' + 'To generate semantics, try turning on an assistive technology (like VoiceOver or TalkBack) on your device.'; + final List trees = []; + bool printedExplanation = false; + for (final RenderView renderView in RendererBinding.instance.renderViews) { + final String? tree = renderView.debugSemantics?.toStringDeep(childOrder: childOrder); + if (tree != null) { + trees.add(tree); + } else { + String message = 'Semantics not generated for $renderView.'; + if (!printedExplanation) { + printedExplanation = true; + message = '$message\n$explanation'; + } + trees.add(message); + } + } + return trees.join('\n\n'); +} + +/// Prints a textual representation of the semantics trees. +/// +/// {@macro flutter.rendering.debugDumpRenderTree} +/// +/// Semantics trees are only constructed when semantics are enabled (see +/// [SemanticsBinding.semanticsEnabled]). If a semantics tree is not available, +/// a notice about the missing semantics tree is printed instead. /// /// The order in which the children of a [SemanticsNode] will be printed is /// controlled by the [childOrder] parameter. void debugDumpSemanticsTree([DebugSemanticsDumpOrder childOrder = DebugSemanticsDumpOrder.traversalOrder]) { - debugPrint(_generateSemanticsTree(childOrder)); + debugPrint(_debugCollectSemanticsTrees(childOrder)); } -String _generateSemanticsTree(DebugSemanticsDumpOrder childOrder) { - final String? tree = RendererBinding.instance.renderView.debugSemantics?.toStringDeep(childOrder: childOrder); - if (tree != null) { - return tree; - } - return 'Semantics not generated.\n' - 'For performance reasons, the framework only generates semantics when asked to do so by the platform.\n' - 'Usually, platforms only ask for semantics when assistive technologies (like screen readers) are running.\n' - 'To generate semantics, try turning on an assistive technology (like VoiceOver or TalkBack) on your device.'; +/// Prints a textual representation of the [PipelineOwner] tree rooted at +/// [RendererBinding.rootPipelineOwner]. +void debugDumpPipelineOwnerTree() { + debugPrint(RendererBinding.instance.rootPipelineOwner.toStringDeep()); } /// A concrete binding for applications that use the Rendering framework @@ -595,18 +740,17 @@ String _generateSemanticsTree(DebugSemanticsDumpOrder childOrder) { /// rendering layer directly. If you are writing to a higher-level /// library, such as the Flutter Widgets library, then you would use /// that layer's binding (see [WidgetsFlutterBinding]). +/// +/// The [RenderingFlutterBinding] can manage multiple render trees. Each render +/// tree is rooted in a [RenderView] that must be added to the binding via +/// [addRenderView] to be consider during frame production, hit testing, etc. +/// Furthermore, the render tree must be managed by a [PipelineOwner] that is +/// part of the pipeline owner tree rooted at [rootPipelineOwner]. +/// +/// Adding [PipelineOwner]s and [RenderView]s to this binding in the way +/// described above is left as a responsibility for a higher level abstraction. +/// The binding does not own any [RenderView]s directly. class RenderingFlutterBinding extends BindingBase with GestureBinding, SchedulerBinding, ServicesBinding, SemanticsBinding, PaintingBinding, RendererBinding { - /// Creates a binding for the rendering layer. - /// - /// The `root` render box is attached directly to the [renderView] and is - /// given constraints that require it to fill the window. - /// - /// This binding does not automatically schedule any frames. Callers are - /// responsible for deciding when to first call [scheduleFrame]. - RenderingFlutterBinding({ RenderBox? root }) { - renderView.child = root; - } - /// Returns an instance of the binding that implements /// [RendererBinding]. If no binding has yet been initialized, the /// [RenderingFlutterBinding] class is used to create and initialize @@ -645,3 +789,82 @@ class _BindingPipelineManifold extends ChangeNotifier implements PipelineManifol super.dispose(); } } + +// A [PipelineOwner] that cannot have a root node. +class _DefaultRootPipelineOwner extends PipelineOwner { + _DefaultRootPipelineOwner() : super(onSemanticsUpdate: _onSemanticsUpdate); + + @override + set rootNode(RenderObject? _) { + assert(() { + throw FlutterError.fromParts([ + ErrorSummary( + 'Cannot set a rootNode on the default root pipeline owner.', + ), + ErrorDescription( + 'By default, the RendererBinding.rootPipelineOwner is not configured ' + 'to manage a root node because this pipeline owner does not define a ' + 'proper onSemanticsUpdate callback to handle semantics for that node.', + ), + ErrorHint( + 'Typically, the root pipeline owner does not manage a root node. ' + 'Instead, properly configured child pipeline owners (which do manage ' + 'root nodes) are added to it. Alternatively, if you do want to set a ' + 'root node for the root pipeline owner, override ' + 'RendererBinding.createRootPipelineOwner to create a ' + 'pipeline owner that is configured to properly handle semantics for ' + 'the provided root node.' + ), + ]); + }()); + } + + static void _onSemanticsUpdate(ui.SemanticsUpdate _) { + // Neve called because we don't have a root node. + assert(false); + } +} + +// Prior to multi view support, the [RendererBinding] would own a long-lived +// [RenderView], that was never disposed (see [RendererBinding.renderView]). +// With multi view support, the [RendererBinding] no longer owns a [RenderView] +// and instead higher level abstractions (like the [View] widget) can add/remove +// multiple [RenderView]s to the binding as needed. When the [View] widget is no +// longer needed, it expects to dispose its [RenderView]. +// +// This special version of a [RenderView] now exists as a bridge between those +// worlds to continue supporting the [RendererBinding.renderView] property +// through its deprecation period. Per the property's contract, it is supposed +// to be long-lived, but it is also managed by a [View] widget (introduced by +// [WidgetsBinding.wrapWithDefaultView]), that expects to dispose its render +// object at the end of the widget's life time. This special version now +// implements logic to reset the [RenderView] when it is "disposed" so it can be +// reused by another [View] widget. +// +// Once the deprecated [RendererBinding.renderView] property is removed, this +// class is no longer necessary. +class _ReusableRenderView extends RenderView { + _ReusableRenderView({required super.view}); + + bool _initialFramePrepared = false; + + @override + void prepareInitialFrame() { + if (_initialFramePrepared) { + return; + } + super.prepareInitialFrame(); + _initialFramePrepared = true; + } + + @override + void scheduleInitialSemantics() { + clearSemantics(); + super.scheduleInitialSemantics(); + } + + @override + void dispose() { // ignore: must_call_super + child = null; + } +} diff --git a/packages/flutter/lib/src/rendering/object.dart b/packages/flutter/lib/src/rendering/object.dart index b12378a9ff4..9a54bea5cbf 100644 --- a/packages/flutter/lib/src/rendering/object.dart +++ b/packages/flutter/lib/src/rendering/object.dart @@ -870,7 +870,7 @@ class _LocalSemanticsHandle implements SemanticsHandle { /// without tying it to a specific binding implementation. All [PipelineOwner]s /// in a given tree must be attached to the same [PipelineManifold]. This /// happens automatically during [adoptChild]. -class PipelineOwner { +class PipelineOwner with DiagnosticableTreeMixin { /// Creates a pipeline owner. /// /// Typically created by the binding (e.g., [RendererBinding]), but can be @@ -984,7 +984,7 @@ class PipelineOwner { return true; }()); FlutterTimeline.startSync( - 'LAYOUT', + 'LAYOUT$_debugRootSuffixForTimelineEventNames', arguments: debugTimelineArguments, ); } @@ -1071,7 +1071,7 @@ class PipelineOwner { /// [flushPaint]. void flushCompositingBits() { if (!kReleaseMode) { - FlutterTimeline.startSync('UPDATING COMPOSITING BITS'); + FlutterTimeline.startSync('UPDATING COMPOSITING BITS$_debugRootSuffixForTimelineEventNames'); } _nodesNeedingCompositingBitsUpdate.sort((RenderObject a, RenderObject b) => a.depth - b.depth); for (final RenderObject node in _nodesNeedingCompositingBitsUpdate) { @@ -1120,7 +1120,7 @@ class PipelineOwner { return true; }()); FlutterTimeline.startSync( - 'PAINT', + 'PAINT$_debugRootSuffixForTimelineEventNames', arguments: debugTimelineArguments, ); } @@ -1247,7 +1247,7 @@ class PipelineOwner { return; } if (!kReleaseMode) { - FlutterTimeline.startSync('SEMANTICS'); + FlutterTimeline.startSync('SEMANTICS$_debugRootSuffixForTimelineEventNames'); } assert(_semanticsOwner != null); assert(() { @@ -1279,6 +1279,20 @@ class PipelineOwner { } } + @override + List debugDescribeChildren() { + return [ + for (final PipelineOwner child in _children) + child.toDiagnosticsNode(), + ]; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('rootNode', rootNode, defaultValue: null)); + } + // TREE MANAGEMENT final Set _children = {}; @@ -1290,6 +1304,8 @@ class PipelineOwner { return true; } + String get _debugRootSuffixForTimelineEventNames => _debugParent == null ? ' (root)' : ''; + /// Mark this [PipelineOwner] as attached to the given [PipelineManifold]. /// /// Typically, this is only called directly on the root [PipelineOwner]. @@ -1315,7 +1331,9 @@ class PipelineOwner { assert(_manifold != null); _manifold!.removeListener(_updateSemanticsOwner); _manifold = null; - _updateSemanticsOwner(); + // Not updating the semantics owner here to not disrupt any of its clients + // in case we get re-attached. If necessary, semantics owner will be updated + // in "attach", or disposed in "dispose", if not reattached. for (final PipelineOwner child in _children) { child.detach(); @@ -1351,7 +1369,9 @@ class PipelineOwner { assert(!_children.contains(child)); assert(_debugAllowChildListModifications, 'Cannot modify child list after layout.'); _children.add(child); - assert(_debugSetParent(child, this)); + if (!kReleaseMode) { + _debugSetParent(child, this); + } if (_manifold != null) { child.attach(_manifold!); } @@ -1369,7 +1389,9 @@ class PipelineOwner { assert(_children.contains(child)); assert(_debugAllowChildListModifications, 'Cannot modify child list after layout.'); _children.remove(child); - assert(_debugSetParent(child, null)); + if (!kReleaseMode) { + _debugSetParent(child, null); + } if (_manifold != null) { child.detach(); } @@ -1384,6 +1406,26 @@ class PipelineOwner { void visitChildren(PipelineOwnerVisitor visitor) { _children.forEach(visitor); } + + /// Release any resources held by this pipeline owner. + /// + /// Prior to calling this method the pipeline owner must be removed from the + /// pipeline owner tree, i.e. it must have neither a parent nor any children + /// (see [dropChild]). It also must be [detach]ed from any [PipelineManifold]. + /// + /// The object is no longer usable after calling dispose. + void dispose() { + assert(_children.isEmpty); + assert(rootNode == null); + assert(_manifold == null); + assert(_debugParent == null); + _semanticsOwner?.dispose(); + _semanticsOwner = null; + _nodesNeedingLayout.clear(); + _nodesNeedingCompositingBitsUpdate.clear(); + _nodesNeedingPaint.clear(); + _nodesNeedingSemantics.clear(); + } } /// Signature for the callback to [PipelineOwner.visitChildren]. @@ -3919,7 +3961,6 @@ abstract class RenderObject with DiagnosticableTreeMixin implements HitTestTarge /// This mixin is typically used to implement render objects created /// in a [SingleChildRenderObjectWidget]. mixin RenderObjectWithChildMixin on RenderObject { - /// Checks whether the given render object has the correct [runtimeType] to be /// a child of this render object. /// diff --git a/packages/flutter/lib/src/rendering/view.dart b/packages/flutter/lib/src/rendering/view.dart index 906d237c1da..03db7f1d608 100644 --- a/packages/flutter/lib/src/rendering/view.dart +++ b/packages/flutter/lib/src/rendering/view.dart @@ -67,10 +67,14 @@ class RenderView extends RenderObject with RenderObjectWithChildMixin /// /// Typically created by the binding (e.g., [RendererBinding]). /// - /// The [configuration] must not be null. + /// Providing a [configuration] is optional, but a configuration must be set + /// before calling [prepareInitialFrame]. This decouples creating the + /// [RenderView] object from configuring it. Typically, the object is created + /// by the [View] widget and configured by the [RendererBinding] when the + /// [RenderView] is registered with it by the [View] widget. RenderView({ RenderBox? child, - required ViewConfiguration configuration, + ViewConfiguration? configuration, required ui.FlutterView view, }) : _configuration = configuration, _view = view { @@ -82,26 +86,39 @@ class RenderView extends RenderObject with RenderObjectWithChildMixin Size _size = Size.zero; /// The constraints used for the root layout. - ViewConfiguration get configuration => _configuration; - ViewConfiguration _configuration; - - /// The configuration is initially set by the [configuration] argument - /// passed to the constructor. /// - /// Always call [prepareInitialFrame] before changing the configuration. + /// Typically, this configuration is set by the [RendererBinding], when the + /// [RenderView] is registered with it. It will also update the configuration + /// if necessary. Therefore, if used in conjunction with the [RendererBinding] + /// this property must not be set manually as the [RendererBinding] will just + /// override it. + /// + /// For tests that want to change the size of the view, set + /// [TestFlutterView.physicalSize] on the appropriate [TestFlutterView] + /// (typically [WidgetTester.view]) instead of setting a configuration + /// directly on the [RenderView]. + ViewConfiguration get configuration => _configuration!; + ViewConfiguration? _configuration; set configuration(ViewConfiguration value) { - if (configuration == value) { + if (_configuration == value) { return; } - final ViewConfiguration oldConfiguration = _configuration; + final ViewConfiguration? oldConfiguration = _configuration; _configuration = value; - if (oldConfiguration.toMatrix() != _configuration.toMatrix()) { + if (_rootTransform == null) { + // [prepareInitialFrame] has not been called yet, nothing to do for now. + return; + } + if (oldConfiguration?.toMatrix() != configuration.toMatrix()) { replaceRootLayer(_updateMatricesAndCreateNewRootLayer()); } assert(_rootTransform != null); markNeedsLayout(); } + /// Whether a [configuration] has been set. + bool get hasConfiguration => _configuration != null; + /// The [FlutterView] into which this [RenderView] will render. ui.FlutterView get flutterView => _view; final ui.FlutterView _view; diff --git a/packages/flutter/lib/src/widgets/adapter.dart b/packages/flutter/lib/src/widgets/adapter.dart new file mode 100644 index 00000000000..1948312f7de --- /dev/null +++ b/packages/flutter/lib/src/widgets/adapter.dart @@ -0,0 +1,177 @@ +// 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 'package:flutter/rendering.dart'; + +import 'framework.dart'; + +/// A bridge from a [RenderObject] to an [Element] tree. +/// +/// The given container is the [RenderObject] that the [Element] tree should be +/// inserted into. It must be a [RenderObject] that implements the +/// [RenderObjectWithChildMixin] protocol. The type argument `T` is the kind of +/// [RenderObject] that the container expects as its child. +/// +/// The [RenderObjectToWidgetAdapter] is an alternative to [RootWidget] for +/// bootstrapping an element tree. Unlike [RootWidget] it requires the +/// existence of a render tree (the [container]) to attach the element tree to. +class RenderObjectToWidgetAdapter extends RenderObjectWidget { + /// Creates a bridge from a [RenderObject] to an [Element] tree. + RenderObjectToWidgetAdapter({ + this.child, + required this.container, + this.debugShortDescription, + }) : super(key: GlobalObjectKey(container)); + + /// The widget below this widget in the tree. + /// + /// {@macro flutter.widgets.ProxyWidget.child} + final Widget? child; + + /// The [RenderObject] that is the parent of the [Element] created by this widget. + final RenderObjectWithChildMixin container; + + /// A short description of this widget used by debugging aids. + final String? debugShortDescription; + + @override + RenderObjectToWidgetElement createElement() => RenderObjectToWidgetElement(this); + + @override + RenderObjectWithChildMixin createRenderObject(BuildContext context) => container; + + @override + void updateRenderObject(BuildContext context, RenderObject renderObject) { } + + /// Inflate this widget and actually set the resulting [RenderObject] as the + /// child of [container]. + /// + /// If `element` is null, this function will create a new element. Otherwise, + /// the given element will have an update scheduled to switch to this widget. + RenderObjectToWidgetElement attachToRenderTree(BuildOwner owner, [ RenderObjectToWidgetElement? element ]) { + if (element == null) { + owner.lockState(() { + element = createElement(); + assert(element != null); + element!.assignOwner(owner); + }); + owner.buildScope(element!, () { + element!.mount(null, null); + }); + } else { + element._newWidget = this; + element.markNeedsBuild(); + } + return element!; + } + + @override + String toStringShort() => debugShortDescription ?? super.toStringShort(); +} + +/// The root of an element tree that is hosted by a [RenderObject]. +/// +/// This element class is the instantiation of a [RenderObjectToWidgetAdapter] +/// widget. It can be used only as the root of an [Element] tree (it cannot be +/// mounted into another [Element]; it's parent must be null). +/// +/// In typical usage, it will be instantiated for a [RenderObjectToWidgetAdapter] +/// whose container is the [RenderView]. +class RenderObjectToWidgetElement extends RenderTreeRootElement with RootElementMixin { + /// Creates an element that is hosted by a [RenderObject]. + /// + /// The [RenderObject] created by this element is not automatically set as a + /// child of the hosting [RenderObject]. To actually attach this element to + /// the render tree, call [RenderObjectToWidgetAdapter.attachToRenderTree]. + RenderObjectToWidgetElement(RenderObjectToWidgetAdapter super.widget); + + Element? _child; + + static const Object _rootChildSlot = Object(); + + @override + void visitChildren(ElementVisitor visitor) { + if (_child != null) { + visitor(_child!); + } + } + + @override + void forgetChild(Element child) { + assert(child == _child); + _child = null; + super.forgetChild(child); + } + + @override + void mount(Element? parent, Object? newSlot) { + assert(parent == null); + super.mount(parent, newSlot); + _rebuild(); + assert(_child != null); + } + + @override + void update(RenderObjectToWidgetAdapter newWidget) { + super.update(newWidget); + assert(widget == newWidget); + _rebuild(); + } + + // When we are assigned a new widget, we store it here + // until we are ready to update to it. + Widget? _newWidget; + + @override + void performRebuild() { + if (_newWidget != null) { + // _newWidget can be null if, for instance, we were rebuilt + // due to a reassemble. + final Widget newWidget = _newWidget!; + _newWidget = null; + update(newWidget as RenderObjectToWidgetAdapter); + } + super.performRebuild(); + assert(_newWidget == null); + } + + @pragma('vm:notify-debugger-on-exception') + void _rebuild() { + try { + _child = updateChild(_child, (widget as RenderObjectToWidgetAdapter).child, _rootChildSlot); + } catch (exception, stack) { + final FlutterErrorDetails details = FlutterErrorDetails( + exception: exception, + stack: stack, + library: 'widgets library', + context: ErrorDescription('attaching to the render tree'), + ); + FlutterError.reportError(details); + final Widget error = ErrorWidget.builder(details); + _child = updateChild(null, error, _rootChildSlot); + } + } + + @override + RenderObjectWithChildMixin get renderObject => super.renderObject as RenderObjectWithChildMixin; + + @override + void insertRenderObjectChild(RenderObject child, Object? slot) { + assert(slot == _rootChildSlot); + assert(renderObject.debugValidateChild(child)); + renderObject.child = child as T; + } + + @override + void moveRenderObjectChild(RenderObject child, Object? oldSlot, Object? newSlot) { + assert(false); + } + + @override + void removeRenderObjectChild(RenderObject child, Object? slot) { + assert(renderObject.child == child); + renderObject.child = null; + } +} diff --git a/packages/flutter/lib/src/widgets/binding.dart b/packages/flutter/lib/src/widgets/binding.dart index 8750f0ef240..f4778adfb30 100644 --- a/packages/flutter/lib/src/widgets/binding.dart +++ b/packages/flutter/lib/src/widgets/binding.dart @@ -280,6 +280,48 @@ abstract mixin class WidgetsBindingObserver { } /// The glue between the widgets layer and the Flutter engine. +/// +/// The [WidgetsBinding] manages a single [Element] tree rooted at [rootElement]. +/// Calling [runApp] (which indirectly calls [attachRootWidget]) bootstraps that +/// element tree. +/// +/// ## Relationship to render trees +/// +/// Multiple render trees may be associated with the element tree. Those are +/// managed by the underlying [RendererBinding]. +/// +/// The element tree is segmented into two types of zones: rendering zones and +/// non-rendering zones. +/// +/// A rendering zone is a part of the element tree that is backed by a render +/// tree and it describes the pixels that are drawn on screen. For elements in +/// this zone, [Element.renderObject] never returns null because the elements +/// are all associated with [RenderObject]s. Almost all widgets can be placed in +/// a rendering zone; notable exceptions are the [View] widget, [ViewCollection] +/// widget, and [RootWidget]. +/// +/// A non-rendering zone is a part of the element tree that is not backed by a +/// render tree. For elements in this zone, [Element.renderObject] returns null +/// because the elements are not associated with any [RenderObject]s. Only +/// widgets that do not produce a [RenderObject] can be used in this zone +/// because there is no render tree to attach the render object to. In other +/// words, [RenderObjectWidget]s cannot be used in this zone. Typically, one +/// would find [InheritedWidget]s, [View]s, and [ViewCollection]s in this zone +/// to inject data across rendering zones into the tree and to organize the +/// rendering zones (and by extension their associated render trees) into a +/// unified element tree. +/// +/// The root of the element tree at [rootElement] starts a non-rendering zone. +/// Within a non-rendering zone, the [View] widget is used to start a rendering +/// zone by bootstrapping a render tree. Within a rendering zone, the +/// [ViewAnchor] can be used to start a new non-rendering zone. +/// +// TODO(goderbauer): Include an example graph showcasing the different zones. +/// +/// To figure out if an element is in a rendering zone it may walk up the tree +/// calling [Element.debugExpectsRenderObjectForSlot] on its ancestors. If it +/// reaches an element that returns false, it is in a non-rendering zone. If it +/// reaches a [RenderObjectElement] ancestor it is in a rendering zone. mixin WidgetsBinding on BindingBase, ServicesBinding, SchedulerBinding, GestureBinding, RendererBinding, SemanticsBinding { @override void initInstances() { @@ -975,6 +1017,8 @@ mixin WidgetsBinding on BindingBase, ServicesBinding, SchedulerBinding, GestureB Widget wrapWithDefaultView(Widget rootWidget) { return View( view: platformDispatcher.implicitView!, + deprecatedDoNotUseWillBeRemovedWithoutNoticePipelineOwner: pipelineOwner, + deprecatedDoNotUseWillBeRemovedWithoutNoticeRenderView: renderView, child: rootWidget, ); } @@ -1000,13 +1044,25 @@ mixin WidgetsBinding on BindingBase, ServicesBinding, SchedulerBinding, GestureB /// * [RenderObjectToWidgetAdapter.attachToRenderTree], which inflates a /// widget and attaches it to the render tree. void attachRootWidget(Widget rootWidget) { - final bool isBootstrapFrame = rootElement == null; - _readyToProduceFrames = true; - _rootElement = RenderObjectToWidgetAdapter( - container: renderView, + attachToBuildOwner(RootWidget( debugShortDescription: '[root]', child: rootWidget, - ).attachToRenderTree(buildOwner!, rootElement as RenderObjectToWidgetElement?); + )); + } + + /// Called by [attachRootWidget] to attach the provided [RootWidget] to the + /// [buildOwner]. + /// + /// This creates the [rootElement], if necessary, or re-uses an existing one. + /// + /// This method is rarely called directly, but it can be useful in tests to + /// restore the element tree to a previous version by providing the + /// [RootWidget] of that version (see [WidgetTester.restartAndRestore] for an + /// exemplary use case). + void attachToBuildOwner(RootWidget widget) { + final bool isBootstrapFrame = rootElement == null; + _readyToProduceFrames = true; + _rootElement = widget.attach(buildOwner!, rootElement as RootElement?); if (isBootstrapFrame) { SchedulerBinding.instance.ensureVisualUpdate(); } @@ -1121,52 +1177,40 @@ void debugDumpApp() { debugPrint(_debugDumpAppString()); } -/// A bridge from a [RenderObject] to an [Element] tree. +/// A widget for the root of the widget tree. /// -/// The given container is the [RenderObject] that the [Element] tree should be -/// inserted into. It must be a [RenderObject] that implements the -/// [RenderObjectWithChildMixin] protocol. The type argument `T` is the kind of -/// [RenderObject] that the container expects as its child. +/// Exposes an [attach] method to attach the widget tree to a [BuildOwner]. That +/// method also bootstraps the element tree. /// -/// Used by [runApp] to bootstrap applications. -class RenderObjectToWidgetAdapter extends RenderObjectWidget { - /// Creates a bridge from a [RenderObject] to an [Element] tree. - /// - /// Used by [WidgetsBinding] to attach the root widget to the [RenderView]. - RenderObjectToWidgetAdapter({ +/// Used by [WidgetsBinding.attachRootWidget] (which is indirectly called by +/// [runApp]) to bootstrap applications. +class RootWidget extends Widget { + /// Creates a [RootWidget]. + const RootWidget({ + super.key, this.child, - required this.container, this.debugShortDescription, - }) : super(key: GlobalObjectKey(container)); + }); /// The widget below this widget in the tree. /// /// {@macro flutter.widgets.ProxyWidget.child} final Widget? child; - /// The [RenderObject] that is the parent of the [Element] created by this widget. - final RenderObjectWithChildMixin container; - /// A short description of this widget used by debugging aids. final String? debugShortDescription; @override - RenderObjectToWidgetElement createElement() => RenderObjectToWidgetElement(this); + RootElement createElement() => RootElement(this); - @override - RenderObjectWithChildMixin createRenderObject(BuildContext context) => container; - - @override - void updateRenderObject(BuildContext context, RenderObject renderObject) { } - - /// Inflate this widget and actually set the resulting [RenderObject] as the - /// child of [container]. + /// Inflate this widget and attaches it to the provided [BuildOwner]. /// /// If `element` is null, this function will create a new element. Otherwise, /// the given element will have an update scheduled to switch to this widget. /// - /// Used by [runApp] to bootstrap applications. - RenderObjectToWidgetElement attachToRenderTree(BuildOwner owner, [ RenderObjectToWidgetElement? element ]) { + /// Used by [WidgetsBinding.attachToBuildOwner] (which is indirectly called by + /// [runApp]) to bootstrap applications. + RootElement attach(BuildOwner owner, [ RootElement? element ]) { if (element == null) { owner.lockState(() { element = createElement(); @@ -1174,7 +1218,7 @@ class RenderObjectToWidgetAdapter extends RenderObjectWi element!.assignOwner(owner); }); owner.buildScope(element!, () { - element!.mount(null, null); + element!.mount(/* parent */ null, /* slot */ null); }); } else { element._newWidget = this; @@ -1187,28 +1231,22 @@ class RenderObjectToWidgetAdapter extends RenderObjectWi String toStringShort() => debugShortDescription ?? super.toStringShort(); } -/// The root of the element tree that is hosted by a [RenderObject]. +/// The root of the element tree. /// -/// This element class is the instantiation of a [RenderObjectToWidgetAdapter] -/// widget. It can be used only as the root of an [Element] tree (it cannot be -/// mounted into another [Element]; it's parent must be null). +/// This element class is the instantiation of a [RootWidget]. It can be used +/// only as the root of an [Element] tree (it cannot be mounted into another +/// [Element]; its parent must be null). /// -/// In typical usage, it will be instantiated for a [RenderObjectToWidgetAdapter] -/// whose container is the [RenderView] that connects to the Flutter engine. In -/// this usage, it is normally instantiated by the bootstrapping logic in the -/// [WidgetsFlutterBinding] singleton created by [runApp]. -class RenderObjectToWidgetElement extends RenderObjectElement with RootElementMixin { - /// Creates an element that is hosted by a [RenderObject]. - /// - /// The [RenderObject] created by this element is not automatically set as a - /// child of the hosting [RenderObject]. To actually attach this element to - /// the render tree, call [RenderObjectToWidgetAdapter.attachToRenderTree]. - RenderObjectToWidgetElement(RenderObjectToWidgetAdapter super.widget); +/// In typical usage, it will be instantiated for a [RootWidget] by calling +/// [RootWidget.attach]. In this usage, it is normally instantiated by the +/// bootstrapping logic in the [WidgetsFlutterBinding] singleton created by +/// [runApp]. +class RootElement extends Element with RootElementMixin { + /// Creates a [RootElement] for the provided [RootWidget]. + RootElement(RootWidget super.widget); Element? _child; - static const Object _rootChildSlot = Object(); - @override void visitChildren(ElementVisitor visitor) { if (_child != null) { @@ -1225,14 +1263,15 @@ class RenderObjectToWidgetElement extends RenderObjectEl @override void mount(Element? parent, Object? newSlot) { - assert(parent == null); + assert(parent == null); // We are the root! super.mount(parent, newSlot); _rebuild(); assert(_child != null); + super.performRebuild(); // clears the "dirty" flag } @override - void update(RenderObjectToWidgetAdapter newWidget) { + void update(RootWidget newWidget) { super.update(newWidget); assert(widget == newWidget); _rebuild(); @@ -1240,25 +1279,24 @@ class RenderObjectToWidgetElement extends RenderObjectEl // When we are assigned a new widget, we store it here // until we are ready to update to it. - Widget? _newWidget; + RootWidget? _newWidget; @override void performRebuild() { if (_newWidget != null) { // _newWidget can be null if, for instance, we were rebuilt // due to a reassemble. - final Widget newWidget = _newWidget!; + final RootWidget newWidget = _newWidget!; _newWidget = null; - update(newWidget as RenderObjectToWidgetAdapter); + update(newWidget); } super.performRebuild(); assert(_newWidget == null); } - @pragma('vm:notify-debugger-on-exception') void _rebuild() { try { - _child = updateChild(_child, (widget as RenderObjectToWidgetAdapter).child, _rootChildSlot); + _child = updateChild(_child, (widget as RootWidget).child, /* slot */ null); } catch (exception, stack) { final FlutterErrorDetails details = FlutterErrorDetails( exception: exception, @@ -1267,31 +1305,18 @@ class RenderObjectToWidgetElement extends RenderObjectEl context: ErrorDescription('attaching to the render tree'), ); FlutterError.reportError(details); - final Widget error = ErrorWidget.builder(details); - _child = updateChild(null, error, _rootChildSlot); + // No error widget possible here since it wouldn't have a view to render into. + _child = null; } + } @override - RenderObjectWithChildMixin get renderObject => super.renderObject as RenderObjectWithChildMixin; + bool get debugDoingBuild => false; // This element doesn't have a build phase. @override - void insertRenderObjectChild(RenderObject child, Object? slot) { - assert(slot == _rootChildSlot); - assert(renderObject.debugValidateChild(child)); - renderObject.child = child as T; - } - - @override - void moveRenderObjectChild(RenderObject child, Object? oldSlot, Object? newSlot) { - assert(false); - } - - @override - void removeRenderObjectChild(RenderObject child, Object? slot) { - assert(renderObject.child == child); - renderObject.child = null; - } + // There is no ancestor RenderObjectElement that the render object could be attached to. + bool debugExpectsRenderObjectForSlot(Object? slot) => false; } /// A concrete binding for applications based on the Widgets framework. diff --git a/packages/flutter/lib/src/widgets/framework.dart b/packages/flutter/lib/src/widgets/framework.dart index 2331ea9d31d..da4132c16a5 100644 --- a/packages/flutter/lib/src/widgets/framework.dart +++ b/packages/flutter/lib/src/widgets/framework.dart @@ -3452,6 +3452,11 @@ abstract class Element extends DiagnosticableTree implements BuildContext { /// If this object is a [RenderObjectElement], the render object is the one at /// this location in the tree. Otherwise, this getter will walk down the tree /// until it finds a [RenderObjectElement]. + /// + /// Some locations in the tree are not backed by a render object. In those + /// cases, this getter returns null. This can happen, if the element is + /// located outside of a [View] since only the element subtree rooted in a + /// view has a render tree associated with it. RenderObject? get renderObject { Element? current = this; while (current != null) { @@ -3460,17 +3465,33 @@ abstract class Element extends DiagnosticableTree implements BuildContext { } else if (current is RenderObjectElement) { return current.renderObject; } else { - Element? next; - current.visitChildren((Element child) { - assert(next == null); // This verifies that there's only one child. - next = child; - }); - current = next; + current = current.renderObjectAttachingChild; } } return null; } + /// Returns the child of this [Element] that will insert a [RenderObject] into + /// an ancestor of this Element to construct the render tree. + /// + /// Returns null if this Element doesn't have any children who need to attach + /// a [RenderObject] to an ancestor of this [Element]. A [RenderObjectElement] + /// will therefore return null because its children insert their + /// [RenderObject]s into the [RenderObjectElement] itself and not into an + /// ancestor of the [RenderObjectElement]. + /// + /// Furthermore, this may return null for [Element]s that hoist their own + /// independent render tree and do not extend the ancestor render tree. + @protected + Element? get renderObjectAttachingChild { + Element? next; + visitChildren((Element child) { + assert(next == null); // This verifies that there's only one child. + next = child; + }); + return next; + } + @override List describeMissingAncestor({ required Type expectedAncestorType }) { final List information = []; @@ -4021,15 +4042,20 @@ abstract class Element extends DiagnosticableTree implements BuildContext { assert(_lifecycleState == _ElementLifecycle.active); assert(child._parent == this); void visit(Element element) { - element._updateSlot(newSlot); - if (element is! RenderObjectElement) { - element.visitChildren(visit); + element.updateSlot(newSlot); + final Element? descendant = element.renderObjectAttachingChild; + if (descendant != null) { + visit(descendant); } } visit(child); } - void _updateSlot(Object? newSlot) { + /// Called by [updateSlotForChild] when the framework needs to change the slot + /// that this [Element] occupies in its ancestor. + @protected + @mustCallSuper + void updateSlot(Object? newSlot) { assert(_lifecycleState == _ElementLifecycle.active); assert(_parent != null); assert(_parent!._lifecycleState == _ElementLifecycle.active); @@ -4070,7 +4096,7 @@ abstract class Element extends DiagnosticableTree implements BuildContext { /// /// The `newSlot` argument specifies the new value for this element's [slot]. void attachRenderObject(Object? newSlot) { - assert(_slot == null); + assert(slot == null); visitChildren((Element child) { child.attachRenderObject(newSlot); }); @@ -4143,7 +4169,6 @@ abstract class Element extends DiagnosticableTree implements BuildContext { @protected @pragma('vm:prefer-inline') Element inflateWidget(Widget newWidget, Object? newSlot) { - final bool isTimelineTracked = !kReleaseMode && _isProfileBuildsEnabledFor(newWidget); if (isTimelineTracked) { Map? debugTimelineArguments; @@ -4169,7 +4194,17 @@ abstract class Element extends DiagnosticableTree implements BuildContext { _debugCheckForCycles(newChild); return true; }()); - newChild._activateWithParent(this, newSlot); + try { + newChild._activateWithParent(this, newSlot); + } catch (_) { + // Attempt to do some clean-up if activation fails to leave tree in a reasonable state. + try { + deactivateChild(newChild); + } catch (_) { + // Clean-up failed. Only surface original exception. + } + rethrow; + } final Element? updatedChild = updateChild(newChild, newWidget, newSlot); assert(newChild == updatedChild); return updatedChild!; @@ -4404,6 +4439,33 @@ abstract class Element extends DiagnosticableTree implements BuildContext { _lifecycleState = _ElementLifecycle.defunct; } + /// Whether the child in the provided `slot` (or one of its descendants) must + /// insert a [RenderObject] into its ancestor [RenderObjectElement] by calling + /// [RenderObjectElement.insertRenderObjectChild] on it. + /// + /// This method is used to define non-rendering zones in the element tree (see + /// [WidgetsBinding] for an explanation of rendering and non-rendering zones): + /// + /// Most branches of the [Element] tree are expected to eventually insert a + /// [RenderObject] into their [RenderObjectElement] ancestor to construct the + /// render tree. However, there is a notable exception: an [Element] may + /// expect that the occupant of a certain child slot creates a new independent + /// render tree and therefore is not allowed to insert a render object into + /// the existing render tree. Those elements must return false from this + /// method for the slot in question to signal to the child in that slot that + /// it must not call [RenderObjectElement.insertRenderObjectChild] on its + /// ancestor. + /// + /// As an example, the element backing the [ViewAnchor] returns false from + /// this method for the [ViewAnchor.view] slot to enforce that it is occupied + /// by e.g. a [View] widget, which will ultimately bootstrap a separate + /// render tree for that view. Another example is the [ViewCollection] widget, + /// which returns false for all its slots for the same reason. + /// + /// Overriding this method is not common, as elements behaving in the way + /// described above are rare. + bool debugExpectsRenderObjectForSlot(Object? slot) => true; + @override RenderObject? findRenderObject() { assert(() { @@ -5266,6 +5328,9 @@ abstract class ComponentElement extends Element { @override bool get debugDoingBuild => _debugDoingBuild; + @override + Element? get renderObjectAttachingChild => _child; + @override void mount(Element? parent, Object? newSlot) { super.mount(parent, newSlot); @@ -6073,6 +6138,9 @@ abstract class RenderObjectElement extends Element { } RenderObject? _renderObject; + @override + Element? get renderObjectAttachingChild => null; + bool _debugDoingBuild = false; @override bool get debugDoingBuild => _debugDoingBuild; @@ -6082,8 +6150,25 @@ abstract class RenderObjectElement extends Element { RenderObjectElement? _findAncestorRenderObjectElement() { Element? ancestor = _parent; while (ancestor != null && ancestor is! RenderObjectElement) { - ancestor = ancestor._parent; + // In debug mode we check whether the ancestor accepts RenderObjects to + // produce a better error message in attachRenderObject. In release mode, + // we assume only correct trees are built (i.e. + // debugExpectsRenderObjectForSlot always returns true) and don't check + // explicitly. + assert(() { + if (!ancestor!.debugExpectsRenderObjectForSlot(slot)) { + ancestor = null; + } + return true; + }()); + ancestor = ancestor?._parent; } + assert(() { + if (ancestor?.debugExpectsRenderObjectForSlot(slot) == false) { + ancestor = null; + } + return true; + }()); return ancestor as RenderObjectElement?; } @@ -6151,7 +6236,7 @@ abstract class RenderObjectElement extends Element { _debugUpdateRenderObjectOwner(); return true; }()); - assert(_slot == newSlot); + assert(slot == newSlot); attachRenderObject(newSlot); super.performRebuild(); // clears the "dirty" flag } @@ -6252,12 +6337,13 @@ abstract class RenderObjectElement extends Element { } @override - void _updateSlot(Object? newSlot) { + void updateSlot(Object? newSlot) { final Object? oldSlot = slot; assert(oldSlot != newSlot); - super._updateSlot(newSlot); + super.updateSlot(newSlot); assert(slot == newSlot); - _ancestorRenderObjectElement!.moveRenderObjectChild(renderObject, oldSlot, slot); + assert(_ancestorRenderObjectElement == _findAncestorRenderObjectElement()); + _ancestorRenderObjectElement?.moveRenderObjectChild(renderObject, oldSlot, slot); } @override @@ -6265,6 +6351,25 @@ abstract class RenderObjectElement extends Element { assert(_ancestorRenderObjectElement == null); _slot = newSlot; _ancestorRenderObjectElement = _findAncestorRenderObjectElement(); + assert(() { + if (_ancestorRenderObjectElement == null) { + FlutterError.reportError(FlutterErrorDetails(exception: FlutterError.fromParts( + [ + ErrorSummary( + 'The render object for ${toStringShort()} cannot find ancestor render object to attach to.', + ), + ErrorDescription( + 'The ownership chain for the RenderObject in question was:\n ${debugGetCreatorChain(10)}', + ), + ErrorHint( + 'Try wrapping your widget in a View widget or any other widget that is backed by ' + 'a $RenderTreeRootElement to serve as the root of the render tree.', + ), + ] + ))); + } + return true; + }()); _ancestorRenderObjectElement?.insertRenderObjectChild(renderObject, newSlot); final ParentDataElement? parentDataElement = _findAncestorParentDataElement(); if (parentDataElement != null) { @@ -6597,6 +6702,67 @@ class MultiChildRenderObjectElement extends RenderObjectElement { } } +/// A [RenderObjectElement] used to manage the root of a render tree. +/// +/// Unlike any other render object element this element does not attempt to +/// attach its [renderObject] to the closest ancestor [RenderObjectElement]. +/// Instead, subclasses must override [attachRenderObject] and +/// [detachRenderObject] to attach/detach the [renderObject] to whatever +/// instance manages the render tree (e.g. by assigning it to +/// [PipelineOwner.rootNode]). +abstract class RenderTreeRootElement extends RenderObjectElement { + /// Creates an element that uses the given widget as its configuration. + RenderTreeRootElement(super.widget); + + @override + @mustCallSuper + void attachRenderObject(Object? newSlot) { + _slot = newSlot; + assert(_debugCheckMustNotAttachRenderObjectToAncestor()); + } + + @override + @mustCallSuper + void detachRenderObject() { + _slot = null; + } + + @override + void updateSlot(Object? newSlot) { + super.updateSlot(newSlot); + assert(_debugCheckMustNotAttachRenderObjectToAncestor()); + } + + bool _debugCheckMustNotAttachRenderObjectToAncestor() { + if (!kDebugMode) { + return true; + } + if (_findAncestorRenderObjectElement() != null) { + throw FlutterError.fromParts( + [ + ErrorSummary( + 'The RenderObject for ${toStringShort()} cannot maintain an independent render tree at its current location.', + ), + ErrorDescription( + 'The ownership chain for the RenderObject in question was:\n ${debugGetCreatorChain(10)}', + ), + ErrorDescription( + 'This RenderObject is the root of an independent render tree and it cannot ' + 'attach itself to an ancestor in an existing tree. The ancestor RenderObject, ' + '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.', + ), + ], + ); + } + return true; + } +} + /// A wrapper class for the [Element] that is the creator of a [RenderObject]. /// /// Setting a [DebugCreator] as [RenderObject.debugCreator] will lead to better diff --git a/packages/flutter/lib/src/widgets/semantics_debugger.dart b/packages/flutter/lib/src/widgets/semantics_debugger.dart index c51ecf9a1ac..24a12d06e7f 100644 --- a/packages/flutter/lib/src/widgets/semantics_debugger.dart +++ b/packages/flutter/lib/src/widgets/semantics_debugger.dart @@ -47,25 +47,31 @@ class SemanticsDebugger extends StatefulWidget { } class _SemanticsDebuggerState extends State with WidgetsBindingObserver { - late _SemanticsClient _client; + _SemanticsClient? _client; + PipelineOwner? _pipelineOwner; @override void initState() { super.initState(); - // TODO(abarth): We shouldn't reach out to the WidgetsBinding.instance - // static here because we might not be in a tree that's attached to that - // binding. Instead, we should find a way to get to the PipelineOwner from - // the BuildContext. - _client = _SemanticsClient(WidgetsBinding.instance.pipelineOwner) - ..addListener(_update); WidgetsBinding.instance.addObserver(this); } + @override + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final PipelineOwner newOwner = View.pipelineOwnerOf(context); + if (newOwner != _pipelineOwner) { + _client?.dispose(); + _client = _SemanticsClient(newOwner) + ..addListener(_update); + _pipelineOwner = newOwner; + } + } + @override void dispose() { - _client - ..removeListener(_update) - ..dispose(); + _client?.dispose(); WidgetsBinding.instance.removeObserver(this); super.dispose(); } @@ -145,19 +151,15 @@ class _SemanticsDebuggerState extends State with WidgetsBindi } void _performAction(Offset position, SemanticsAction action) { - _pipelineOwner.semanticsOwner?.performActionAt(position, action); + _pipelineOwner?.semanticsOwner?.performActionAt(position, action); } - // TODO(abarth): This shouldn't be a static. We should get the pipeline owner - // from [context] somehow. - PipelineOwner get _pipelineOwner => WidgetsBinding.instance.pipelineOwner; - @override Widget build(BuildContext context) { return CustomPaint( foregroundPainter: _SemanticsDebuggerPainter( - _pipelineOwner, - _client.generation, + _pipelineOwner!, + _client!.generation, _lastPointerDownLocation, // in physical pixels View.of(context).devicePixelRatio, widget.labelStyle, diff --git a/packages/flutter/lib/src/widgets/view.dart b/packages/flutter/lib/src/widgets/view.dart index 14f87c6d10d..b25558b6c54 100644 --- a/packages/flutter/lib/src/widgets/view.dart +++ b/packages/flutter/lib/src/widgets/view.dart @@ -2,48 +2,89 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'dart:ui' show FlutterView; +import 'dart:collection'; +import 'dart:ui' show FlutterView, SemanticsUpdate; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; import 'framework.dart'; import 'lookup_boundary.dart'; import 'media_query.dart'; -/// Injects a [FlutterView] into the tree and makes it available to descendants -/// within the same [LookupBoundary] via [View.of] and [View.maybeOf]. +/// Bootstraps a render tree that is rendered into the provided [FlutterView]. +/// +/// The content rendered into that view is determined by the provided [child]. +/// Descendants within the same [LookupBoundary] can look up the view they are +/// rendered into via [View.of] and [View.maybeOf]. /// /// The provided [child] is wrapped in a [MediaQuery] constructed from the given /// [view]. /// -/// In a future version of Flutter, the functionality of this widget will be -/// extended to actually bootstrap the render tree that is going to be rendered -/// into the provided [view]. This will enable rendering content into multiple -/// [FlutterView]s from a single widget tree. -/// /// Each [FlutterView] can be associated with at most one [View] widget in the /// widget tree. Two or more [View] widgets configured with the same /// [FlutterView] must never exist within the same widget tree at the same time. -/// Internally, this limitation is enforced by a [GlobalObjectKey] that derives -/// its identity from the [view] provided to this widget. +/// This limitation is enforced by a [GlobalObjectKey] that derives its identity +/// from the [view] provided to this widget. +/// +/// Since the [View] widget bootstraps its own independent render tree, neither +/// it nor any of its descendants will insert a [RenderObject] into an existing +/// render tree. Therefore, the [View] widget can only be used in those parts of +/// the widget tree where it is not required to participate in the construction +/// of the surrounding render tree. In other words, the widget may only be used +/// in a non-rendering zone of the widget tree (see [WidgetsBinding] for a +/// definition of rendering and non-rendering zones). +/// +/// In practical terms, the widget is typically used at the root of the widget +/// tree outside of any other [View] widget, as a child of a [ViewCollection] +/// widget, or in the [ViewAnchor.view] slot of a [ViewAnchor] widget. It is not +/// required to be a direct child, though, since other non-[RenderObjectWidget]s +/// (e.g. [InheritedWidget]s, [Builder]s, or [StatefulWidget]s/[StatelessWidget] +/// that only produce non-[RenderObjectWidget]s) are allowed to be present +/// between those widgets and the [View] widget. +/// +/// See also: +/// +/// * [Element.debugExpectsRenderObjectForSlot], which defines whether a [View] +/// widget is allowed in a given child slot. class View extends StatelessWidget { - /// Injects the provided [view] into the widget tree. - View({required this.view, required this.child}) : super(key: GlobalObjectKey(view)); + /// Create a [View] widget to bootstrap a render tree that is rendered into + /// the provided [FlutterView]. + /// + /// The content rendered into that [view] is determined by the given [child] + /// widget. + View({ + super.key, + required this.view, + @Deprecated( + 'Do not use. ' + 'This parameter only exists to implement the deprecated RendererBinding.pipelineOwner property until it is removed. ' + 'This feature was deprecated after v3.10.0-12.0.pre.' + ) + PipelineOwner? deprecatedDoNotUseWillBeRemovedWithoutNoticePipelineOwner, + @Deprecated( + 'Do not use. ' + 'This parameter only exists to implement the deprecated RendererBinding.renderView property until it is removed. ' + 'This feature was deprecated after v3.10.0-12.0.pre.' + ) + RenderView? deprecatedDoNotUseWillBeRemovedWithoutNoticeRenderView, + required this.child, + }) : _deprecatedPipelineOwner = deprecatedDoNotUseWillBeRemovedWithoutNoticePipelineOwner, + _deprecatedRenderView = deprecatedDoNotUseWillBeRemovedWithoutNoticeRenderView, + assert((deprecatedDoNotUseWillBeRemovedWithoutNoticePipelineOwner == null) == (deprecatedDoNotUseWillBeRemovedWithoutNoticeRenderView == null)), + assert(deprecatedDoNotUseWillBeRemovedWithoutNoticeRenderView == null || deprecatedDoNotUseWillBeRemovedWithoutNoticeRenderView.flutterView == view); - /// The [FlutterView] to be injected into the tree. + /// The [FlutterView] into which [child] is drawn. final FlutterView view; + /// The widget below this widget in the tree, which will be drawn into the + /// [view]. + /// /// {@macro flutter.widgets.ProxyWidget.child} final Widget child; - @override - Widget build(BuildContext context) { - return _ViewScope( - view: view, - child: MediaQuery.fromView( - view: view, - child: child, - ), - ); - } + final PipelineOwner? _deprecatedPipelineOwner; + final RenderView? _deprecatedRenderView; /// Returns the [FlutterView] that the provided `context` will render into. /// @@ -106,13 +147,588 @@ class View extends StatelessWidget { }()); return result!; } + + /// Returns the [PipelineOwner] parent to which a child [View] should attach + /// its [PipelineOwner] to. + /// + /// If `context` has a [View] ancestor, it returns the [PipelineOwner] + /// responsible for managing the render tree of that view. If there is no + /// [View] ancestor, [RendererBinding.rootPipelineOwner] is returned instead. + static PipelineOwner pipelineOwnerOf(BuildContext context) { + return context.dependOnInheritedWidgetOfExactType<_PipelineOwnerScope>()?.pipelineOwner + ?? RendererBinding.instance.rootPipelineOwner; + } + + @override + Widget build(BuildContext context) { + return _RawView( + view: view, + deprecatedPipelineOwner: _deprecatedPipelineOwner, + deprecatedRenderView: _deprecatedRenderView, + builder: (BuildContext context, PipelineOwner owner) { + return _ViewScope( + view: view, + child: _PipelineOwnerScope( + pipelineOwner: owner, + child: MediaQuery.fromView( + view: view, + child: child, + ), + ), + ); + } + ); + } +} + +/// A builder for the content [Widget] of a [_RawView]. +/// +/// The widget returned by the builder defines the content that is drawn into +/// the [FlutterView] configured on the [_RawView]. +/// +/// The builder is given the [PipelineOwner] that the [_RawView] uses to manage +/// its render tree. Typical builder implementations make that pipeline owner +/// available as an attachment point for potential child views. +/// +/// Used by [_RawView.builder]. +typedef _RawViewContentBuilder = Widget Function(BuildContext context, PipelineOwner owner); + +/// The workhorse behind the [View] widget that actually bootstraps a render +/// tree. +/// +/// It instantiates the [RenderView] as the root of that render tree and adds it +/// to the [RendererBinding] via [RendererBinding.addRenderView]. It also owns +/// the [PipelineOwner] that manages this render tree and adds it as a child to +/// the surrounding parent [PipelineOwner] obtained with [View.pipelineOwnerOf]. +/// This ensures that the render tree bootstrapped by this widget participates +/// properly in frame production and hit testing. +class _RawView extends RenderObjectWidget { + /// Create a [RawView] widget to bootstrap a render tree that is rendered into + /// the provided [FlutterView]. + /// + /// The content rendered into that [view] is determined by the [Widget] + /// returned by [builder]. + _RawView({ + required this.view, + required PipelineOwner? deprecatedPipelineOwner, + required RenderView? deprecatedRenderView, + required this.builder, + }) : _deprecatedPipelineOwner = deprecatedPipelineOwner, + _deprecatedRenderView = deprecatedRenderView, + assert(deprecatedRenderView == null || deprecatedRenderView.flutterView == view), + // TODO(goderbauer): Replace this with GlobalObjectKey(view) when the deprecated properties are removed. + super(key: _DeprecatedRawViewKey(view, deprecatedPipelineOwner, deprecatedRenderView)); + + /// The [FlutterView] into which the [Widget] returned by [builder] is drawn. + final FlutterView view; + + /// Determines the content [Widget] that is drawn into the [view]. + /// + /// The [builder] is given the [PipelineOwner] responsible for the render tree + /// bootstrapped by this widget and typically makes it available as an + /// attachment point for potential child views. + final _RawViewContentBuilder builder; + + final PipelineOwner? _deprecatedPipelineOwner; + final RenderView? _deprecatedRenderView; + + @override + RenderObjectElement createElement() => _RawViewElement(this); + + @override + RenderObject createRenderObject(BuildContext context) { + return _deprecatedRenderView ?? RenderView( + view: view, + ); + } + + // No need to implement updateRenderObject: RawView uses the view as a + // GlobalKey, so we never need to update the RenderObject with a new view. +} + +class _RawViewElement extends RenderTreeRootElement { + _RawViewElement(super.widget); + + late final PipelineOwner _pipelineOwner = PipelineOwner( + onSemanticsOwnerCreated: _handleSemanticsOwnerCreated, + onSemanticsUpdate: _handleSemanticsUpdate, + onSemanticsOwnerDisposed: _handleSemanticsOwnerDisposed, + ); + + PipelineOwner get _effectivePipelineOwner => (widget as _RawView)._deprecatedPipelineOwner ?? _pipelineOwner; + + void _handleSemanticsOwnerCreated() { + (_effectivePipelineOwner.rootNode as RenderView?)?.scheduleInitialSemantics(); + } + + void _handleSemanticsOwnerDisposed() { + (_effectivePipelineOwner.rootNode as RenderView?)?.clearSemantics(); + } + + void _handleSemanticsUpdate(SemanticsUpdate update) { + (widget as _RawView).view.updateSemantics(update); + } + + @override + RenderView get renderObject => super.renderObject as RenderView; + + Element? _child; + + void _updateChild() { + try { + final Widget child = (widget as _RawView).builder(this, _effectivePipelineOwner); + _child = updateChild(_child, child, null); + } catch (e, stack) { + final FlutterErrorDetails details = FlutterErrorDetails( + exception: e, + stack: stack, + library: 'widgets library', + context: ErrorDescription('building $this'), + informationCollector: !kDebugMode ? null : () => [ + DiagnosticsDebugCreator(DebugCreator(this)), + ], + ); + FlutterError.reportError(details); + final Widget error = ErrorWidget.builder(details); + _child = updateChild(null, error, slot); + } + } + + @override + void mount(Element? parent, Object? newSlot) { + super.mount(parent, newSlot); + assert(_effectivePipelineOwner.rootNode == null); + _effectivePipelineOwner.rootNode = renderObject; + _attachView(); + _updateChild(); + renderObject.prepareInitialFrame(); + if (_effectivePipelineOwner.semanticsOwner != null) { + renderObject.scheduleInitialSemantics(); + } + } + + PipelineOwner? _parentPipelineOwner; // Is null if view is currently not attached. + + void _attachView([PipelineOwner? parentPipelineOwner]) { + assert(_parentPipelineOwner == null); + parentPipelineOwner ??= View.pipelineOwnerOf(this); + parentPipelineOwner.adoptChild(_effectivePipelineOwner); + RendererBinding.instance.addRenderView(renderObject); + _parentPipelineOwner = parentPipelineOwner; + } + + void _detachView() { + final PipelineOwner? parentPipelineOwner = _parentPipelineOwner; + if (parentPipelineOwner != null) { + RendererBinding.instance.removeRenderView(renderObject); + parentPipelineOwner.dropChild(_effectivePipelineOwner); + _parentPipelineOwner = null; + } + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_parentPipelineOwner == null) { + return; + } + final PipelineOwner newParentPipelineOwner = View.pipelineOwnerOf(this); + if (newParentPipelineOwner != _parentPipelineOwner) { + _detachView(); + _attachView(newParentPipelineOwner); + } + } + + @override + void performRebuild() { + super.performRebuild(); + _updateChild(); + } + + @override + void activate() { + super.activate(); + assert(_effectivePipelineOwner.rootNode == null); + _effectivePipelineOwner.rootNode = renderObject; + _attachView(); + } + + @override + void deactivate() { + _detachView(); + assert(_effectivePipelineOwner.rootNode == renderObject); + _effectivePipelineOwner.rootNode = null; // To satisfy the assert in the super class. + super.deactivate(); + } + + @override + void update(_RawView newWidget) { + super.update(newWidget); + _updateChild(); + } + + @override + void visitChildren(ElementVisitor visitor) { + if (_child != null) { + visitor(_child!); + } + } + + @override + void forgetChild(Element child) { + assert(child == _child); + _child = null; + super.forgetChild(child); + } + + @override + void insertRenderObjectChild(RenderBox child, Object? slot) { + assert(slot == null); + assert(renderObject.debugValidateChild(child)); + renderObject.child = child; + } + + @override + void moveRenderObjectChild(RenderObject child, Object? oldSlot, Object? newSlot) { + assert(false); + } + + @override + void removeRenderObjectChild(RenderObject child, Object? slot) { + assert(slot == null); + assert(renderObject.child == child); + renderObject.child = null; + } + + @override + void unmount() { + if (_effectivePipelineOwner != (widget as _RawView)._deprecatedPipelineOwner) { + _effectivePipelineOwner.dispose(); + } + super.unmount(); + } } class _ViewScope extends InheritedWidget { const _ViewScope({required this.view, required super.child}); - final FlutterView view; + final FlutterView? view; @override bool updateShouldNotify(_ViewScope oldWidget) => view != oldWidget.view; } + +class _PipelineOwnerScope extends InheritedWidget { + const _PipelineOwnerScope({ + required this.pipelineOwner, + required super.child, + }); + + final PipelineOwner pipelineOwner; + + @override + bool updateShouldNotify(_PipelineOwnerScope oldWidget) => pipelineOwner != oldWidget.pipelineOwner; +} + +class _MultiChildComponentWidget extends Widget { + const _MultiChildComponentWidget({ + super.key, + List views = const [], + Widget? child, + }) : _views = views, _child = child; + + // It is up to the subclasses to make the relevant properties public. + final List _views; + final Widget? _child; + + @override + Element createElement() => _MultiChildComponentElement(this); +} + +/// A collection of sibling [View]s. +/// +/// This widget can only be used in places were a [View] widget is allowed, i.e. +/// in a non-rendering zone of the widget tree. In practical terms, it can be +/// used at the root of the widget tree outside of any [View] widget, as a child +/// to a another [ViewCollection], or in the [ViewAnchor.view] slot of a +/// [ViewAnchor] widget. It is not required to be a direct child of those +/// widgets; other non-[RenderObjectWidget]s may appear in between the two (such +/// as an [InheritedWidget]). +/// +/// Similarly, the [views] children of this widget must be [View]s, but they +/// may be wrapped in additional non-[RenderObjectWidget]s (e.g. +/// [InheritedWidget]s). +/// +/// See also: +/// +/// * [WidgetsBinding] for an explanation of rendering and non-rendering zones. +class ViewCollection extends _MultiChildComponentWidget { + /// Creates a [ViewCollection] widget. + /// + /// The provided list of [views] must contain at least one widget. + const ViewCollection({super.key, required super.views}) : assert(views.length > 0); + + /// The [View] descendants of this widget. + /// + /// The [View]s may be wrapped in other non-[RenderObjectWidget]s (e.g. + /// [InheritedWidget]s). However, no [RenderObjectWidget] is allowed to appear + /// between the [ViewCollection] and the next [View] widget. + List get views => _views; +} + +/// Decorates a [child] widget with a side [View]. +/// +/// This widget must have a [View] ancestor, into which the [child] widget +/// is rendered. +/// +/// Typically, a [View] or [ViewCollection] widget is used in the [view] slot to +/// define the content of the side view(s). Those widgets may be wrapped in +/// other non-[RenderObjectWidget]s (e.g. [InheritedWidget]s). However, no +/// [RenderObjectWidget] is allowed to appear between the [ViewAnchor] and the +/// next [View] widget in the [view] slot. The widgets in the [view] slot have +/// access to all [InheritedWidget]s above the [ViewAnchor] in the tree. +/// +/// In technical terms, the [ViewAnchor] can only be used in a rendering zone of +/// the widget tree and the [view] slot marks the start of a new non-rendering +/// zone (see [WidgetsBinding] for a definition of these zones). Typically, +/// it is occupied by a [View] widget, which will start a new rendering zone. +/// +/// {@template flutter.widgets.ViewAnchor} +/// An example use case for this widget is a tooltip for a button. The tooltip +/// should be able to extend beyond the bounds of the main view. For this, the +/// tooltip can be implemented as a separate [View], which is anchored to the +/// button in the main view by wrapping that button with a [ViewAnchor]. In this +/// example, the [view] slot is configured with the tooltip [View] and the +/// [child] is the button widget rendered into the surrounding view. +/// {@endtemplate} +class ViewAnchor extends StatelessWidget { + /// Creates a [ViewAnchor] widget. + const ViewAnchor({ + super.key, + this.view, + required this.child, + }); + + /// The widget that defines the view anchored to this widget. + /// + /// Typically, a [View] or [ViewCollection] widget is used, which may be + /// wrapped in other non-[RenderObjectWidget]s (e.g. [InheritedWidget]s). + /// + /// {@macro flutter.widgets.ViewAnchor} + final Widget? view; + + /// The widget below this widget in the tree. + /// + /// It is rendered into the surrounding view, not in the view defined by + /// [view]. + /// + /// {@macro flutter.widgets.ViewAnchor} + final Widget child; + + @override + Widget build(BuildContext context) { + return _MultiChildComponentWidget( + views: [ + if (view != null) + _ViewScope( + view: null, + child: view!, + ), + ], + child: child, + ); + } +} + +class _MultiChildComponentElement extends Element { + _MultiChildComponentElement(super.widget); + + List _viewElements = []; + final Set _forgottenViewElements = HashSet(); + Element? _childElement; + + bool _debugAssertChildren() { + final _MultiChildComponentWidget typedWidget = widget as _MultiChildComponentWidget; + // Each view widget must have a corresponding element. + assert(_viewElements.length == typedWidget._views.length); + // Iff there is a child widget, it must have a corresponding element. + assert((_childElement == null) == (typedWidget._child == null)); + // The child element is not also a view element. + assert(!_viewElements.contains(_childElement)); + return true; + } + + @override + void attachRenderObject(Object? newSlot) { + super.attachRenderObject(newSlot); + assert(_debugCheckMustAttachRenderObject(newSlot)); + } + + @override + void mount(Element? parent, Object? newSlot) { + super.mount(parent, newSlot); + assert(_debugCheckMustAttachRenderObject(newSlot)); + assert(_viewElements.isEmpty); + assert(_childElement == null); + rebuild(); + assert(_debugAssertChildren()); + } + + @override + void updateSlot(Object? newSlot) { + super.updateSlot(newSlot); + assert(_debugCheckMustAttachRenderObject(newSlot)); + } + + bool _debugCheckMustAttachRenderObject(Object? slot) { + // Check only applies in the ViewCollection configuration. + if (!kDebugMode || (widget as _MultiChildComponentWidget)._child != null) { + return true; + } + bool hasAncestorRenderObjectElement = false; + bool ancestorWantsRenderObject = true; + visitAncestorElements((Element ancestor) { + if (!ancestor.debugExpectsRenderObjectForSlot(slot)) { + ancestorWantsRenderObject = false; + return false; + } + if (ancestor is RenderObjectElement) { + hasAncestorRenderObjectElement = true; + return false; + } + return true; + }); + if (hasAncestorRenderObjectElement && ancestorWantsRenderObject) { + FlutterError.reportError( + FlutterErrorDetails(exception: FlutterError.fromParts( + [ + ErrorSummary( + 'The Element for ${toStringShort()} cannot be inserted into slot "$slot" of its ancestor. ', + ), + ErrorDescription( + 'The ownership chain for the Element in question was:\n ${debugGetCreatorChain(10)}', + ), + ErrorDescription( + 'This Element allows the creation of multiple independent render trees, which cannot ' + 'be attached to an ancestor in an existing render tree. However, an ancestor RenderObject ' + 'is expecting 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 its ancestor.', + ), + ], + )), + ); + } + return true; + } + + @override + void update(_MultiChildComponentWidget newWidget) { + // Cannot switch from ViewAnchor config to ViewCollection config. + assert((newWidget._child == null) == ((widget as _MultiChildComponentWidget)._child == null)); + super.update(newWidget); + rebuild(force: true); + assert(_debugAssertChildren()); + } + + static const Object _viewSlot = Object(); + + @override + bool debugExpectsRenderObjectForSlot(Object? slot) => slot != _viewSlot; + + @override + void performRebuild() { + final _MultiChildComponentWidget typedWidget = widget as _MultiChildComponentWidget; + + _childElement = updateChild(_childElement, typedWidget._child, slot); + + final List views = typedWidget._views; + _viewElements = updateChildren( + _viewElements, + views, + forgottenChildren: _forgottenViewElements, + slots: List.generate(views.length, (_) => _viewSlot), + ); + _forgottenViewElements.clear(); + + super.performRebuild(); // clears the dirty flag + assert(_debugAssertChildren()); + } + + @override + void forgetChild(Element child) { + if (child == _childElement) { + _childElement = null; + } else { + assert(_viewElements.contains(child)); + assert(!_forgottenViewElements.contains(child)); + _forgottenViewElements.add(child); + } + super.forgetChild(child); + } + + @override + void visitChildren(ElementVisitor visitor) { + if (_childElement != null) { + visitor(_childElement!); + } + for (final Element child in _viewElements) { + if (!_forgottenViewElements.contains(child)) { + visitor(child); + } + } + } + + @override + bool get debugDoingBuild => false; // This element does not have a concept of "building". + + @override + Element? get renderObjectAttachingChild => _childElement; + + @override + List debugDescribeChildren() { + final List children = []; + if (_childElement != null) { + children.add(_childElement!.toDiagnosticsNode()); + } + for (int i = 0; i < _viewElements.length; i++) { + children.add(_viewElements[i].toDiagnosticsNode( + name: 'view ${i + 1}', + style: DiagnosticsTreeStyle.offstage, + )); + } + return children; + } +} + +// A special [GlobalKey] to support passing the deprecated +// [RendererBinding.renderView] and [RendererBinding.pipelineOwner] to the +// [_RawView]. Will be removed when those deprecated properties are removed. +@optionalTypeArgs +class _DeprecatedRawViewKey> extends GlobalKey { + const _DeprecatedRawViewKey(this.view, this.owner, this.renderView) : super.constructor(); + + final FlutterView view; + final PipelineOwner? owner; + final RenderView? renderView; + + @override + bool operator ==(Object other) { + if (other.runtimeType != runtimeType) { + return false; + } + return other is _DeprecatedRawViewKey + && identical(other.view, view) + && identical(other.owner, owner) + && identical(other.renderView, renderView); + } + + @override + int get hashCode => Object.hash(view, owner, renderView); + + @override + String toString() => '[_DeprecatedRawViewKey ${describeIdentity(view)}]'; +} diff --git a/packages/flutter/lib/src/widgets/widget_inspector.dart b/packages/flutter/lib/src/widgets/widget_inspector.dart index d4a8c87cafb..d3ffc09525e 100644 --- a/packages/flutter/lib/src/widgets/widget_inspector.dart +++ b/packages/flutter/lib/src/widgets/widget_inspector.dart @@ -1103,8 +1103,7 @@ mixin WidgetInspectorService { renderObject.markNeedsPaint(); renderObject.visitChildren(markTreeNeedsPaint); } - final RenderObject root = RendererBinding.instance.renderView; - markTreeNeedsPaint(root); + RendererBinding.instance.renderViews.forEach(markTreeNeedsPaint); } else { debugOnProfilePaint = null; } diff --git a/packages/flutter/lib/widgets.dart b/packages/flutter/lib/widgets.dart index fd2cf457c41..092cd82e551 100644 --- a/packages/flutter/lib/widgets.dart +++ b/packages/flutter/lib/widgets.dart @@ -18,6 +18,7 @@ export 'package:vector_math/vector_math_64.dart' show Matrix4; export 'foundation.dart' show UniqueKey; export 'rendering.dart' show TextSelectionHandleType; export 'src/widgets/actions.dart'; +export 'src/widgets/adapter.dart'; export 'src/widgets/animated_cross_fade.dart'; export 'src/widgets/animated_scroll_view.dart'; export 'src/widgets/animated_size.dart'; diff --git a/packages/flutter/test/foundation/service_extensions_test.dart b/packages/flutter/test/foundation/service_extensions_test.dart index 2e524b70d15..b97a70a74a6 100644 --- a/packages/flutter/test/foundation/service_extensions_test.dart +++ b/packages/flutter/test/foundation/service_extensions_test.dart @@ -117,9 +117,17 @@ Future> hasReassemble(Future> pendingR void main() { final List console = []; + late PipelineOwner owner; setUpAll(() async { - binding = TestServiceExtensionsBinding()..scheduleFrame(); + binding = TestServiceExtensionsBinding(); + final RenderView view = RenderView(view: binding.platformDispatcher.views.single); + owner = PipelineOwner(onSemanticsUpdate: (ui.SemanticsUpdate _) { }) + ..rootNode = view; + binding.rootPipelineOwner.adoptChild(owner); + binding.addRenderView(view); + view.prepareInitialFrame(); + binding.scheduleFrame(); expect(binding.frameScheduled, isTrue); // We need to test this service extension here because the result is true @@ -176,6 +184,10 @@ void main() { expect(console, isEmpty); debugPrint = debugPrintThrottled; + binding.rootPipelineOwner.dropChild(owner); + owner + ..rootNode = null + ..dispose(); }); // The following list is alphabetical, one test per extension. @@ -268,11 +280,13 @@ void main() { await binding.doFrame(); final Map result = await binding.testExtension(RenderingServiceExtensions.debugDumpSemanticsTreeInTraversalOrder.name, {}); - expect(result, { - 'data': 'Semantics not generated.\n' - 'For performance reasons, the framework only generates semantics when asked to do so by the platform.\n' - 'Usually, platforms only ask for semantics when assistive technologies (like screen readers) are running.\n' - 'To generate semantics, try turning on an assistive technology (like VoiceOver or TalkBack) on your device.' + expect(result, { + 'data': matches( + r'Semantics not generated for RenderView#[0-9a-f]{5}\.\n' + r'For performance reasons, the framework only generates semantics when asked to do so by the platform.\n' + r'Usually, platforms only ask for semantics when assistive technologies \(like screen readers\) are running.\n' + r'To generate semantics, try turning on an assistive technology \(like VoiceOver or TalkBack\) on your device.' + ) }); }); @@ -280,11 +294,13 @@ void main() { await binding.doFrame(); final Map result = await binding.testExtension(RenderingServiceExtensions.debugDumpSemanticsTreeInInverseHitTestOrder.name, {}); - expect(result, { - 'data': 'Semantics not generated.\n' - 'For performance reasons, the framework only generates semantics when asked to do so by the platform.\n' - 'Usually, platforms only ask for semantics when assistive technologies (like screen readers) are running.\n' - 'To generate semantics, try turning on an assistive technology (like VoiceOver or TalkBack) on your device.' + expect(result, { + 'data': matches( + r'Semantics not generated for RenderView#[0-9a-f]{5}\.\n' + r'For performance reasons, the framework only generates semantics when asked to do so by the platform.\n' + r'Usually, platforms only ask for semantics when assistive technologies \(like screen readers\) are running.\n' + r'To generate semantics, try turning on an assistive technology \(like VoiceOver or TalkBack\) on your device.' + ) }); }); diff --git a/packages/flutter/test/rendering/binding_pipeline_manifold_test.dart b/packages/flutter/test/rendering/binding_pipeline_manifold_test.dart index 5374454db90..81d79a5a82e 100644 --- a/packages/flutter/test/rendering/binding_pipeline_manifold_test.dart +++ b/packages/flutter/test/rendering/binding_pipeline_manifold_test.dart @@ -13,20 +13,20 @@ void main() { tearDown(() { final List children = []; - RendererBinding.instance.pipelineOwner.visitChildren((PipelineOwner child) { + RendererBinding.instance.rootPipelineOwner.visitChildren((PipelineOwner child) { children.add(child); }); - children.forEach(RendererBinding.instance.pipelineOwner.dropChild); + children.forEach(RendererBinding.instance.rootPipelineOwner.dropChild); }); test("BindingPipelineManifold notifies binding if render object managed by binding's PipelineOwner tree needs visual update", () { final PipelineOwner child = PipelineOwner(); - RendererBinding.instance.pipelineOwner.adoptChild(child); + RendererBinding.instance.rootPipelineOwner.adoptChild(child); final RenderObject renderObject = TestRenderObject(); child.rootNode = renderObject; renderObject.scheduleInitialLayout(); - RendererBinding.instance.pipelineOwner.flushLayout(); + RendererBinding.instance.rootPipelineOwner.flushLayout(); MyTestRenderingFlutterBinding.instance.ensureVisualUpdateCount = 0; renderObject.markNeedsLayout(); @@ -37,20 +37,20 @@ void main() { final PipelineOwner child = PipelineOwner( onSemanticsUpdate: (_) { }, ); - RendererBinding.instance.pipelineOwner.adoptChild(child); + RendererBinding.instance.rootPipelineOwner.adoptChild(child); expect(child.semanticsOwner, isNull); - expect(RendererBinding.instance.pipelineOwner.semanticsOwner, isNull); + expect(RendererBinding.instance.rootPipelineOwner.semanticsOwner, isNull); final SemanticsHandle handle = SemanticsBinding.instance.ensureSemantics(); expect(child.semanticsOwner, isNotNull); - expect(RendererBinding.instance.pipelineOwner.semanticsOwner, isNotNull); + expect(RendererBinding.instance.rootPipelineOwner.semanticsOwner, isNotNull); handle.dispose(); expect(child.semanticsOwner, isNull); - expect(RendererBinding.instance.pipelineOwner.semanticsOwner, isNull); + expect(RendererBinding.instance.rootPipelineOwner.semanticsOwner, isNull); }); } diff --git a/packages/flutter/test/rendering/binding_test.dart b/packages/flutter/test/rendering/binding_test.dart index 80495aa264c..67cc00b998d 100644 --- a/packages/flutter/test/rendering/binding_test.dart +++ b/packages/flutter/test/rendering/binding_test.dart @@ -10,29 +10,48 @@ import 'package:flutter_test/flutter_test.dart'; void main() { WidgetsFlutterBinding.ensureInitialized(); - test('handleMetricsChanged does not scheduleForcedFrame unless there is a child to the renderView', () async { + test('handleMetricsChanged does not scheduleForcedFrame unless there a registered renderView with a child', () async { expect(SchedulerBinding.instance.hasScheduledFrame, false); RendererBinding.instance.handleMetricsChanged(); expect(SchedulerBinding.instance.hasScheduledFrame, false); + RendererBinding.instance.addRenderView(RendererBinding.instance.renderView); + RendererBinding.instance.handleMetricsChanged(); + expect(SchedulerBinding.instance.hasScheduledFrame, false); + RendererBinding.instance.renderView.child = RenderLimitedBox(); RendererBinding.instance.handleMetricsChanged(); expect(SchedulerBinding.instance.hasScheduledFrame, true); + + RendererBinding.instance.removeRenderView(RendererBinding.instance.renderView); }); test('debugDumpSemantics prints explanation when semantics are unavailable', () { + RendererBinding.instance.addRenderView(RendererBinding.instance.renderView); final List log = []; debugPrint = (String? message, {int? wrapWidth}) { log.add(message); }; debugDumpSemanticsTree(); expect(log, hasLength(1)); + expect(log.single, startsWith('Semantics not generated')); + expect(log.single, endsWith( + 'For performance reasons, the framework only generates semantics when asked to do so by the platform.\n' + 'Usually, platforms only ask for semantics when assistive technologies (like screen readers) are running.\n' + 'To generate semantics, try turning on an assistive technology (like VoiceOver or TalkBack) on your device.' + )); + RendererBinding.instance.removeRenderView(RendererBinding.instance.renderView); + }); + + test('root pipeline owner cannot manage root node', () { + final RenderObject rootNode = RenderProxyBox(); expect( - log.single, - 'Semantics not generated.\n' - 'For performance reasons, the framework only generates semantics when asked to do so by the platform.\n' - 'Usually, platforms only ask for semantics when assistive technologies (like screen readers) are running.\n' - 'To generate semantics, try turning on an assistive technology (like VoiceOver or TalkBack) on your device.' + () => RendererBinding.instance.rootPipelineOwner.rootNode = rootNode, + throwsA(isFlutterError.having( + (FlutterError e) => e.message, + 'message', + contains('Cannot set a rootNode on the default root pipeline owner.'), + )), ); }); } diff --git a/packages/flutter/test/rendering/mouse_tracker_test_utils.dart b/packages/flutter/test/rendering/mouse_tracker_test_utils.dart index 1f9c5bfe052..eb81870be9c 100644 --- a/packages/flutter/test/rendering/mouse_tracker_test_utils.dart +++ b/packages/flutter/test/rendering/mouse_tracker_test_utils.dart @@ -32,8 +32,21 @@ class TestMouseTrackerFlutterBinding extends BindingBase postFrameCallbacks = []; } + late final RenderView _renderView = RenderView( + view: platformDispatcher.implicitView!, + ); + + late final PipelineOwner _pipelineOwner = PipelineOwner( + onSemanticsUpdate: (ui.SemanticsUpdate _) { assert(false); }, + ); + void setHitTest(BoxHitTest hitTest) { - renderView.child = _TestHitTester(hitTest); + if (_pipelineOwner.rootNode == null) { + _pipelineOwner.rootNode = _renderView; + rootPipelineOwner.adoptChild(_pipelineOwner); + addRenderView(_renderView); + } + _renderView.child = _TestHitTester(hitTest); } SchedulerPhase? _overridePhase; diff --git a/packages/flutter/test/rendering/multi_view_binding_test.dart b/packages/flutter/test/rendering/multi_view_binding_test.dart new file mode 100644 index 00000000000..9a577579758 --- /dev/null +++ b/packages/flutter/test/rendering/multi_view_binding_test.dart @@ -0,0 +1,208 @@ +// 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 'dart:ui'; + +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + final RendererBinding binding = RenderingFlutterBinding.ensureInitialized(); + + test('Adding/removing renderviews updates renderViews getter', () { + final FlutterView flutterView = FakeFlutterView(); + final RenderView view = RenderView(view: flutterView); + + expect(binding.renderViews, isEmpty); + binding.addRenderView(view); + expect(binding.renderViews, contains(view)); + expect(view.configuration.devicePixelRatio, flutterView.devicePixelRatio); + expect(view.configuration.size, flutterView.physicalSize / flutterView.devicePixelRatio); + + binding.removeRenderView(view); + expect(binding.renderViews, isEmpty); + }); + + test('illegal add/remove renderviews', () { + final FlutterView flutterView = FakeFlutterView(); + final RenderView view1 = RenderView(view: flutterView); + final RenderView view2 = RenderView(view: flutterView); + final RenderView view3 = RenderView(view: FakeFlutterView(viewId: 200)); + + expect(binding.renderViews, isEmpty); + binding.addRenderView(view1); + expect(binding.renderViews, contains(view1)); + + expect(() => binding.addRenderView(view1), throwsAssertionError); + expect(() => binding.addRenderView(view2), throwsAssertionError); + expect(() => binding.removeRenderView(view2), throwsAssertionError); + expect(() => binding.removeRenderView(view3), throwsAssertionError); + + expect(binding.renderViews, contains(view1)); + binding.removeRenderView(view1); + expect(binding.renderViews, isEmpty); + expect(() => binding.removeRenderView(view1), throwsAssertionError); + expect(() => binding.removeRenderView(view2), throwsAssertionError); + }); + + test('changing metrics updates configuration', () { + final FakeFlutterView flutterView = FakeFlutterView(); + final RenderView view = RenderView(view: flutterView); + binding.addRenderView(view); + expect(view.configuration.devicePixelRatio, 2.5); + expect(view.configuration.size, const Size(160.0, 240.0)); + + flutterView.devicePixelRatio = 3.0; + flutterView.physicalSize = const Size(300, 300); + binding.handleMetricsChanged(); + expect(view.configuration.devicePixelRatio, 3.0); + expect(view.configuration.size, const Size(100.0, 100.0)); + + binding.removeRenderView(view); + }); + + test('semantics actions are performed on the right view', () { + final FakeFlutterView flutterView1 = FakeFlutterView(viewId: 1); + final FakeFlutterView flutterView2 = FakeFlutterView(viewId: 2); + final RenderView renderView1 = RenderView(view: flutterView1); + final RenderView renderView2 = RenderView(view: flutterView2); + final PipelineOwnerSpy owner1 = PipelineOwnerSpy() + ..rootNode = renderView1; + final PipelineOwnerSpy owner2 = PipelineOwnerSpy() + ..rootNode = renderView2; + + binding.addRenderView(renderView1); + binding.addRenderView(renderView2); + + binding.performSemanticsAction( + const SemanticsActionEvent(type: SemanticsAction.copy, viewId: 1, nodeId: 11), + ); + expect(owner1.semanticsOwner.performedActions.single, (11, SemanticsAction.copy, null)); + expect(owner2.semanticsOwner.performedActions, isEmpty); + owner1.semanticsOwner.performedActions.clear(); + + binding.performSemanticsAction( + const SemanticsActionEvent(type: SemanticsAction.tap, viewId: 2, nodeId: 22), + ); + expect(owner1.semanticsOwner.performedActions, isEmpty); + expect(owner2.semanticsOwner.performedActions.single, (22, SemanticsAction.tap, null)); + owner2.semanticsOwner.performedActions.clear(); + + binding.performSemanticsAction( + const SemanticsActionEvent(type: SemanticsAction.tap, viewId: 3, nodeId: 22), + ); + expect(owner1.semanticsOwner.performedActions, isEmpty); + expect(owner2.semanticsOwner.performedActions, isEmpty); + + binding.removeRenderView(renderView1); + binding.removeRenderView(renderView2); + }); + + test('all registered renderviews are asked to composite frame', () { + final FakeFlutterView flutterView1 = FakeFlutterView(viewId: 1); + final FakeFlutterView flutterView2 = FakeFlutterView(viewId: 2); + final RenderView renderView1 = RenderView(view: flutterView1); + final RenderView renderView2 = RenderView(view: flutterView2); + final PipelineOwner owner1 = PipelineOwner()..rootNode = renderView1; + final PipelineOwner owner2 = PipelineOwner()..rootNode = renderView2; + binding.rootPipelineOwner.adoptChild(owner1); + binding.rootPipelineOwner.adoptChild(owner2); + binding.addRenderView(renderView1); + binding.addRenderView(renderView2); + renderView1.prepareInitialFrame(); + renderView2.prepareInitialFrame(); + + expect(flutterView1.renderedScenes, isEmpty); + expect(flutterView2.renderedScenes, isEmpty); + + binding.handleBeginFrame(Duration.zero); + binding.handleDrawFrame(); + + expect(flutterView1.renderedScenes, hasLength(1)); + expect(flutterView2.renderedScenes, hasLength(1)); + + binding.removeRenderView(renderView1); + + binding.handleBeginFrame(Duration.zero); + binding.handleDrawFrame(); + + expect(flutterView1.renderedScenes, hasLength(1)); + expect(flutterView2.renderedScenes, hasLength(2)); + + binding.removeRenderView(renderView2); + + binding.handleBeginFrame(Duration.zero); + binding.handleDrawFrame(); + + expect(flutterView1.renderedScenes, hasLength(1)); + expect(flutterView2.renderedScenes, hasLength(2)); + }); + + test('hit-testing reaches the right view', () { + final FakeFlutterView flutterView1 = FakeFlutterView(viewId: 1); + final FakeFlutterView flutterView2 = FakeFlutterView(viewId: 2); + final RenderView renderView1 = RenderView(view: flutterView1); + final RenderView renderView2 = RenderView(view: flutterView2); + binding.addRenderView(renderView1); + binding.addRenderView(renderView2); + + HitTestResult result = HitTestResult(); + binding.hitTestInView(result, Offset.zero, 1); + expect(result.path, hasLength(2)); + expect(result.path.first.target, renderView1); + expect(result.path.last.target, binding); + + result = HitTestResult(); + binding.hitTestInView(result, Offset.zero, 2); + expect(result.path, hasLength(2)); + expect(result.path.first.target, renderView2); + expect(result.path.last.target, binding); + + result = HitTestResult(); + binding.hitTestInView(result, Offset.zero, 3); + expect(result.path.single.target, binding); + + binding.removeRenderView(renderView1); + binding.removeRenderView(renderView2); + }); +} + +class FakeFlutterView extends Fake implements FlutterView { + FakeFlutterView({ + this.viewId = 100, + this.devicePixelRatio = 2.5, + this.physicalSize = const Size(400,600), + this.padding = FakeViewPadding.zero, + }); + + @override + final int viewId; + @override + double devicePixelRatio; + @override + Size physicalSize; + @override + ViewPadding padding; + + List renderedScenes = []; + + @override + void render(Scene scene) { + renderedScenes.add(scene); + } +} + +class PipelineOwnerSpy extends PipelineOwner { + @override + final SemanticsOwnerSpy semanticsOwner = SemanticsOwnerSpy(); +} + +class SemanticsOwnerSpy extends Fake implements SemanticsOwner { + final List<(int, SemanticsAction, Object?)> performedActions = <(int, SemanticsAction, Object?)>[]; + + @override + void performAction(int id, SemanticsAction action, [ Object? args ]) { + performedActions.add((id, action, args)); + } +} diff --git a/packages/flutter/test/rendering/pipeline_owner_tree_test.dart b/packages/flutter/test/rendering/pipeline_owner_tree_test.dart index 5c2368dee5a..2f997ea7c67 100644 --- a/packages/flutter/test/rendering/pipeline_owner_tree_test.dart +++ b/packages/flutter/test/rendering/pipeline_owner_tree_test.dart @@ -678,20 +678,43 @@ void main() { expect(root.semanticsOwner, isNotNull); expect(child.semanticsOwner, isNotNull); - expect(childOfChild.semanticsOwner, isNull); + expect(childOfChild.semanticsOwner, isNotNull); // Retained in case we get re-attached. final SemanticsHandle childSemantics = child.ensureSemantics(); root.dropChild(child); expect(root.semanticsOwner, isNotNull); expect(child.semanticsOwner, isNotNull); - expect(childOfChild.semanticsOwner, isNull); + expect(childOfChild.semanticsOwner, isNotNull); // Retained in case we get re-attached. childSemantics.dispose(); expect(root.semanticsOwner, isNotNull); expect(child.semanticsOwner, isNull); - expect(childOfChild.semanticsOwner, isNull); + expect(childOfChild.semanticsOwner, isNotNull); + + manifold.semanticsEnabled = false; + + expect(root.semanticsOwner, isNull); + expect(childOfChild.semanticsOwner, isNotNull); + + root.adoptChild(childOfChild); + expect(root.semanticsOwner, isNull); + expect(childOfChild.semanticsOwner, isNull); // Disposed on re-attachment. + + manifold.semanticsEnabled = true; + expect(root.semanticsOwner, isNotNull); + expect(childOfChild.semanticsOwner, isNotNull); + + root.dropChild(childOfChild); + + expect(root.semanticsOwner, isNotNull); + expect(childOfChild.semanticsOwner, isNotNull); + + childOfChild.dispose(); + + expect(root.semanticsOwner, isNotNull); + expect(childOfChild.semanticsOwner, isNull); // Disposed on dispose. }); test('can adopt/drop children during own layout', () { @@ -789,6 +812,38 @@ void main() { }); expect(children.single, childOfChild3); }); + + test('printing pipeline owner tree smoke test', () { + final PipelineOwner root = PipelineOwner(); + final PipelineOwner child1 = PipelineOwner() + ..rootNode = FakeRenderView(); + final PipelineOwner childOfChild1 = PipelineOwner() + ..rootNode = FakeRenderView(); + final PipelineOwner child2 = PipelineOwner() + ..rootNode = FakeRenderView(); + final PipelineOwner childOfChild2 = PipelineOwner() + ..rootNode = FakeRenderView(); + + root.adoptChild(child1); + child1.adoptChild(childOfChild1); + root.adoptChild(child2); + child2.adoptChild(childOfChild2); + + expect(root.toStringDeep(), equalsIgnoringHashCodes( + 'PipelineOwner#00000\n' + ' ├─PipelineOwner#00000\n' + ' │ │ rootNode: FakeRenderView#00000 NEEDS-LAYOUT NEEDS-PAINT\n' + ' │ │\n' + ' │ └─PipelineOwner#00000\n' + ' │ rootNode: FakeRenderView#00000 NEEDS-LAYOUT NEEDS-PAINT\n' + ' │\n' + ' └─PipelineOwner#00000\n' + ' │ rootNode: FakeRenderView#00000 NEEDS-LAYOUT NEEDS-PAINT\n' + ' │\n' + ' └─PipelineOwner#00000\n' + ' rootNode: FakeRenderView#00000 NEEDS-LAYOUT NEEDS-PAINT\n' + )); + }); } class TestPipelineManifold extends ChangeNotifier implements PipelineManifold { @@ -860,3 +915,5 @@ List _treeWalk(PipelineOwner root) { root.visitChildren(visitor); return results; } + +class FakeRenderView extends RenderBox { } diff --git a/packages/flutter/test/rendering/platform_view_test.dart b/packages/flutter/test/rendering/platform_view_test.dart index baee5bc5458..c6ab6cc9b54 100644 --- a/packages/flutter/test/rendering/platform_view_test.dart +++ b/packages/flutter/test/rendering/platform_view_test.dart @@ -47,10 +47,10 @@ void main() { child: platformViewRenderBox, ); int semanticsUpdateCount = 0; - final SemanticsHandle semanticsHandle = TestRenderingFlutterBinding.instance.pipelineOwner.ensureSemantics( - listener: () { - ++semanticsUpdateCount; - }, + final SemanticsHandle semanticsHandle = TestRenderingFlutterBinding.instance.rootPipelineOwner.ensureSemantics( + listener: () { + ++semanticsUpdateCount; + }, ); layout(tree, phase: EnginePhase.flushSemantics); // Initial semantics update diff --git a/packages/flutter/test/rendering/rendering_tester.dart b/packages/flutter/test/rendering/rendering_tester.dart index 7e8d0e6c1dd..6ebfc38f4de 100644 --- a/packages/flutter/test/rendering/rendering_tester.dart +++ b/packages/flutter/test/rendering/rendering_tester.dart @@ -3,6 +3,7 @@ // found in the LICENSE file. import 'dart:async'; +import 'dart:ui' show SemanticsUpdate; import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; @@ -43,6 +44,44 @@ class TestRenderingFlutterBinding extends BindingBase with SchedulerBinding, Ser void initInstances() { super.initInstances(); _instance = this; + // TODO(goderbauer): Create (fake) window if embedder doesn't provide an implicit view. + assert(platformDispatcher.implicitView != null); + _renderView = initRenderView(platformDispatcher.implicitView!); + } + + @override + RenderView get renderView => _renderView; + late RenderView _renderView; + + @override + PipelineOwner get pipelineOwner => rootPipelineOwner; + + /// Creates a [RenderView] object to be the root of the + /// [RenderObject] rendering tree, and initializes it so that it + /// will be rendered when the next frame is requested. + /// + /// Called automatically when the binding is created. + RenderView initRenderView(FlutterView view) { + final RenderView renderView = RenderView(view: view); + rootPipelineOwner.rootNode = renderView; + addRenderView(renderView); + renderView.prepareInitialFrame(); + return renderView; + } + + @override + PipelineOwner createRootPipelineOwner() { + return PipelineOwner( + onSemanticsOwnerCreated: () { + renderView.scheduleInitialSemantics(); + }, + onSemanticsUpdate: (SemanticsUpdate update) { + renderView.updateSemantics(update); + }, + onSemanticsOwnerDisposed: () { + renderView.clearSemantics(); + }, + ); } /// Creates and initializes the binding. This function is @@ -139,23 +178,25 @@ class TestRenderingFlutterBinding extends BindingBase with SchedulerBinding, Ser final FlutterExceptionHandler? oldErrorHandler = FlutterError.onError; FlutterError.onError = _errors.add; try { - pipelineOwner.flushLayout(); + rootPipelineOwner.flushLayout(); if (phase == EnginePhase.layout) { return; } - pipelineOwner.flushCompositingBits(); + rootPipelineOwner.flushCompositingBits(); if (phase == EnginePhase.compositingBits) { return; } - pipelineOwner.flushPaint(); + rootPipelineOwner.flushPaint(); if (phase == EnginePhase.paint) { return; } - renderView.compositeFrame(); + for (final RenderView renderView in renderViews) { + renderView.compositeFrame(); + } if (phase == EnginePhase.composite) { return; } - pipelineOwner.flushSemantics(); + rootPipelineOwner.flushSemantics(); if (phase == EnginePhase.flushSemantics) { return; } diff --git a/packages/flutter/test/rendering/view_test.dart b/packages/flutter/test/rendering/view_test.dart index a3367a691fb..6344c7a53b3 100644 --- a/packages/flutter/test/rendering/view_test.dart +++ b/packages/flutter/test/rendering/view_test.dart @@ -122,6 +122,16 @@ void main() { isNot(paintsGreenRect), ); }); + + test('Config can be set and changed after instantiation without calling prepareInitialFrame first', () { + final RenderView view = RenderView( + view: RendererBinding.instance.platformDispatcher.views.single, + ); + view.configuration = const ViewConfiguration(size: Size(100, 200), devicePixelRatio: 3.0); + view.configuration = const ViewConfiguration(size: Size(200, 300), devicePixelRatio: 2.0); + PipelineOwner().rootNode = view; + view.prepareInitialFrame(); + }); } const Color orange = Color(0xFFFF9000); diff --git a/packages/flutter/test/scheduler/benchmarks_test.dart b/packages/flutter/test/scheduler/benchmarks_test.dart index 9578ba74c49..ec9c03b54a2 100644 --- a/packages/flutter/test/scheduler/benchmarks_test.dart +++ b/packages/flutter/test/scheduler/benchmarks_test.dart @@ -42,7 +42,7 @@ void main() { await benchmarkWidgets( (WidgetTester tester) async { const Key root = Key('root'); - binding.attachRootWidget(Container(key: root)); + binding.attachRootWidget(binding.wrapWithDefaultView(Container(key: root))); await tester.pump(); expect(binding.framesBegun, greaterThan(0)); diff --git a/packages/flutter/test/widgets/container_test.dart b/packages/flutter/test/widgets/container_test.dart index 7c327bc9754..37f08221419 100644 --- a/packages/flutter/test/widgets/container_test.dart +++ b/packages/flutter/test/widgets/container_test.dart @@ -156,8 +156,9 @@ void main() { equalsIgnoringHashCodes( 'RenderPadding#00000 relayoutBoundary=up1\n' ' │ creator: Padding ← Container ← Align ← MediaQuery ←\n' - ' │ _MediaQueryFromView ← _ViewScope ← View-[GlobalObjectKey\n' - ' │ TestFlutterView#00000] ← [root]\n' + ' │ _MediaQueryFromView ← _PipelineOwnerScope ← _ViewScope ←\n' + ' │ _RawView-[_DeprecatedRawViewKey TestFlutterView#00000] ← View ←\n' + ' │ [root]\n' ' │ parentData: offset=Offset(0.0, 0.0) (can use size)\n' ' │ constraints: BoxConstraints(0.0<=w<=800.0, 0.0<=h<=600.0)\n' ' │ size: Size(63.0, 88.0)\n' @@ -165,8 +166,9 @@ void main() { ' │\n' ' └─child: RenderConstrainedBox#00000 relayoutBoundary=up2\n' ' │ creator: ConstrainedBox ← Padding ← Container ← Align ←\n' - ' │ MediaQuery ← _MediaQueryFromView ← _ViewScope ←\n' - ' │ View-[GlobalObjectKey TestFlutterView#00000] ← [root]\n' + ' │ MediaQuery ← _MediaQueryFromView ← _PipelineOwnerScope ←\n' + ' │ _ViewScope ← _RawView-[_DeprecatedRawViewKey\n' + ' │ TestFlutterView#00000] ← View ← [root]\n' ' │ parentData: offset=Offset(5.0, 5.0) (can use size)\n' ' │ constraints: BoxConstraints(0.0<=w<=790.0, 0.0<=h<=590.0)\n' ' │ size: Size(53.0, 78.0)\n' @@ -174,8 +176,9 @@ void main() { ' │\n' ' └─child: RenderDecoratedBox#00000\n' ' │ creator: DecoratedBox ← ConstrainedBox ← Padding ← Container ←\n' - ' │ Align ← MediaQuery ← _MediaQueryFromView ← _ViewScope ←\n' - ' │ View-[GlobalObjectKey TestFlutterView#00000] ← [root]\n' + ' │ Align ← MediaQuery ← _MediaQueryFromView ← _PipelineOwnerScope\n' + ' │ ← _ViewScope ← _RawView-[_DeprecatedRawViewKey\n' + ' │ TestFlutterView#00000] ← View ← [root]\n' ' │ parentData: (can use size)\n' ' │ constraints: BoxConstraints(w=53.0, h=78.0)\n' ' │ size: Size(53.0, 78.0)\n' @@ -188,8 +191,9 @@ void main() { ' └─child: _RenderColoredBox#00000\n' ' │ creator: ColoredBox ← DecoratedBox ← ConstrainedBox ← Padding ←\n' ' │ Container ← Align ← MediaQuery ← _MediaQueryFromView ←\n' - ' │ _ViewScope ← View-[GlobalObjectKey TestFlutterView#00000] ←\n' - ' │ [root]\n' + ' │ _PipelineOwnerScope ← _ViewScope ←\n' + ' │ _RawView-[_DeprecatedRawViewKey TestFlutterView#00000] ← View ←\n' + ' │ ⋯\n' ' │ parentData: (can use size)\n' ' │ constraints: BoxConstraints(w=53.0, h=78.0)\n' ' │ size: Size(53.0, 78.0)\n' @@ -198,8 +202,8 @@ void main() { ' └─child: RenderPadding#00000\n' ' │ creator: Padding ← ColoredBox ← DecoratedBox ← ConstrainedBox ←\n' ' │ Padding ← Container ← Align ← MediaQuery ← _MediaQueryFromView\n' - ' │ ← _ViewScope ← View-[GlobalObjectKey TestFlutterView#00000] ←\n' - ' │ [root]\n' + ' │ ← _PipelineOwnerScope ← _ViewScope ←\n' + ' │ _RawView-[_DeprecatedRawViewKey TestFlutterView#00000] ← ⋯\n' ' │ parentData: (can use size)\n' ' │ constraints: BoxConstraints(w=53.0, h=78.0)\n' ' │ size: Size(53.0, 78.0)\n' @@ -208,8 +212,7 @@ void main() { ' └─child: RenderPositionedBox#00000\n' ' │ creator: Align ← Padding ← ColoredBox ← DecoratedBox ←\n' ' │ ConstrainedBox ← Padding ← Container ← Align ← MediaQuery ←\n' - ' │ _MediaQueryFromView ← _ViewScope ← View-[GlobalObjectKey\n' - ' │ TestFlutterView#00000] ← ⋯\n' + ' │ _MediaQueryFromView ← _PipelineOwnerScope ← _ViewScope ← ⋯\n' ' │ parentData: offset=Offset(7.0, 7.0) (can use size)\n' ' │ constraints: BoxConstraints(w=39.0, h=64.0)\n' ' │ size: Size(39.0, 64.0)\n' @@ -220,7 +223,7 @@ void main() { ' └─child: RenderConstrainedBox#00000 relayoutBoundary=up1\n' ' │ creator: SizedBox ← Align ← Padding ← ColoredBox ← DecoratedBox ←\n' ' │ ConstrainedBox ← Padding ← Container ← Align ← MediaQuery ←\n' - ' │ _MediaQueryFromView ← _ViewScope ← ⋯\n' + ' │ _MediaQueryFromView ← _PipelineOwnerScope ← ⋯\n' ' │ parentData: offset=Offset(14.0, 31.0) (can use size)\n' ' │ constraints: BoxConstraints(0.0<=w<=39.0, 0.0<=h<=64.0)\n' ' │ size: Size(25.0, 33.0)\n' @@ -255,8 +258,9 @@ void main() { equalsIgnoringHashCodes( 'RenderPadding#00000 relayoutBoundary=up1\n' ' │ creator: Padding ← Container ← Align ← MediaQuery ←\n' - ' │ _MediaQueryFromView ← _ViewScope ← View-[GlobalObjectKey\n' - ' │ TestFlutterView#00000] ← [root]\n' + ' │ _MediaQueryFromView ← _PipelineOwnerScope ← _ViewScope ←\n' + ' │ _RawView-[_DeprecatedRawViewKey TestFlutterView#00000] ← View ←\n' + ' │ [root]\n' ' │ parentData: offset=Offset(0.0, 0.0) (can use size)\n' ' │ constraints: BoxConstraints(0.0<=w<=800.0, 0.0<=h<=600.0)\n' ' │ layer: null\n' @@ -267,8 +271,9 @@ void main() { ' │\n' ' └─child: RenderConstrainedBox#00000 relayoutBoundary=up2\n' ' │ creator: ConstrainedBox ← Padding ← Container ← Align ←\n' - ' │ MediaQuery ← _MediaQueryFromView ← _ViewScope ←\n' - ' │ View-[GlobalObjectKey TestFlutterView#00000] ← [root]\n' + ' │ MediaQuery ← _MediaQueryFromView ← _PipelineOwnerScope ←\n' + ' │ _ViewScope ← _RawView-[_DeprecatedRawViewKey\n' + ' │ TestFlutterView#00000] ← View ← [root]\n' ' │ parentData: offset=Offset(5.0, 5.0) (can use size)\n' ' │ constraints: BoxConstraints(0.0<=w<=790.0, 0.0<=h<=590.0)\n' ' │ layer: null\n' @@ -278,8 +283,9 @@ void main() { ' │\n' ' └─child: RenderDecoratedBox#00000\n' ' │ creator: DecoratedBox ← ConstrainedBox ← Padding ← Container ←\n' - ' │ Align ← MediaQuery ← _MediaQueryFromView ← _ViewScope ←\n' - ' │ View-[GlobalObjectKey TestFlutterView#00000] ← [root]\n' + ' │ Align ← MediaQuery ← _MediaQueryFromView ← _PipelineOwnerScope\n' + ' │ ← _ViewScope ← _RawView-[_DeprecatedRawViewKey\n' + ' │ TestFlutterView#00000] ← View ← [root]\n' ' │ parentData: (can use size)\n' ' │ constraints: BoxConstraints(w=53.0, h=78.0)\n' ' │ layer: null\n' @@ -300,8 +306,9 @@ void main() { ' └─child: _RenderColoredBox#00000\n' ' │ creator: ColoredBox ← DecoratedBox ← ConstrainedBox ← Padding ←\n' ' │ Container ← Align ← MediaQuery ← _MediaQueryFromView ←\n' - ' │ _ViewScope ← View-[GlobalObjectKey TestFlutterView#00000] ←\n' - ' │ [root]\n' + ' │ _PipelineOwnerScope ← _ViewScope ←\n' + ' │ _RawView-[_DeprecatedRawViewKey TestFlutterView#00000] ← View ←\n' + ' │ ⋯\n' ' │ parentData: (can use size)\n' ' │ constraints: BoxConstraints(w=53.0, h=78.0)\n' ' │ layer: null\n' @@ -312,8 +319,8 @@ void main() { ' └─child: RenderPadding#00000\n' ' │ creator: Padding ← ColoredBox ← DecoratedBox ← ConstrainedBox ←\n' ' │ Padding ← Container ← Align ← MediaQuery ← _MediaQueryFromView\n' - ' │ ← _ViewScope ← View-[GlobalObjectKey TestFlutterView#00000] ←\n' - ' │ [root]\n' + ' │ ← _PipelineOwnerScope ← _ViewScope ←\n' + ' │ _RawView-[_DeprecatedRawViewKey TestFlutterView#00000] ← ⋯\n' ' │ parentData: (can use size)\n' ' │ constraints: BoxConstraints(w=53.0, h=78.0)\n' ' │ layer: null\n' @@ -325,8 +332,7 @@ void main() { ' └─child: RenderPositionedBox#00000\n' ' │ creator: Align ← Padding ← ColoredBox ← DecoratedBox ←\n' ' │ ConstrainedBox ← Padding ← Container ← Align ← MediaQuery ←\n' - ' │ _MediaQueryFromView ← _ViewScope ← View-[GlobalObjectKey\n' - ' │ TestFlutterView#00000] ← ⋯\n' + ' │ _MediaQueryFromView ← _PipelineOwnerScope ← _ViewScope ← ⋯\n' ' │ parentData: offset=Offset(7.0, 7.0) (can use size)\n' ' │ constraints: BoxConstraints(w=39.0, h=64.0)\n' ' │ layer: null\n' @@ -340,7 +346,7 @@ void main() { ' └─child: RenderConstrainedBox#00000 relayoutBoundary=up1\n' ' │ creator: SizedBox ← Align ← Padding ← ColoredBox ← DecoratedBox ←\n' ' │ ConstrainedBox ← Padding ← Container ← Align ← MediaQuery ←\n' - ' │ _MediaQueryFromView ← _ViewScope ← ⋯\n' + ' │ _MediaQueryFromView ← _PipelineOwnerScope ← ⋯\n' ' │ parentData: offset=Offset(14.0, 31.0) (can use size)\n' ' │ constraints: BoxConstraints(0.0<=w<=39.0, 0.0<=h<=64.0)\n' ' │ layer: null\n' @@ -367,7 +373,7 @@ void main() { ' shape: rectangle\n' ' configuration: ImageConfiguration(bundle:\n' ' PlatformAssetBundle#00000(), devicePixelRatio: 3.0, platform:\n' - ' android)\n', + ' android)\n' ), ); }); @@ -386,8 +392,9 @@ void main() { 'RenderPadding#00000 relayoutBoundary=up1\n' ' │ needsCompositing: false\n' ' │ creator: Padding ← Container ← Align ← MediaQuery ←\n' - ' │ _MediaQueryFromView ← _ViewScope ← View-[GlobalObjectKey\n' - ' │ TestFlutterView#00000] ← [root]\n' + ' │ _MediaQueryFromView ← _PipelineOwnerScope ← _ViewScope ←\n' + ' │ _RawView-[_DeprecatedRawViewKey TestFlutterView#00000] ← View ←\n' + ' │ [root]\n' ' │ parentData: offset=Offset(0.0, 0.0) (can use size)\n' ' │ constraints: BoxConstraints(0.0<=w<=800.0, 0.0<=h<=600.0)\n' ' │ layer: null\n' @@ -401,8 +408,9 @@ void main() { ' └─child: RenderConstrainedBox#00000 relayoutBoundary=up2\n' ' │ needsCompositing: false\n' ' │ creator: ConstrainedBox ← Padding ← Container ← Align ←\n' - ' │ MediaQuery ← _MediaQueryFromView ← _ViewScope ←\n' - ' │ View-[GlobalObjectKey TestFlutterView#00000] ← [root]\n' + ' │ MediaQuery ← _MediaQueryFromView ← _PipelineOwnerScope ←\n' + ' │ _ViewScope ← _RawView-[_DeprecatedRawViewKey\n' + ' │ TestFlutterView#00000] ← View ← [root]\n' ' │ parentData: offset=Offset(5.0, 5.0) (can use size)\n' ' │ constraints: BoxConstraints(0.0<=w<=790.0, 0.0<=h<=590.0)\n' ' │ layer: null\n' @@ -415,8 +423,9 @@ void main() { ' └─child: RenderDecoratedBox#00000\n' ' │ needsCompositing: false\n' ' │ creator: DecoratedBox ← ConstrainedBox ← Padding ← Container ←\n' - ' │ Align ← MediaQuery ← _MediaQueryFromView ← _ViewScope ←\n' - ' │ View-[GlobalObjectKey TestFlutterView#00000] ← [root]\n' + ' │ Align ← MediaQuery ← _MediaQueryFromView ← _PipelineOwnerScope\n' + ' │ ← _ViewScope ← _RawView-[_DeprecatedRawViewKey\n' + ' │ TestFlutterView#00000] ← View ← [root]\n' ' │ parentData: (can use size)\n' ' │ constraints: BoxConstraints(w=53.0, h=78.0)\n' ' │ layer: null\n' @@ -440,8 +449,9 @@ void main() { ' │ needsCompositing: false\n' ' │ creator: ColoredBox ← DecoratedBox ← ConstrainedBox ← Padding ←\n' ' │ Container ← Align ← MediaQuery ← _MediaQueryFromView ←\n' - ' │ _ViewScope ← View-[GlobalObjectKey TestFlutterView#00000] ←\n' - ' │ [root]\n' + ' │ _PipelineOwnerScope ← _ViewScope ←\n' + ' │ _RawView-[_DeprecatedRawViewKey TestFlutterView#00000] ← View ←\n' + ' │ ⋯\n' ' │ parentData: (can use size)\n' ' │ constraints: BoxConstraints(w=53.0, h=78.0)\n' ' │ layer: null\n' @@ -455,8 +465,8 @@ void main() { ' │ needsCompositing: false\n' ' │ creator: Padding ← ColoredBox ← DecoratedBox ← ConstrainedBox ←\n' ' │ Padding ← Container ← Align ← MediaQuery ← _MediaQueryFromView\n' - ' │ ← _ViewScope ← View-[GlobalObjectKey TestFlutterView#00000] ←\n' - ' │ [root]\n' + ' │ ← _PipelineOwnerScope ← _ViewScope ←\n' + ' │ _RawView-[_DeprecatedRawViewKey TestFlutterView#00000] ← ⋯\n' ' │ parentData: (can use size)\n' ' │ constraints: BoxConstraints(w=53.0, h=78.0)\n' ' │ layer: null\n' @@ -471,8 +481,7 @@ void main() { ' │ needsCompositing: false\n' ' │ creator: Align ← Padding ← ColoredBox ← DecoratedBox ←\n' ' │ ConstrainedBox ← Padding ← Container ← Align ← MediaQuery ←\n' - ' │ _MediaQueryFromView ← _ViewScope ← View-[GlobalObjectKey\n' - ' │ TestFlutterView#00000] ← ⋯\n' + ' │ _MediaQueryFromView ← _PipelineOwnerScope ← _ViewScope ← ⋯\n' ' │ parentData: offset=Offset(7.0, 7.0) (can use size)\n' ' │ constraints: BoxConstraints(w=39.0, h=64.0)\n' ' │ layer: null\n' @@ -489,7 +498,7 @@ void main() { ' │ needsCompositing: false\n' ' │ creator: SizedBox ← Align ← Padding ← ColoredBox ← DecoratedBox ←\n' ' │ ConstrainedBox ← Padding ← Container ← Align ← MediaQuery ←\n' - ' │ _MediaQueryFromView ← _ViewScope ← ⋯\n' + ' │ _MediaQueryFromView ← _PipelineOwnerScope ← ⋯\n' ' │ parentData: offset=Offset(14.0, 31.0) (can use size)\n' ' │ constraints: BoxConstraints(0.0<=w<=39.0, 0.0<=h<=64.0)\n' ' │ layer: null\n' @@ -521,7 +530,7 @@ void main() { ' shape: rectangle\n' ' configuration: ImageConfiguration(bundle:\n' ' PlatformAssetBundle#00000(), devicePixelRatio: 3.0, platform:\n' - ' android)\n', + ' android)\n' ), ); }); diff --git a/packages/flutter/test/widgets/custom_multi_child_layout_test.dart b/packages/flutter/test/widgets/custom_multi_child_layout_test.dart index 0a6db8b1c85..a12887496a0 100644 --- a/packages/flutter/test/widgets/custom_multi_child_layout_test.dart +++ b/packages/flutter/test/widgets/custom_multi_child_layout_test.dart @@ -373,8 +373,9 @@ void main() { ' The following child has no ID: RenderConstrainedBox#00000 NEEDS-LAYOUT NEEDS-PAINT:\n' ' creator: ConstrainedBox ← Container ← LayoutWithMissingId ←\n' ' CustomMultiChildLayout ← Center ← MediaQuery ←\n' - ' _MediaQueryFromView ← _ViewScope ← View-[GlobalObjectKey\n' - ' TestFlutterView#00000] ← [root]\n' + ' _MediaQueryFromView ← _PipelineOwnerScope ← _ViewScope ←\n' + ' _RawView-[_DeprecatedRawViewKey TestFlutterView#00000] ← View ←\n' + ' [root]\n' ' parentData: offset=Offset(0.0, 0.0); id=null\n' ' constraints: MISSING\n' ' size: MISSING\n' diff --git a/packages/flutter/test/widgets/debug_test.dart b/packages/flutter/test/widgets/debug_test.dart index a98f70fe4ff..8ae59d0aa05 100644 --- a/packages/flutter/test/widgets/debug_test.dart +++ b/packages/flutter/test/widgets/debug_test.dart @@ -144,7 +144,10 @@ void main() { ), ); } - return Container(); + return View( + view: tester.view, + child: const SizedBox(), + ); }, ), ); diff --git a/packages/flutter/test/widgets/focus_manager_test.dart b/packages/flutter/test/widgets/focus_manager_test.dart index 1589bac4e9d..0a49b56f4ec 100644 --- a/packages/flutter/test/widgets/focus_manager_test.dart +++ b/packages/flutter/test/widgets/focus_manager_test.dart @@ -1227,8 +1227,9 @@ void main() { 'FocusManager#00000\n' ' │ primaryFocus: FocusNode#00000(Child 4 [PRIMARY FOCUS])\n' ' │ primaryFocusCreator: Container-[GlobalKey#00000] ← MediaQuery ←\n' - ' │ _MediaQueryFromView ← _ViewScope ← View-[GlobalObjectKey\n' - ' │ TestFlutterView#00000] ← [root]\n' + ' │ _MediaQueryFromView ← _PipelineOwnerScope ← _ViewScope ←\n' + ' │ _RawView-[_DeprecatedRawViewKey TestFlutterView#00000] ← View ←\n' + ' │ [root]\n' ' │\n' ' └─rootScope: FocusScopeNode#00000(Root Focus Scope [IN FOCUS PATH])\n' ' │ IN FOCUS PATH\n' diff --git a/packages/flutter/test/widgets/init_state_test.dart b/packages/flutter/test/widgets/init_state_test.dart index e70267e55e6..d6b74a256d3 100644 --- a/packages/flutter/test/widgets/init_state_test.dart +++ b/packages/flutter/test/widgets/init_state_test.dart @@ -30,7 +30,7 @@ class TestWidgetState extends State { void main() { testWidgets('initState() is called when we are in the tree', (WidgetTester tester) async { await tester.pumpWidget(const Parent(child: TestWidget())); - expect(ancestors, containsAllInOrder(['Parent', 'View', 'RenderObjectToWidgetAdapter'])); + expect(ancestors, containsAllInOrder(['Parent', 'View', 'RootWidget'])); }); } diff --git a/packages/flutter/test/widgets/keep_alive_test.dart b/packages/flutter/test/widgets/keep_alive_test.dart index a92e9b24ca9..b25a14d45d6 100644 --- a/packages/flutter/test/widgets/keep_alive_test.dart +++ b/packages/flutter/test/widgets/keep_alive_test.dart @@ -205,7 +205,7 @@ void main() { ); // The important lines below are the ones marked with "<----" expect(tester.binding.renderView.toStringDeep(minLevel: DiagnosticLevel.info), equalsIgnoringHashCodes( - 'RenderView#00000\n' + '_ReusableRenderView#00000\n' ' │ debug mode enabled - ${Platform.operatingSystem}\n' ' │ view size: Size(2400.0, 1800.0) (in physical pixels)\n' ' │ device pixel ratio: 3.0 (physical pixels per logical pixel)\n' @@ -379,7 +379,7 @@ void main() { await tester.drag(find.byType(ListView), const Offset(0.0, -1000.0)); await tester.pump(); expect(tester.binding.renderView.toStringDeep(minLevel: DiagnosticLevel.info), equalsIgnoringHashCodes( - 'RenderView#00000\n' + '_ReusableRenderView#00000\n' ' │ debug mode enabled - ${Platform.operatingSystem}\n' ' │ view size: Size(2400.0, 1800.0) (in physical pixels)\n' ' │ device pixel ratio: 3.0 (physical pixels per logical pixel)\n' diff --git a/packages/flutter/test/widgets/media_query_test.dart b/packages/flutter/test/widgets/media_query_test.dart index 135f284ac96..2a24e45ca07 100644 --- a/packages/flutter/test/widgets/media_query_test.dart +++ b/packages/flutter/test/widgets/media_query_test.dart @@ -44,26 +44,25 @@ class _MediaQueryAspectVariant extends TestVariant<_MediaQueryAspectCase> { void main() { testWidgets('MediaQuery does not have a default', (WidgetTester tester) async { - bool tested = false; + 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( builder: (BuildContext context) { - tested = true; - MediaQuery.of(context); // should throw - return Container(); + try { + MediaQuery.of(context); + } on FlutterError catch (e) { + error = e; + } + return View( + view: tester.view, + child: const SizedBox(), + ); }, ), ); - expect(tested, isTrue); - final dynamic exception = tester.takeException(); - expect(exception, isNotNull); - expect(exception ,isFlutterError); - final FlutterError error = exception as FlutterError; - expect(error.diagnostics.length, 5); - expect(error.diagnostics.last, isA()); expect( error.toStringDeep(), startsWith( @@ -119,7 +118,10 @@ void main() { final MediaQueryData? data = MediaQuery.maybeOf(context); expect(data, isNull); tested = true; - return Container(); + return View( + view: tester.view, + child: const SizedBox(), + ); }, ), ); @@ -295,7 +297,10 @@ void main() { child: Builder( builder: (BuildContext context) { data = MediaQuery.of(context); - return const Placeholder(); + return View( + view: tester.view, + child: const SizedBox(), + ); }, ) ); @@ -348,7 +353,10 @@ void main() { builder: (BuildContext context) { rebuildCount++; data = MediaQuery.of(context); - return const Placeholder(); + return View( + view: tester.view, + child: const SizedBox(), + ); }, ), ); diff --git a/packages/flutter/test/widgets/multi_view_binding_test.dart b/packages/flutter/test/widgets/multi_view_binding_test.dart new file mode 100644 index 00000000000..6c7c1d09de3 --- /dev/null +++ b/packages/flutter/test/widgets/multi_view_binding_test.dart @@ -0,0 +1,39 @@ +// 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/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('runApp uses deprecated pipelineOwner and renderView', (WidgetTester tester) async { + runApp(const SizedBox()); + final RenderObject renderObject = tester.renderObject(find.byType(SizedBox)); + + RenderObject parent = renderObject; + while (parent.parent != null) { + parent = parent.parent!; + } + expect(parent, isA()); + expect(parent, equals(tester.binding.renderView)); + + expect(renderObject.owner, equals(tester.binding.pipelineOwner)); + }); + + testWidgets('can manually attach RootWidget to build owner', (WidgetTester tester) async { + expect(find.byType(ColoredBox), findsNothing); + + final RootWidget rootWidget = RootWidget( + child: View( + view: tester.view, + child: const ColoredBox(color: Colors.orange), + ), + ); + tester.binding.attachToBuildOwner(rootWidget); + await tester.pump(); + expect(find.byType(ColoredBox), findsOneWidget); + expect(tester.binding.rootElement!.widget, equals(rootWidget)); + expect(tester.element(find.byType(ColoredBox)).owner, equals(tester.binding.buildOwner)); + }); +} diff --git a/packages/flutter/test/widgets/multi_view_tree_updates_test.dart b/packages/flutter/test/widgets/multi_view_tree_updates_test.dart new file mode 100644 index 00000000000..7bc41fe9c51 --- /dev/null +++ b/packages/flutter/test/widgets/multi_view_tree_updates_test.dart @@ -0,0 +1,221 @@ +// 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 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Widgets in view update as expected', (WidgetTester tester) async { + final Widget widget = View( + view: tester.view, + child: const TestWidget(), + ); + + await pumpWidgetWithoutViewWrapper( + tester: tester, + widget: widget, + ); + + expect(find.text('Hello'), findsOneWidget); + expect(tester.renderObject(find.byType(Text)).text.toPlainText(), 'Hello'); + + tester.state(find.byType(TestWidget)).text = 'World'; + await tester.pump(); + expect(find.text('Hello'), findsNothing); + expect(find.text('World'), findsOneWidget); + expect(tester.renderObject(find.byType(Text)).text.toPlainText(), 'World'); + + await pumpWidgetWithoutViewWrapper( + tester: tester, + widget: ViewCollection( + views: [widget], + ), + ); + expect(find.text('Hello'), findsNothing); + expect(find.text('World'), findsOneWidget); + expect(tester.renderObject(find.byType(Text)).text.toPlainText(), 'World'); + + tester.state(find.byType(TestWidget)).text = 'FooBar'; + await pumpWidgetWithoutViewWrapper( + tester: tester, + widget: widget, + ); + expect(find.text('World'), findsNothing); + expect(find.text('FooBar'), findsOneWidget); + expect(tester.renderObject(find.byType(Text)).text.toPlainText(), 'FooBar'); + }); + + testWidgets('Views in ViewCollection update as expected', (WidgetTester tester) async { + Iterable renderParagraphTexts() { + return tester.renderObjectList(find.byType(Text)).map((RenderParagraph r) => r.text.toPlainText()); + } + + final Key key1 = UniqueKey(); + final Key key2 = UniqueKey(); + final Widget view1 = View( + view: tester.view, + child: TestWidget(key: key1), + ); + final Widget view2 = View( + view: FakeView(tester.view), + child: TestWidget(key: key2), + ); + + await pumpWidgetWithoutViewWrapper( + tester: tester, + widget: ViewCollection( + views: [view1, view2], + ), + ); + + expect(find.text('Hello'), findsNWidgets(2)); + expect(renderParagraphTexts(), ['Hello', 'Hello']); + + tester.state(find.byKey(key1)).text = 'Guten'; + tester.state(find.byKey(key2)).text = 'Tag'; + await tester.pump(); + expect(find.text('Hello'), findsNothing); + expect(find.text('Guten'), findsOneWidget); + expect(find.text('Tag'), findsOneWidget); + expect(renderParagraphTexts(), ['Guten', 'Tag']); + + tester.state(find.byKey(key2)).text = 'Abend'; + await tester.pump(); + expect(find.text('Tag'), findsNothing); + expect(find.text('Guten'), findsOneWidget); + expect(find.text('Abend'), findsOneWidget); + expect(renderParagraphTexts(), ['Guten', 'Abend']); + + tester.state(find.byKey(key2)).text = 'Morgen'; + await pumpWidgetWithoutViewWrapper( + tester: tester, + widget: ViewCollection( + views: [view1, ViewCollection(views: [view2])], + ), + ); + expect(find.text('Abend'), findsNothing); + expect(find.text('Guten'), findsOneWidget); + expect(find.text('Morgen'), findsOneWidget); + expect(renderParagraphTexts(), ['Guten', 'Morgen']); + }); + + testWidgets('Views in ViewAnchor update as expected', (WidgetTester tester) async { + Iterable renderParagraphTexts() { + return tester.renderObjectList(find.byType(Text)).map((RenderParagraph r) => r.text.toPlainText()); + } + + final Key insideAnchoredViewKey = UniqueKey(); + final Key outsideAnchoredViewKey = UniqueKey(); + final Widget view = View( + view: FakeView(tester.view), + child: TestWidget(key: insideAnchoredViewKey), + ); + + await tester.pumpWidget( + ViewAnchor( + view: view, + child: TestWidget(key: outsideAnchoredViewKey), + ), + ); + + expect(find.text('Hello'), findsNWidgets(2)); + expect(renderParagraphTexts(), ['Hello', 'Hello']); + + tester.state(find.byKey(outsideAnchoredViewKey)).text = 'Guten'; + tester.state(find.byKey(insideAnchoredViewKey)).text = 'Tag'; + await tester.pump(); + expect(find.text('Hello'), findsNothing); + expect(find.text('Guten'), findsOneWidget); + expect(find.text('Tag'), findsOneWidget); + expect(renderParagraphTexts(), ['Guten', 'Tag']); + + tester.state(find.byKey(insideAnchoredViewKey)).text = 'Abend'; + await tester.pump(); + expect(find.text('Tag'), findsNothing); + expect(find.text('Guten'), findsOneWidget); + expect(find.text('Abend'), findsOneWidget); + expect(renderParagraphTexts(), ['Guten', 'Abend']); + + tester.state(find.byKey(outsideAnchoredViewKey)).text = 'Schönen'; + await tester.pump(); + expect(find.text('Guten'), findsNothing); + expect(find.text('Schönen'), findsOneWidget); + expect(find.text('Abend'), findsOneWidget); + expect(renderParagraphTexts(), ['Schönen', 'Abend']); + + tester.state(find.byKey(insideAnchoredViewKey)).text = 'Tag'; + await tester.pumpWidget( + ViewAnchor( + view: ViewCollection(views: [view]), + child: TestWidget(key: outsideAnchoredViewKey), + ), + ); + await tester.pump(); + expect(find.text('Abend'), findsNothing); + expect(find.text('Schönen'), findsOneWidget); + expect(find.text('Tag'), findsOneWidget); + expect(renderParagraphTexts(), ['Schönen', 'Tag']); + + tester.state(find.byKey(insideAnchoredViewKey)).text = 'Morgen'; + await tester.pumpWidget( + SizedBox( + child: ViewAnchor( + view: ViewCollection(views: [view]), + child: TestWidget(key: outsideAnchoredViewKey), + ), + ), + ); + await tester.pump(); + expect(find.text('Schönen'), findsNothing); // The `outsideAnchoredViewKey` is not a global key, its state is lost in the move above. + expect(find.text('Tag'), findsNothing); + expect(find.text('Hello'), findsOneWidget); + expect(find.text('Morgen'), findsOneWidget); + expect(renderParagraphTexts(), ['Hello', 'Morgen']); + }); +} + +class TestWidget extends StatefulWidget { + const TestWidget({super.key}); + + @override + State createState() => TestWidgetState(); +} + +class TestWidgetState extends State { + String get text => _text; + String _text = 'Hello'; + set text(String value) { + if (_text == value) { + return; + } + setState(() { + _text = value; + }); + } + + @override + Widget build(BuildContext context) { + 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(); +} + +class FakeView extends TestFlutterView{ + FakeView(FlutterView view, { this.viewId = 100 }) : super( + view: view, + platformDispatcher: view.platformDispatcher as TestPlatformDispatcher, + display: view.display as TestDisplay, + ); + + @override + final int viewId; +} diff --git a/packages/flutter/test/widgets/slotted_render_object_widget_test.dart b/packages/flutter/test/widgets/slotted_render_object_widget_test.dart index 7d61f844a81..1ec23fa8081 100644 --- a/packages/flutter/test/widgets/slotted_render_object_widget_test.dart +++ b/packages/flutter/test/widgets/slotted_render_object_widget_test.dart @@ -222,16 +222,18 @@ void main() { equalsIgnoringHashCodes( '_RenderDiagonal#00000 relayoutBoundary=up1\n' ' │ creator: _Diagonal ← Align ← Directionality ← MediaQuery ←\n' - ' │ _MediaQueryFromView ← _ViewScope ← View-[GlobalObjectKey\n' - ' │ TestFlutterView#00000] ← [root]\n' + ' │ _MediaQueryFromView ← _PipelineOwnerScope ← _ViewScope ←\n' + ' │ _RawView-[_DeprecatedRawViewKey TestFlutterView#00000] ← View ←\n' + ' │ [root]\n' ' │ parentData: offset=Offset(0.0, 0.0) (can use size)\n' ' │ constraints: BoxConstraints(0.0<=w<=800.0, 0.0<=h<=600.0)\n' ' │ size: Size(190.0, 220.0)\n' ' │\n' ' ├─topLeft: RenderConstrainedBox#00000 relayoutBoundary=up2\n' ' │ creator: SizedBox ← _Diagonal ← Align ← Directionality ←\n' - ' │ MediaQuery ← _MediaQueryFromView ← _ViewScope ←\n' - ' │ View-[GlobalObjectKey TestFlutterView#00000] ← [root]\n' + ' │ MediaQuery ← _MediaQueryFromView ← _PipelineOwnerScope ←\n' + ' │ _ViewScope ← _RawView-[_DeprecatedRawViewKey\n' + ' │ TestFlutterView#00000] ← View ← [root]\n' ' │ parentData: offset=Offset(0.0, 0.0) (can use size)\n' ' │ constraints: BoxConstraints(unconstrained)\n' ' │ size: Size(80.0, 100.0)\n' @@ -239,8 +241,9 @@ void main() { ' │\n' ' └─bottomRight: RenderConstrainedBox#00000 relayoutBoundary=up2\n' ' creator: SizedBox ← _Diagonal ← Align ← Directionality ←\n' - ' MediaQuery ← _MediaQueryFromView ← _ViewScope ←\n' - ' View-[GlobalObjectKey TestFlutterView#00000] ← [root]\n' + ' MediaQuery ← _MediaQueryFromView ← _PipelineOwnerScope ←\n' + ' _ViewScope ← _RawView-[_DeprecatedRawViewKey\n' + ' TestFlutterView#00000] ← View ← [root]\n' ' parentData: offset=Offset(80.0, 100.0) (can use size)\n' ' constraints: BoxConstraints(unconstrained)\n' ' size: Size(110.0, 120.0)\n' diff --git a/packages/flutter/test/widgets/stateful_component_test.dart b/packages/flutter/test/widgets/stateful_component_test.dart index 3cebbe5829b..078e30f0cd6 100644 --- a/packages/flutter/test/widgets/stateful_component_test.dart +++ b/packages/flutter/test/widgets/stateful_component_test.dart @@ -10,10 +10,9 @@ import 'test_widgets.dart'; void main() { testWidgets('Stateful widget smoke test', (WidgetTester tester) async { - void checkTree(BoxDecoration expectedDecoration) { final SingleChildRenderObjectElement element = tester.element( - find.byElementPredicate((Element element) => element is SingleChildRenderObjectElement), + find.byElementPredicate((Element element) => element is SingleChildRenderObjectElement && element.renderObject is! RenderView), ); expect(element, isNotNull); expect(element.renderObject, isA()); diff --git a/packages/flutter/test/widgets/tree_shape_test.dart b/packages/flutter/test/widgets/tree_shape_test.dart new file mode 100644 index 00000000000..306bacfb18c --- /dev/null +++ b/packages/flutter/test/widgets/tree_shape_test.dart @@ -0,0 +1,1161 @@ +// 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 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.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), + ); + + expect(tester.takeException(), isFlutterError.having( + (FlutterError error) => error.message, + 'message', + startsWith('The render object for ColoredBox cannot find ancestor render object to attach to.'), + )); + }); + + testWidgets('Moving a RenderObjectWidget to the RootWidget via GlobalKey fails', (WidgetTester tester) async { + final Widget globalKeyedWidget = ColoredBox( + key: GlobalKey(), + color: Colors.red, + ); + + await pumpWidgetWithoutViewWrapper( + tester: tester, + widget: View( + view: tester.view, + child: globalKeyedWidget, + ), + ); + expect(tester.takeException(), isNull); + + await pumpWidgetWithoutViewWrapper( + tester: tester, + widget: globalKeyedWidget, + ); + + expect(tester.takeException(), isFlutterError.having( + (FlutterError error) => error.message, + 'message', + contains('cannot find ancestor render object to attach to.'), + )); + }); + + testWidgets('A View cannot be a child of a render object widget', (WidgetTester tester) async { + await tester.pumpWidget(Center( + child: View( + view: FakeView(tester.view), + child: Container(), + ), + )); + + expect(tester.takeException(), isFlutterError.having( + (FlutterError error) => error.message, + 'message', + contains('cannot maintain an independent render tree at its current location.'), + )); + }); + + testWidgets('The child of a ViewAnchor cannot be a View', (WidgetTester tester) async { + await tester.pumpWidget( + ViewAnchor( + child: View( + view: FakeView(tester.view), + child: Container(), + ), + ), + ); + + expect(tester.takeException(), isFlutterError.having( + (FlutterError error) => error.message, + 'message', + contains('cannot maintain an independent render tree at its current location.'), + )); + }); + + testWidgets('A View can not be moved via GlobalKey to be a child of a RenderObject', (WidgetTester tester) async { + final Widget globalKeyedView = View( + key: GlobalKey(), + view: FakeView(tester.view), + child: const ColoredBox(color: Colors.red), + ); + + await pumpWidgetWithoutViewWrapper( + tester: tester, + widget: globalKeyedView, + ); + expect(tester.takeException(), isNull); + + await pumpWidgetWithoutViewWrapper( + tester: tester, + widget: View( + view: tester.view, + child: globalKeyedView, + ), + ); + + expect(tester.takeException(), isFlutterError.having( + (FlutterError error) => error.message, + 'message', + contains('cannot maintain an independent render tree at its current location.'), + )); + }); + + testWidgets('The view property of a ViewAnchor cannot be a render object widget', (WidgetTester tester) async { + await tester.pumpWidget( + ViewAnchor( + view: const ColoredBox(color: Colors.red), + child: Container(), + ), + ); + + expect(tester.takeException(), isFlutterError.having( + (FlutterError error) => error.message, + 'message', + startsWith('The render object for ColoredBox cannot find ancestor render object to attach to.'), + )); + }); + + testWidgets('A RenderObject cannot be moved into the view property of a ViewAnchor via GlobalKey', (WidgetTester tester) async { + final Widget globalKeyedWidget = ColoredBox( + key: GlobalKey(), + color: Colors.red, + ); + + await tester.pumpWidget( + ViewAnchor( + child: globalKeyedWidget, + ), + ); + expect(tester.takeException(), isNull); + + await tester.pumpWidget( + ViewAnchor( + view: globalKeyedWidget, + child: const SizedBox(), + ), + ); + + expect(tester.takeException(), isFlutterError.having( + (FlutterError error) => error.message, + 'message', + contains('cannot find ancestor render object to attach to.'), + )); + }); + + 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( + child: SizedBox(), + ), + ); + + expect(tester.takeException(), isFlutterError.having( + (FlutterError error) => error.message, + 'message', + startsWith('The render object for SizedBox cannot find ancestor render object to attach to.'), + )); + }); + + testWidgets('ViewAnchor cannot be moved to the top of the widget tree (outside of View) via GlobalKey', (WidgetTester tester) async { + final Widget globalKeyedViewAnchor = ViewAnchor( + key: GlobalKey(), + child: const SizedBox(), + ); + + await pumpWidgetWithoutViewWrapper( + tester: tester, + widget: View( + view: tester.view, + child: globalKeyedViewAnchor, + ), + ); + expect(tester.takeException(), isNull); + + await pumpWidgetWithoutViewWrapper( + tester: tester, + widget: globalKeyedViewAnchor, + ); + + expect(tester.takeException(), isFlutterError.having( + (FlutterError error) => error.message, + 'message', + contains('cannot find ancestor render object to attach to.'), + )); + }); + + testWidgets('View can be used at the top of the widget tree', (WidgetTester tester) async { + await pumpWidgetWithoutViewWrapper( + tester: tester, + widget: View( + view: tester.view, + child: Container(), + ), + ); + + expect(tester.takeException(), isNull); + }); + + testWidgets('View can be moved to the top of the widget tree view GlobalKey', (WidgetTester tester) async { + final Widget globalKeyView = View( + view: FakeView(tester.view), + child: const ColoredBox(color: Colors.red), + ); + + await pumpWidgetWithoutViewWrapper( + tester: tester, + widget: View( + view: tester.view, + child: ViewAnchor( + view: globalKeyView, // This one has trouble when deactivating + child: const SizedBox(), + ), + ), + ); + expect(tester.takeException(), isNull); + expect(find.byType(SizedBox), findsOneWidget); + expect(find.byType(ColoredBox), findsOneWidget); + + await pumpWidgetWithoutViewWrapper( + tester: tester, + widget: globalKeyView, + ); + expect(tester.takeException(), isNull); + expect(find.byType(SizedBox), findsNothing); + expect(find.byType(ColoredBox), findsOneWidget); + }); + + testWidgets('ViewCollection can be used at the top of the widget tree', (WidgetTester tester) async { + await pumpWidgetWithoutViewWrapper( + tester: tester, + widget: ViewCollection( + views: [ + View( + view: tester.view, + child: Container(), + ), + ], + ), + ); + + expect(tester.takeException(), isNull); + }); + + testWidgets('ViewCollection cannot be used inside a View', (WidgetTester tester) async { + await tester.pumpWidget( + ViewCollection( + views: [ + View( + view: FakeView(tester.view), + child: Container(), + ), + ], + ), + ); + + expect(tester.takeException(), isFlutterError.having( + (FlutterError error) => error.message, + 'message', + startsWith('The Element for ViewCollection cannot be inserted into slot "null" of its ancestor.'), + )); + }); + + testWidgets('ViewCollection can be used as ViewAnchor.view', (WidgetTester tester) async { + await tester.pumpWidget( + ViewAnchor( + view: ViewCollection( + views: [ + View( + view: FakeView(tester.view), + child: Container(), + ) + ], + ), + child: Container(), + ), + ); + + expect(tester.takeException(), isNull); + }); + + testWidgets('ViewCollection cannot have render object widgets as children', (WidgetTester tester) async { + await pumpWidgetWithoutViewWrapper( + tester: tester, + widget: ViewCollection( + views: const [ + ColoredBox(color: Colors.red), + ], + ), + ); + + expect(tester.takeException(), isFlutterError.having( + (FlutterError error) => error.message, + 'message', + startsWith('The render object for ColoredBox cannot find ancestor render object to attach to.'), + )); + }); + + testWidgets('Views can be moved in and out of ViewCollections via GlobalKey', (WidgetTester tester) async { + final Widget greenView = View( + key: GlobalKey(debugLabel: 'green'), + view: tester.view, + child: const ColoredBox(color: Colors.green), + ); + final Widget redView = View( + key: GlobalKey(debugLabel: 'red'), + view: FakeView(tester.view), + child: const ColoredBox(color: Colors.red), + ); + + await pumpWidgetWithoutViewWrapper( + tester: tester, + widget: ViewCollection( + views: [ + greenView, + ViewCollection( + views: [ + redView, + ], + ), + ] + ), + ); + expect(tester.takeException(), isNull); + expect(find.byType(ColoredBox), findsNWidgets(2)); + + await pumpWidgetWithoutViewWrapper( + tester: tester, + widget: ViewCollection( + views: [ + redView, + ViewCollection( + views: [ + greenView, + ], + ), + ] + ), + ); + expect(tester.takeException(), isNull); + expect(find.byType(ColoredBox), findsNWidgets(2)); + }); + + testWidgets('Can move stuff between views via global key: viewA -> viewB', (WidgetTester tester) async { + final FlutterView greenView = tester.view; + final FlutterView redView = FakeView(tester.view); + final Widget globalKeyChild = SizedBox( + key: GlobalKey(), + ); + + Map collectLeafRenderObjects() { + final Map result = {}; + for (final RenderView renderView in RendererBinding.instance.renderViews) { + void visit(RenderObject object) { + result[renderView.flutterView.viewId] = object; + object.visitChildren(visit); + } + visit(renderView); + } + return result; + } + + await pumpWidgetWithoutViewWrapper( + tester: tester, + widget: ViewCollection( + views: [ + View( + view: greenView, + child: ColoredBox( + color: Colors.green, + child: globalKeyChild, + ), + ), + View( + view: redView, + child: const ColoredBox( + color: Colors.red, + ), + ), + ], + ), + ); + expect( + find.descendant( + of: findsColoredBox(Colors.green), + matching: find.byType(SizedBox), + ), + findsOneWidget, + ); + expect( + find.descendant( + of: findsColoredBox(Colors.red), + matching: find.byType(SizedBox), + ), + findsNothing, + ); + final RenderObject boxWithGlobalKey = tester.renderObject(find.byKey(globalKeyChild.key!)); + + Map leafRenderObject = collectLeafRenderObjects(); + expect(leafRenderObject[greenView.viewId], isA()); + expect(leafRenderObject[redView.viewId], isNot(isA())); + + // Move the child. + await pumpWidgetWithoutViewWrapper( + tester: tester, + widget: ViewCollection( + views: [ + View( + view: greenView, + child: const ColoredBox( + color: Colors.green, + ), + ), + View( + view: redView, + child: ColoredBox( + color: Colors.red, + child: globalKeyChild, + ), + ), + ], + ), + ); + + expect( + find.descendant( + of: findsColoredBox(Colors.green), + matching: find.byType(SizedBox), + ), + findsNothing, + ); + expect( + find.descendant( + of: findsColoredBox(Colors.red), + matching: find.byType(SizedBox), + ), + findsOneWidget, + ); + expect( + tester.renderObject(find.byKey(globalKeyChild.key!)), + equals(boxWithGlobalKey), + ); + + leafRenderObject = collectLeafRenderObjects(); + expect(leafRenderObject[greenView.viewId], isNot(isA())); + expect(leafRenderObject[redView.viewId], isA()); + }); + + testWidgets('Can move stuff between views via global key: viewB -> viewA', (WidgetTester tester) async { + final FlutterView greenView = tester.view; + final FlutterView redView = FakeView(tester.view); + final Widget globalKeyChild = SizedBox( + key: GlobalKey(), + ); + + Map collectLeafRenderObjects() { + final Map result = {}; + for (final RenderView renderView in RendererBinding.instance.renderViews) { + void visit(RenderObject object) { + result[renderView.flutterView.viewId] = object; + object.visitChildren(visit); + } + visit(renderView); + } + return result; + } + + await pumpWidgetWithoutViewWrapper( + tester: tester, + widget: ViewCollection( + views: [ + View( + view: greenView, + child: const ColoredBox( + color: Colors.green, + ), + ), + View( + view: redView, + child: ColoredBox( + color: Colors.red, + child: globalKeyChild, + ), + ), + ], + ), + ); + expect( + find.descendant( + of: findsColoredBox(Colors.red), + matching: find.byType(SizedBox), + ), + findsOneWidget, + ); + expect( + find.descendant( + of: findsColoredBox(Colors.green), + matching: find.byType(SizedBox), + ), + findsNothing, + ); + final RenderObject boxWithGlobalKey = tester.renderObject(find.byKey(globalKeyChild.key!)); + + Map leafRenderObject = collectLeafRenderObjects(); + expect(leafRenderObject[redView.viewId], isA()); + expect(leafRenderObject[greenView.viewId], isNot(isA())); + + // Move the child. + await pumpWidgetWithoutViewWrapper( + tester: tester, + widget: ViewCollection( + views: [ + View( + view: greenView, + child: ColoredBox( + color: Colors.green, + child: globalKeyChild, + ), + ), + View( + view: redView, + child: const ColoredBox( + color: Colors.red, + ), + ), + ], + ), + ); + + expect( + find.descendant( + of: findsColoredBox(Colors.red), + matching: find.byType(SizedBox), + ), + findsNothing, + ); + expect( + find.descendant( + of: findsColoredBox(Colors.green), + matching: find.byType(SizedBox), + ), + findsOneWidget, + ); + expect( + tester.renderObject(find.byKey(globalKeyChild.key!)), + equals(boxWithGlobalKey), + ); + + leafRenderObject = collectLeafRenderObjects(); + expect(leafRenderObject[redView.viewId], isNot(isA())); + expect(leafRenderObject[greenView.viewId], isA()); + }); + + testWidgets('Can move stuff out of a view that is going away, viewA -> ViewB', (WidgetTester tester) async { + final FlutterView greenView = tester.view; + final Key greenKey = UniqueKey(); + final FlutterView redView = FakeView(tester.view); + final Key redKey = UniqueKey(); + final Widget globalKeyChild = SizedBox( + key: GlobalKey(), + ); + + await pumpWidgetWithoutViewWrapper( + tester: tester, + widget: ViewCollection( + views: [ + View( + key: greenKey, + view: greenView, + child: const ColoredBox( + color: Colors.green, + ), + ), + View( + key: redKey, + view: redView, + child: ColoredBox( + color: Colors.red, + child: globalKeyChild, + ), + ), + ], + ), + ); + expect( + find.descendant( + of: findsColoredBox(Colors.red), + matching: find.byType(SizedBox), + ), + findsOneWidget, + ); + expect( + find.descendant( + of: findsColoredBox(Colors.green), + matching: find.byType(SizedBox), + ), + findsNothing, + ); + final RenderObject boxWithGlobalKey = tester.renderObject(find.byKey(globalKeyChild.key!)); + + // Move the child and remove its view. + await pumpWidgetWithoutViewWrapper( + tester: tester, + widget: ViewCollection( + views: [ + View( + key: greenKey, + view: greenView, + child: ColoredBox( + color: Colors.green, + child: globalKeyChild, + ), + ), + ], + ), + ); + + expect( + findsColoredBox(Colors.red), + findsNothing, + ); + expect( + find.descendant( + of: findsColoredBox(Colors.green), + matching: find.byType(SizedBox), + ), + findsOneWidget, + ); + expect( + tester.renderObject(find.byKey(globalKeyChild.key!)), + equals(boxWithGlobalKey), + ); + }); + + testWidgets('Can move stuff out of a view that is going away, viewB -> ViewA', (WidgetTester tester) async { + final FlutterView greenView = tester.view; + final Key greenKey = UniqueKey(); + final FlutterView redView = FakeView(tester.view); + final Key redKey = UniqueKey(); + final Widget globalKeyChild = SizedBox( + key: GlobalKey(), + ); + + await pumpWidgetWithoutViewWrapper( + tester: tester, + widget: ViewCollection( + views: [ + View( + key: greenKey, + view: greenView, + child: ColoredBox( + color: Colors.green, + child: globalKeyChild, + ), + ), + View( + key: redKey, + view: redView, + child: const ColoredBox( + color: Colors.red, + ), + ), + ], + ), + ); + expect( + find.descendant( + of: findsColoredBox(Colors.green), + matching: find.byType(SizedBox), + ), + findsOneWidget, + ); + expect( + find.descendant( + of: findsColoredBox(Colors.red), + matching: find.byType(SizedBox), + ), + findsNothing, + ); + final RenderObject boxWithGlobalKey = tester.renderObject(find.byKey(globalKeyChild.key!)); + + // Move the child and remove its view. + await pumpWidgetWithoutViewWrapper( + tester: tester, + widget: ViewCollection( + views: [ + View( + key: redKey, + view: redView, + child: ColoredBox( + color: Colors.red, + child: globalKeyChild, + ), + ), + ], + ), + ); + + expect( + findsColoredBox(Colors.green), + findsNothing, + ); + expect( + find.descendant( + of: findsColoredBox(Colors.red), + matching: find.byType(SizedBox), + ), + findsOneWidget, + ); + expect( + tester.renderObject(find.byKey(globalKeyChild.key!)), + equals(boxWithGlobalKey), + ); + }); + + testWidgets('Can move stuff out of a view that is moving itself, stuff ends up before view', (WidgetTester tester) async { + final Key key1 = UniqueKey(); + final Key key2 = UniqueKey(); + final Key key3 = UniqueKey(); + final Key key4 = UniqueKey(); + + final GlobalKey viewKey = GlobalKey(); + final GlobalKey childKey = GlobalKey(); + + await tester.pumpWidget(Column( + children: [ + SizedBox(key: key1), + ViewAnchor( + key: key2, + view: View( + key: viewKey, + view: FakeView(tester.view), + child: SizedBox( + child: ColoredBox( + key: childKey, + color: Colors.green, + ), + ), + ), + child: const SizedBox(), + ), + ViewAnchor( + key: key3, + child: const SizedBox(), + ), + SizedBox(key: key4), + ], + )); + + await tester.pumpWidget(Column( + children: [ + SizedBox( + key: key1, + child: ColoredBox( + key: childKey, + color: Colors.green, + ), + ), + ViewAnchor( + key: key2, + child: const SizedBox(), + ), + ViewAnchor( + key: key3, + view: View( + key: viewKey, + view: FakeView(tester.view), + child: const SizedBox(), + ), + child: const SizedBox(), + ), + SizedBox(key: key4), + ], + )); + + await tester.pumpWidget(Column( + children: [ + SizedBox(key: key1), + ViewAnchor( + key: key2, + view: View( + key: viewKey, + view: FakeView(tester.view), + child: SizedBox( + child: ColoredBox( + key: childKey, + color: Colors.green, + ), + ), + ), + child: const SizedBox(), + ), + ViewAnchor( + key: key3, + child: const SizedBox(), + ), + SizedBox(key: key4), + ], + )); + }); + + testWidgets('Can move stuff out of a view that is moving itself, stuff ends up after view', (WidgetTester tester) async { + final Key key1 = UniqueKey(); + final Key key2 = UniqueKey(); + final Key key3 = UniqueKey(); + final Key key4 = UniqueKey(); + + final GlobalKey viewKey = GlobalKey(); + final GlobalKey childKey = GlobalKey(); + + await tester.pumpWidget(Column( + children: [ + SizedBox(key: key1), + ViewAnchor( + key: key2, + view: View( + key: viewKey, + view: FakeView(tester.view), + child: SizedBox( + child: ColoredBox( + key: childKey, + color: Colors.green, + ), + ), + ), + child: const SizedBox(), + ), + ViewAnchor( + key: key3, + child: const SizedBox(), + ), + SizedBox(key: key4), + ], + )); + + await tester.pumpWidget(Column( + children: [ + SizedBox( + key: key1, + ), + ViewAnchor( + key: key2, + child: const SizedBox(), + ), + ViewAnchor( + key: key3, + view: View( + key: viewKey, + view: FakeView(tester.view), + child: const SizedBox(), + ), + child: const SizedBox(), + ), + SizedBox( + key: key4, + child: ColoredBox( + key: childKey, + color: Colors.green, + ), + ), + ], + )); + + await tester.pumpWidget(Column( + children: [ + SizedBox(key: key1), + ViewAnchor( + key: key2, + view: View( + key: viewKey, + view: FakeView(tester.view), + child: SizedBox( + child: ColoredBox( + key: childKey, + color: Colors.green, + ), + ), + ), + child: const SizedBox(), + ), + ViewAnchor( + key: key3, + child: const SizedBox(), + ), + SizedBox(key: key4), + ], + )); + }); + + testWidgets('Can globalkey move down the tree from a view that is going away', (WidgetTester tester) async { + final FlutterView anchorView = FakeView(tester.view); + final Widget globalKeyChild = SizedBox( + key: GlobalKey(), + ); + + await tester.pumpWidget( + ColoredBox( + color: Colors.green, + child: ViewAnchor( + view: View( + view: anchorView, + child: ColoredBox( + color: Colors.yellow, + child: globalKeyChild, + ), + ), + child: const ColoredBox(color: Colors.red), + ), + ), + ); + + expect(findsColoredBox(Colors.green), findsOneWidget); + expect(findsColoredBox(Colors.yellow), findsOneWidget); + expect( + find.descendant( + of: findsColoredBox(Colors.yellow), + matching: find.byType(SizedBox), + ), + findsOneWidget, + ); + expect(findsColoredBox(Colors.red), findsOneWidget); + expect( + find.descendant( + of: findsColoredBox(Colors.red), + matching: find.byType(SizedBox), + ), + findsNothing, + ); + expect(find.byType(SizedBox), findsOneWidget); + final RenderObject boxWithGlobalKey = tester.renderObject(find.byKey(globalKeyChild.key!)); + + await tester.pumpWidget( + ColoredBox( + color: Colors.green, + child: ViewAnchor( + child: ColoredBox( + color: Colors.red, + child: globalKeyChild, + ), + ), + ), + ); + expect(findsColoredBox(Colors.green), findsOneWidget); + expect(findsColoredBox(Colors.yellow), findsNothing); + expect( + find.descendant( + of: findsColoredBox(Colors.yellow), + matching: find.byType(SizedBox), + ), + findsNothing, + ); + expect(findsColoredBox(Colors.red), findsOneWidget); + expect( + find.descendant( + of: findsColoredBox(Colors.red), + matching: find.byType(SizedBox), + ), + findsOneWidget, + ); + expect(find.byType(SizedBox), findsOneWidget); + expect( + tester.renderObject(find.byKey(globalKeyChild.key!)), + boxWithGlobalKey, + ); + }); + + testWidgets('RenderObjects are disposed when a view goes away from a ViewAnchor', (WidgetTester tester) async { + final FlutterView anchorView = FakeView(tester.view); + + await tester.pumpWidget( + ColoredBox( + color: Colors.green, + child: ViewAnchor( + view: View( + view: anchorView, + child: const ColoredBox(color: Colors.yellow), + ), + child: const ColoredBox(color: Colors.red), + ), + ), + ); + + final RenderObject box = tester.renderObject(findsColoredBox(Colors.yellow)); + + await tester.pumpWidget( + const ColoredBox( + color: Colors.green, + child: ViewAnchor( + child: ColoredBox(color: Colors.red), + ), + ), + ); + + expect(box.debugDisposed, isTrue); + }); + + testWidgets('RenderObjects are disposed when a view goes away from a ViewCollection', (WidgetTester tester) async { + final FlutterView redView = tester.view; + final FlutterView greenView = FakeView(tester.view); + + await pumpWidgetWithoutViewWrapper( + tester: tester, + widget: ViewCollection( + views: [ + View( + view: redView, + child: const ColoredBox(color: Colors.red), + ), + View( + view: greenView, + child: const ColoredBox(color: Colors.green), + ), + ], + ), + ); + + expect(findsColoredBox(Colors.green), findsOneWidget); + expect(findsColoredBox(Colors.red), findsOneWidget); + final RenderObject box = tester.renderObject(findsColoredBox(Colors.green)); + + await pumpWidgetWithoutViewWrapper( + tester: tester, + widget: ViewCollection( + views: [ + View( + view: redView, + child: const ColoredBox(color: Colors.red), + ), + ], + ), + ); + + expect(findsColoredBox(Colors.green), findsNothing); + expect(findsColoredBox(Colors.red), findsOneWidget); + expect(box.debugDisposed, isTrue); + }); + + testWidgets('View can be wrapped and unwrapped', (WidgetTester tester) async { + final Widget view = View( + view: tester.view, + child: const SizedBox(), + ); + + await pumpWidgetWithoutViewWrapper( + tester: tester, + widget: view, + ); + + final RenderObject renderView = tester.renderObject(find.byType(View)); + final RenderObject renderSizedBox = tester.renderObject(find.byType(SizedBox)); + + await pumpWidgetWithoutViewWrapper( + tester: tester, + widget: ViewCollection( + views: [view], + ), + ); + + expect(tester.renderObject(find.byType(View)), same(renderView)); + expect(tester.renderObject(find.byType(SizedBox)), same(renderSizedBox)); + + await pumpWidgetWithoutViewWrapper( + tester: tester, + widget: view, + ); + + expect(tester.renderObject(find.byType(View)), same(renderView)); + expect(tester.renderObject(find.byType(SizedBox)), same(renderSizedBox)); + }); + + testWidgets('ViewAnchor with View can be wrapped and unwrapped', (WidgetTester tester) async { + final Widget viewAnchor = ViewAnchor( + view: View( + view: FakeView(tester.view), + child: const SizedBox(), + ), + child: const ColoredBox(color: Colors.green), + ); + + await tester.pumpWidget(viewAnchor); + + final List renderViews = tester.renderObjectList(find.byType(View)).toList(); + final RenderObject renderSizedBox = tester.renderObject(find.byType(SizedBox)); + + await tester.pumpWidget(ColoredBox(color: Colors.yellow, child: viewAnchor)); + + expect(tester.renderObjectList(find.byType(View)), renderViews); + expect(tester.renderObject(find.byType(SizedBox)), same(renderSizedBox)); + + await tester.pumpWidget(viewAnchor); + + expect(tester.renderObjectList(find.byType(View)), renderViews); + expect(tester.renderObject(find.byType(SizedBox)), same(renderSizedBox)); + }); + + testWidgets('Moving a View keeps its semantics tree stable', (WidgetTester tester) async { + final Widget view = View( + // No explicit key, we rely on the implicit key of the underlying RawView. + view: tester.view, + child: Semantics( + textDirection: TextDirection.ltr, + label: 'Hello', + child: const SizedBox(), + ) + ); + await pumpWidgetWithoutViewWrapper( + tester: tester, + widget: view, + ); + + final RenderObject renderSemantics = tester.renderObject(find.bySemanticsLabel('Hello')); + final SemanticsNode semantics = tester.getSemantics(find.bySemanticsLabel('Hello')); + expect(semantics.id, 1); + expect(renderSemantics.debugSemantics, same(semantics)); + + await pumpWidgetWithoutViewWrapper( + tester: tester, + widget: ViewCollection( + views: [ + view, + ], + ), + ); + + final RenderObject renderSemanticsAfterMove = tester.renderObject(find.bySemanticsLabel('Hello')); + final SemanticsNode semanticsAfterMove = tester.getSemantics(find.bySemanticsLabel('Hello')); + expect(renderSemanticsAfterMove, same(renderSemantics)); + expect(semanticsAfterMove.id, 1); + expect(semanticsAfterMove, same(semantics)); + }); +} + +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(); +} + +class FakeView extends TestFlutterView{ + FakeView(FlutterView view, { this.viewId = 100 }) : super( + view: view, + platformDispatcher: view.platformDispatcher as TestPlatformDispatcher, + display: view.display as TestDisplay, + ); + + @override + final int viewId; +} diff --git a/packages/flutter/test/widgets/view_test.dart b/packages/flutter/test/widgets/view_test.dart index 6da63c137dc..b0a87c09ffb 100644 --- a/packages/flutter/test/widgets/view_test.dart +++ b/packages/flutter/test/widgets/view_test.dart @@ -4,7 +4,8 @@ import 'dart:ui'; -import 'package:flutter/widgets.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { @@ -67,4 +68,436 @@ void main() { )), ); }); + + testWidgets('child of view finds view, parentPipelineOwner, mediaQuery', (WidgetTester tester) async { + FlutterView? outsideView; + FlutterView? insideView; + PipelineOwner? outsideParent; + PipelineOwner? insideParent; + + await pumpWidgetWithoutViewWrapper( + tester: tester, + widget: Builder( + builder: (BuildContext context) { + outsideView = View.maybeOf(context); + outsideParent = View.pipelineOwnerOf(context); + return View( + view: tester.view, + child: Builder( + builder: (BuildContext context) { + insideView = View.maybeOf(context); + insideParent = View.pipelineOwnerOf(context); + return const SizedBox(); + }, + ), + ); + }, + ), + ); + expect(outsideView, isNull); + expect(insideView, equals(tester.view)); + + expect(outsideParent, isNotNull); + expect(insideParent, isNotNull); + expect(outsideParent, isNot(equals(insideParent))); + + expect(outsideParent, tester.binding.rootPipelineOwner); + expect(insideParent, equals(tester.renderObject(find.byType(SizedBox)).owner)); + + final List pipelineOwners = []; + tester.binding.rootPipelineOwner.visitChildren((PipelineOwner child) { + pipelineOwners.add(child); + }); + expect(pipelineOwners.single, equals(insideParent)); + }); + + testWidgets('cannot have multiple views with same FlutterView', (WidgetTester tester) async { + await pumpWidgetWithoutViewWrapper( + tester: tester, + widget: ViewCollection( + views: [ + View( + view: tester.view, + child: const SizedBox(), + ), + View( + view: tester.view, + child: const SizedBox(), + ), + ], + ), + ); + + expect( + tester.takeException(), + isFlutterError.having( + (FlutterError e) => e.message, + 'message', + contains('Multiple widgets used the same GlobalKey'), + ), + ); + }); + + testWidgets('ViewCollection must have one view', (WidgetTester tester) async { + expect(() => ViewCollection(views: const []), throwsAssertionError); + }); + + testWidgets('ViewAnchor.child does not see surrounding view', (WidgetTester tester) async { + FlutterView? inside; + FlutterView? outside; + await tester.pumpWidget( + Builder( + builder: (BuildContext context) { + outside = View.maybeOf(context); + return ViewAnchor( + view: Builder( + builder: (BuildContext context) { + inside = View.maybeOf(context); + return View(view: FakeView(tester.view), child: const SizedBox()); + }, + ), + child: const SizedBox(), + ); + }, + ), + ); + expect(inside, isNull); + expect(outside, isNotNull); + }); + + testWidgets('ViewAnchor layout order', (WidgetTester tester) async { + Finder findSpyWidget(int label) { + return find.byWidgetPredicate((Widget w) => w is SpyRenderWidget && w.label == label); + } + + final List log = []; + await tester.pumpWidget( + SpyRenderWidget( + label: 1, + log: log, + child: ViewAnchor( + view: View( + view: FakeView(tester.view), + child: SpyRenderWidget(label: 2, log: log), + ), + child: SpyRenderWidget(label: 3, log: log), + ), + ), + ); + log.clear(); + tester.renderObject(findSpyWidget(3)).markNeedsLayout(); + tester.renderObject(findSpyWidget(2)).markNeedsLayout(); + tester.renderObject(findSpyWidget(1)).markNeedsLayout(); + await tester.pump(); + expect(log, ['layout 1', 'layout 3', 'layout 2']); + }); + + testWidgets('visitChildren of ViewAnchor visits both children', (WidgetTester tester) async { + await tester.pumpWidget( + ViewAnchor( + view: View( + view: FakeView(tester.view), + child: const ColoredBox(color: Colors.green), + ), + child: const SizedBox(), + ), + ); + final Element viewAnchorElement = tester.element(find.byElementPredicate((Element e) => e.runtimeType.toString() == '_MultiChildComponentElement')); + final List children = []; + viewAnchorElement.visitChildren((Element element) { + children.add(element); + }); + expect(children, hasLength(2)); + + await tester.pumpWidget( + const ViewAnchor( + child: SizedBox(), + ), + ); + children.clear(); + viewAnchorElement.visitChildren((Element element) { + children.add(element); + }); + expect(children, hasLength(1)); + }); + + testWidgets('visitChildren of ViewCollection visits all children', (WidgetTester tester) async { + await pumpWidgetWithoutViewWrapper( + tester: tester, + widget: ViewCollection( + views: [ + View( + view: tester.view, + child: const SizedBox(), + ), + View( + view: FakeView(tester.view), + child: const SizedBox(), + ), + View( + view: FakeView(tester.view, viewId: 423), + child: const SizedBox(), + ), + ], + ), + ); + final Element viewAnchorElement = tester.element(find.byElementPredicate((Element e) => e.runtimeType.toString() == '_MultiChildComponentElement')); + final List children = []; + viewAnchorElement.visitChildren((Element element) { + children.add(element); + }); + expect(children, hasLength(3)); + + await pumpWidgetWithoutViewWrapper( + tester: tester, + widget: ViewCollection( + views: [ + View( + view: tester.view, + child: const SizedBox(), + ), + ], + ), + ); + children.clear(); + viewAnchorElement.visitChildren((Element element) { + children.add(element); + }); + expect(children, hasLength(1)); + }); + + group('renderObject getter', () { + testWidgets('ancestors of view see RenderView as renderObject', (WidgetTester tester) async { + late BuildContext builderContext; + await pumpWidgetWithoutViewWrapper( + tester: tester, + widget: Builder( + builder: (BuildContext context) { + builderContext = context; + return View( + view: tester.view, + child: const SizedBox(), + ); + }, + ), + ); + + final RenderObject? renderObject = builderContext.findRenderObject(); + expect(renderObject, isNotNull); + expect(renderObject, isA()); + expect(renderObject, tester.renderObject(find.byType(View))); + expect(tester.element(find.byType(Builder)).renderObject, renderObject); + }); + + testWidgets('ancestors of ViewCollection get null for renderObject', (WidgetTester tester) async { + late BuildContext builderContext; + await pumpWidgetWithoutViewWrapper( + tester: tester, + widget: Builder( + builder: (BuildContext context) { + builderContext = context; + return ViewCollection( + views: [ + View( + view: tester.view, + child: const SizedBox(), + ), + View( + view: FakeView(tester.view), + child: const SizedBox(), + ), + ], + ); + }, + ), + ); + + final RenderObject? renderObject = builderContext.findRenderObject(); + expect(renderObject, isNull); + expect(tester.element(find.byType(Builder)).renderObject, isNull); + }); + + testWidgets('ancestors of a ViewAnchor see the right RenderObject', (WidgetTester tester) async { + late BuildContext builderContext; + await tester.pumpWidget( + Builder( + builder: (BuildContext context) { + builderContext = context; + return ViewAnchor( + view: View( + view: FakeView(tester.view), + child: const ColoredBox(color: Colors.green), + ), + child: const SizedBox(), + ); + }, + ), + ); + + final RenderObject? renderObject = builderContext.findRenderObject(); + expect(renderObject, isNotNull); + expect(renderObject, isA()); + expect(renderObject, tester.renderObject(find.byType(SizedBox))); + expect(tester.element(find.byType(Builder)).renderObject, renderObject); + }); + }); + + testWidgets('correctly switches between view configurations', (WidgetTester tester) async { + await pumpWidgetWithoutViewWrapper( + tester: tester, + widget: View( + view: tester.view, + deprecatedDoNotUseWillBeRemovedWithoutNoticePipelineOwner: tester.binding.pipelineOwner, + deprecatedDoNotUseWillBeRemovedWithoutNoticeRenderView: tester.binding.renderView, + child: const SizedBox(), + ), + ); + RenderObject renderView = tester.renderObject(find.byType(View)); + expect(renderView, same(tester.binding.renderView)); + expect(renderView.owner, same(tester.binding.pipelineOwner)); + expect(tester.renderObject(find.byType(SizedBox)).owner, same(tester.binding.pipelineOwner)); + + await pumpWidgetWithoutViewWrapper( + tester: tester, + widget: View( + view: tester.view, + child: const SizedBox(), + ), + ); + renderView = tester.renderObject(find.byType(View)); + expect(renderView, isNot(same(tester.binding.renderView))); + 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( + view: tester.view, + deprecatedDoNotUseWillBeRemovedWithoutNoticePipelineOwner: tester.binding.pipelineOwner, + deprecatedDoNotUseWillBeRemovedWithoutNoticeRenderView: tester.binding.renderView, + child: const SizedBox(), + ), + ); + renderView = tester.renderObject(find.byType(View)); + expect(renderView, same(tester.binding.renderView)); + expect(renderView.owner, same(tester.binding.pipelineOwner)); + expect(tester.renderObject(find.byType(SizedBox)).owner, same(tester.binding.pipelineOwner)); + + expect(() => View( + view: tester.view, + deprecatedDoNotUseWillBeRemovedWithoutNoticePipelineOwner: tester.binding.pipelineOwner, + child: const SizedBox(), + ), throwsAssertionError); + expect(() => View( + view: tester.view, + deprecatedDoNotUseWillBeRemovedWithoutNoticeRenderView: tester.binding.renderView, + child: const SizedBox(), + ), throwsAssertionError); + expect(() => View( + view: FakeView(tester.view), + deprecatedDoNotUseWillBeRemovedWithoutNoticeRenderView: tester.binding.renderView, + deprecatedDoNotUseWillBeRemovedWithoutNoticePipelineOwner: tester.binding.pipelineOwner, + child: const SizedBox(), + ), throwsAssertionError); + }); + + testWidgets('attaches itself correctly', (WidgetTester tester) async { + final Key viewKey = UniqueKey(); + late final PipelineOwner parentPipelineOwner; + await tester.pumpWidget( + ViewAnchor( + view: Builder( + builder: (BuildContext context) { + parentPipelineOwner = View.pipelineOwnerOf(context); + return View( + key: viewKey, + view: FakeView(tester.view), + child: const SizedBox(), + ); + }, + ), + child: const ColoredBox(color: Colors.green), + ), + ); + + expect(parentPipelineOwner, isNot(RendererBinding.instance.rootPipelineOwner)); + + final RenderView rawView = tester.renderObject(find.byKey(viewKey)); + expect(RendererBinding.instance.renderViews, contains(rawView)); + + final List children = []; + parentPipelineOwner.visitChildren((PipelineOwner child) { + children.add(child); + }); + final PipelineOwner rawViewOwner = rawView.owner!; + expect(children, contains(rawViewOwner)); + + // Remove that View from the tree. + await tester.pumpWidget( + const ViewAnchor( + child: ColoredBox(color: Colors.green), + ), + ); + + expect(rawView.owner, isNull); + expect(RendererBinding.instance.renderViews, isNot(contains(rawView))); + children.clear(); + parentPipelineOwner.visitChildren((PipelineOwner child) { + children.add(child); + }); + expect(children, isNot(contains(rawViewOwner))); + }); +} + +Future pumpWidgetWithoutViewWrapper({required WidgetTester tester, required Widget widget}) { + tester.binding.attachRootWidget(widget); + tester.binding.scheduleFrame(); + return tester.binding.pump(); +} + +class FakeView extends TestFlutterView{ + FakeView(FlutterView view, { this.viewId = 100 }) : super( + view: view, + platformDispatcher: view.platformDispatcher as TestPlatformDispatcher, + display: view.display as TestDisplay, + ); + + @override + final int viewId; +} + +class SpyRenderWidget extends SizedBox { + const SpyRenderWidget({super.key, required this.label, required this.log, super.child}); + + final int label; + final List log; + + @override + RenderSpy createRenderObject(BuildContext context) { + return RenderSpy( + additionalConstraints: const BoxConstraints(), + label: label, + log: log, + ); + } + + @override + void updateRenderObject(BuildContext context, RenderSpy renderObject) { + renderObject + ..label = label + ..log = log; + } +} + +class RenderSpy extends RenderConstrainedBox { + RenderSpy({required super.additionalConstraints, required this.label, required this.log}); + + int label; + List log; + + @override + void performLayout() { + log.add('layout $label'); + super.performLayout(); + } } diff --git a/packages/flutter/test/widgets/widget_inspector_test.dart b/packages/flutter/test/widgets/widget_inspector_test.dart index a75293e8325..9e00c5352ba 100644 --- a/packages/flutter/test/widgets/widget_inspector_test.dart +++ b/packages/flutter/test/widgets/widget_inspector_test.dart @@ -4509,7 +4509,6 @@ class _TestWidgetInspectorService extends TestWidgetInspectorService { expect(result['parentData'], isNull); }); - testWidgets('ext.flutter.inspector.getLayoutExplorerNode for RenderView',(WidgetTester tester) async { await pumpWidgetForLayoutExplorer(tester); @@ -4530,7 +4529,7 @@ class _TestWidgetInspectorService extends TestWidgetInspectorService { final Map? renderObject = result['renderObject'] as Map?; expect(renderObject, isNotNull); - expect(renderObject!['description'], startsWith('RenderView')); + expect(renderObject!['description'], contains('RenderView')); expect(result['parentRenderElement'], isNull); expect(result['constraints'], isNull); diff --git a/packages/flutter/test_release/widgets/memory_allocations_test.dart b/packages/flutter/test_release/widgets/memory_allocations_test.dart index 166725ab6b1..4057379308d 100644 --- a/packages/flutter/test_release/widgets/memory_allocations_test.dart +++ b/packages/flutter/test_release/widgets/memory_allocations_test.dart @@ -55,7 +55,7 @@ class _TestRenderObject extends RenderObject { Rect get semanticBounds => throw UnimplementedError(); } -class _TestElement extends RenderObjectElement with RootElementMixin { +class _TestElement extends RenderTreeRootElement with RootElementMixin { _TestElement(): super(_TestLeafRenderObjectWidget()); void makeInactive() { diff --git a/packages/flutter_driver/lib/src/common/handler_factory.dart b/packages/flutter_driver/lib/src/common/handler_factory.dart index 05e70aee8d2..9960e8396db 100644 --- a/packages/flutter_driver/lib/src/common/handler_factory.dart +++ b/packages/flutter_driver/lib/src/common/handler_factory.dart @@ -187,11 +187,20 @@ mixin CommandHandlerFactory { Future _getHealth(Command command) async => const Health(HealthStatus.ok); Future _getLayerTree(Command command) async { - return LayerTree(RendererBinding.instance.renderView.debugLayer?.toStringDeep()); + final String trees = [ + for (final RenderView renderView in RendererBinding.instance.renderViews) + if (renderView.debugLayer != null) + renderView.debugLayer!.toStringDeep(), + ].join('\n\n'); + return LayerTree(trees.isNotEmpty ? trees : null); } Future _getRenderTree(Command command) async { - return RenderTree(RendererBinding.instance.renderView.toStringDeep()); + final String trees = [ + for (final RenderView renderView in RendererBinding.instance.renderViews) + renderView.toStringDeep(), + ].join('\n\n'); + return RenderTree(trees.isNotEmpty ? trees : null); } Future _enterText(Command command) async { diff --git a/packages/flutter_test/lib/src/_matchers_web.dart b/packages/flutter_test/lib/src/_matchers_web.dart index 749b57164ed..7f54720eb23 100644 --- a/packages/flutter_test/lib/src/_matchers_web.dart +++ b/packages/flutter_test/lib/src/_matchers_web.dart @@ -58,8 +58,8 @@ class MatchesGoldenFile extends AsyncMatcher { final RenderObject renderObject = _findRepaintBoundary(element); final Size size = renderObject.paintBounds.size; final TestWidgetsFlutterBinding binding = TestWidgetsFlutterBinding.instance; - final Element e = binding.rootElement!; final ui.FlutterView view = binding.platformDispatcher.implicitView!; + final RenderView renderView = binding.renderViews.firstWhere((RenderView r) => r.flutterView == view); // Unlike `flutter_tester`, we don't have the ability to render an element // to an image directly. Instead, we will use `window.render()` to render @@ -78,7 +78,7 @@ class MatchesGoldenFile extends AsyncMatcher { return ex.message; } }); - _renderElement(view, _findRepaintBoundary(e)); + _renderElement(view, renderView); return result; } diff --git a/packages/flutter_test/lib/src/accessibility.dart b/packages/flutter_test/lib/src/accessibility.dart index a696837d3a6..7faeb4a3862 100644 --- a/packages/flutter_test/lib/src/accessibility.dart +++ b/packages/flutter_test/lib/src/accessibility.dart @@ -131,11 +131,10 @@ class MinimumTapTargetGuideline extends AccessibilityGuideline { @override FutureOr evaluate(WidgetTester tester) { Evaluation result = const Evaluation.pass(); - for (final FlutterView view in tester.platformDispatcher.views) { + for (final RenderView view in tester.binding.renderViews) { result += _traverse( - view, - // TODO(pdblasi-google): Get the specific semantics root for this view when available - tester.binding.pipelineOwner.semanticsOwner!.rootSemanticsNode!, + view.flutterView, + view.owner!.semanticsOwner!.rootSemanticsNode!, ); } @@ -239,10 +238,8 @@ class LabeledTapTargetGuideline extends AccessibilityGuideline { FutureOr evaluate(WidgetTester tester) { Evaluation result = const Evaluation.pass(); - // TODO(pdblasi-google): Use view to retrieve the appropriate root semantics node when available. - // ignore: unused_local_variable - for (final FlutterView view in tester.platformDispatcher.views) { - result += _traverse(tester.binding.pipelineOwner.semanticsOwner!.rootSemanticsNode!); + for (final RenderView view in tester.binding.renderViews) { + result += _traverse(view.owner!.semanticsOwner!.rootSemanticsNode!); } return result; @@ -318,9 +315,7 @@ class MinimumTextContrastGuideline extends AccessibilityGuideline { @override Future evaluate(WidgetTester tester) async { Evaluation result = const Evaluation.pass(); - for (final FlutterView view in tester.platformDispatcher.views) { - // TODO(pdblasi): This renderView will need to be retrieved from view when available. - final RenderView renderView = tester.binding.renderView; + for (final RenderView renderView in tester.binding.renderViews) { final OffsetLayer layer = renderView.debugLayer! as OffsetLayer; final SemanticsNode root = renderView.owner!.semanticsOwner!.rootSemanticsNode!; @@ -329,13 +324,13 @@ class MinimumTextContrastGuideline extends AccessibilityGuideline { () async { // Needs to be the same pixel ratio otherwise our dimensions won't match // the last transform layer. - final double ratio = 1 / view.devicePixelRatio; + final double ratio = 1 / renderView.flutterView.devicePixelRatio; image = await layer.toImage(renderView.paintBounds, pixelRatio: ratio); return image.toByteData(); }, ); - result += await _evaluateNode(root, tester, image, byteData!, view); + result += await _evaluateNode(root, tester, image, byteData!, renderView); } return result; @@ -346,7 +341,7 @@ class MinimumTextContrastGuideline extends AccessibilityGuideline { WidgetTester tester, ui.Image image, ByteData byteData, - FlutterView view, + RenderView renderView, ) async { Evaluation result = const Evaluation.pass(); @@ -368,7 +363,7 @@ class MinimumTextContrastGuideline extends AccessibilityGuideline { return true; }); for (final SemanticsNode child in children) { - result += await _evaluateNode(child, tester, image, byteData, view); + result += await _evaluateNode(child, tester, image, byteData, renderView); } if (shouldSkipNode(data)) { return result; @@ -376,7 +371,7 @@ class MinimumTextContrastGuideline extends AccessibilityGuideline { final String text = data.label.isEmpty ? data.value : data.label; final Iterable elements = find.text(text).hitTestable().evaluate(); for (final Element element in elements) { - result += await _evaluateElement(node, element, tester, image, byteData, view); + result += await _evaluateElement(node, element, tester, image, byteData, renderView); } return result; } @@ -387,7 +382,7 @@ class MinimumTextContrastGuideline extends AccessibilityGuideline { WidgetTester tester, ui.Image image, ByteData byteData, - FlutterView view, + RenderView renderView, ) async { // Look up inherited text properties to determine text size and weight. late bool isBold; @@ -408,7 +403,7 @@ class MinimumTextContrastGuideline extends AccessibilityGuideline { // not included in renderBox.getTransformTo(null). Manually multiply the // root transform to the global transform. final Matrix4 rootTransform = Matrix4.identity(); - tester.binding.renderView.applyPaintTransform(tester.binding.renderView.child!, rootTransform); + renderView.applyPaintTransform(renderView.child!, rootTransform); rootTransform.multiply(globalTransform); screenBounds = MatrixUtils.transformRect(rootTransform, renderBox.paintBounds); Rect nodeBounds = node.rect; @@ -443,7 +438,7 @@ class MinimumTextContrastGuideline extends AccessibilityGuideline { throw StateError('Unexpected widget type: ${widget.runtimeType}'); } - if (isNodeOffScreen(paintBoundsWithOffset, view)) { + if (isNodeOffScreen(paintBoundsWithOffset, renderView.flutterView)) { return const Evaluation.pass(); } @@ -562,9 +557,7 @@ class CustomMinimumContrastGuideline extends AccessibilityGuideline { Evaluation result = const Evaluation.pass(); for (final Element element in elements) { final FlutterView view = tester.viewOf(find.byElementPredicate((Element e) => e == element)); - - // TODO(pdblasi): Obtain this renderView from view when possible. - final RenderView renderView = tester.binding.renderView; + final RenderView renderView = tester.binding.renderViews.firstWhere((RenderView r) => r.flutterView == view); final OffsetLayer layer = renderView.debugLayer! as OffsetLayer; late final ui.Image image; diff --git a/packages/flutter_test/lib/src/binding.dart b/packages/flutter_test/lib/src/binding.dart index 23caf1f33b3..c3c289b016e 100644 --- a/packages/flutter_test/lib/src/binding.dart +++ b/packages/flutter_test/lib/src/binding.dart @@ -495,8 +495,8 @@ abstract class TestWidgetsFlutterBinding extends BindingBase Size? _surfaceSize; - /// Artificially changes the surface size to `size` on the Widget binding, - /// then flushes microtasks. + /// Artificially changes the logical size of [WidgetTester.view] to the + /// specified size, then flushes microtasks. /// /// Set to null to use the default surface size. /// @@ -508,7 +508,10 @@ abstract class TestWidgetsFlutterBinding extends BindingBase /// addTearDown(() => binding.setSurfaceSize(null)); /// ``` /// - /// See also [TestFlutterView.physicalSize], which has a similar effect. + /// This method only affects the size of the [WidgetTester.view]. It does not + /// affect the size of any other views. Instead of this method, consider + /// setting [TestFlutterView.physicalSize], which works for any view, + /// including [WidgetTester.view]. // TODO(pdblasi-google): Deprecate this. https://github.com/flutter/flutter/issues/123881 Future setSurfaceSize(Size? size) { return TestAsyncUtils.guard(() async { @@ -522,14 +525,37 @@ abstract class TestWidgetsFlutterBinding extends BindingBase } @override - ViewConfiguration createViewConfiguration() { - final FlutterView view = platformDispatcher.implicitView!; - final double devicePixelRatio = view.devicePixelRatio; - final Size size = _surfaceSize ?? view.physicalSize / devicePixelRatio; - return ViewConfiguration( - size: size, - devicePixelRatio: devicePixelRatio, - ); + void addRenderView(RenderView view) { + _insideAddRenderView = true; + try { + super.addRenderView(view); + } finally { + _insideAddRenderView = false; + } + } + + bool _insideAddRenderView = false; + + @override + ViewConfiguration createViewConfigurationFor(RenderView renderView) { + if (_insideAddRenderView + && renderView.hasConfiguration + && renderView.configuration is TestViewConfiguration + && renderView == this.renderView) { // ignore: deprecated_member_use + // If a test has reached out to the now deprecated renderView property to set a custom TestViewConfiguration + // we are not replacing it. This is to maintain backwards compatibility with how things worked prior to the + // deprecation of that property. + // TODO(goderbauer): Remove this "if" when the deprecated renderView property is removed. + return renderView.configuration; + } + final FlutterView view = renderView.flutterView; + if (_surfaceSize != null && view == platformDispatcher.implicitView) { + return ViewConfiguration( + size: _surfaceSize!, + devicePixelRatio: view.devicePixelRatio, + ); + } + return super.createViewConfigurationFor(renderView); } /// Acts as if the application went idle. @@ -1377,16 +1403,18 @@ class AutomatedTestWidgetsFlutterBinding extends TestWidgetsFlutterBinding { debugBuildingDirtyElements = true; buildOwner!.buildScope(rootElement!); if (_phase != EnginePhase.build) { - pipelineOwner.flushLayout(); + rootPipelineOwner.flushLayout(); if (_phase != EnginePhase.layout) { - pipelineOwner.flushCompositingBits(); + rootPipelineOwner.flushCompositingBits(); if (_phase != EnginePhase.compositingBits) { - pipelineOwner.flushPaint(); + rootPipelineOwner.flushPaint(); if (_phase != EnginePhase.paint && sendFramesToEngine) { _firstFrameSent = true; - renderView.compositeFrame(); // this sends the bits to the GPU + for (final RenderView renderView in renderViews) { + renderView.compositeFrame(); // this sends the bits to the GPU + } if (_phase != EnginePhase.composite) { - pipelineOwner.flushSemantics(); + rootPipelineOwner.flushSemantics(); // this sends the semantics to the OS. assert(_phase == EnginePhase.flushSemantics || _phase == EnginePhase.sendSemanticsUpdate); } @@ -1759,9 +1787,14 @@ class LiveTestWidgetsFlutterBinding extends TestWidgetsFlutterBinding { } } - void _markViewNeedsPaint() { + void _markViewsNeedPaint([int? viewId]) { _viewNeedsPaint = true; - renderView.markNeedsPaint(); + final Iterable toMark = viewId == null + ? renderViews + : renderViews.where((RenderView renderView) => renderView.flutterView.viewId == viewId); + for (final RenderView renderView in toMark) { + renderView.markNeedsPaint(); + } } TextPainter? _label; @@ -1779,15 +1812,16 @@ class LiveTestWidgetsFlutterBinding extends TestWidgetsFlutterBinding { _label ??= TextPainter(textAlign: TextAlign.left, textDirection: TextDirection.ltr); _label!.text = TextSpan(text: value, style: _labelStyle); _label!.layout(); - _markViewNeedsPaint(); + _markViewsNeedPaint(); } - final Map _pointerIdToPointerRecord = {}; + final Expando> _renderViewToPointerIdToPointerRecord = Expando>(); void _handleRenderViewPaint(PaintingContext context, Offset offset, RenderView renderView) { assert(offset == Offset.zero); - if (_pointerIdToPointerRecord.isNotEmpty) { + final Map? pointerIdToRecord = _renderViewToPointerIdToPointerRecord[renderView]; + if (pointerIdToRecord != null && pointerIdToRecord.isNotEmpty) { final double radius = renderView.configuration.size.shortestSide * 0.05; final Path path = Path() ..addOval(Rect.fromCircle(center: Offset.zero, radius: radius)) @@ -1800,7 +1834,7 @@ class LiveTestWidgetsFlutterBinding extends TestWidgetsFlutterBinding { ..strokeWidth = radius / 10.0 ..style = PaintingStyle.stroke; bool dirty = false; - for (final _LiveTestPointerRecord record in _pointerIdToPointerRecord.values) { + for (final _LiveTestPointerRecord record in pointerIdToRecord.values) { paint.color = record.color.withOpacity(record.decay < 0 ? (record.decay / (_kPointerDecay - 1)) : 1.0); canvas.drawPath(path.shift(record.position), paint); if (record.decay < 0) { @@ -1808,14 +1842,14 @@ class LiveTestWidgetsFlutterBinding extends TestWidgetsFlutterBinding { } record.decay += 1; } - _pointerIdToPointerRecord + pointerIdToRecord .keys - .where((int pointer) => _pointerIdToPointerRecord[pointer]!.decay == 0) + .where((int pointer) => pointerIdToRecord[pointer]!.decay == 0) .toList() - .forEach(_pointerIdToPointerRecord.remove); + .forEach(pointerIdToRecord.remove); if (dirty) { scheduleMicrotask(() { - _markViewNeedsPaint(); + _markViewsNeedPaint(renderView.flutterView.viewId); }); } } @@ -1846,19 +1880,29 @@ class LiveTestWidgetsFlutterBinding extends TestWidgetsFlutterBinding { void handlePointerEvent(PointerEvent event) { switch (pointerEventSource) { case TestBindingEventSource.test: - final _LiveTestPointerRecord? record = _pointerIdToPointerRecord[event.pointer]; - if (record != null) { - record.position = event.position; - if (!event.down) { - record.decay = _kPointerDecay; + RenderView? target; + for (final RenderView renderView in renderViews) { + if (renderView.flutterView.viewId == event.viewId) { + target = renderView; + break; + } + } + if (target != null) { + final _LiveTestPointerRecord? record = _renderViewToPointerIdToPointerRecord[target]?[event.pointer]; + if (record != null) { + record.position = event.position; + if (!event.down) { + record.decay = _kPointerDecay; + } + _markViewsNeedPaint(event.viewId); + } else if (event.down) { + _renderViewToPointerIdToPointerRecord[target] ??= {}; + _renderViewToPointerIdToPointerRecord[target]![event.pointer] = _LiveTestPointerRecord( + event.pointer, + event.position, + ); + _markViewsNeedPaint(event.viewId); } - _markViewNeedsPaint(); - } else if (event.down) { - _pointerIdToPointerRecord[event.pointer] = _LiveTestPointerRecord( - event.pointer, - event.position, - ); - _markViewNeedsPaint(); } super.handlePointerEvent(event); case TestBindingEventSource.device: @@ -1870,6 +1914,7 @@ class LiveTestWidgetsFlutterBinding extends TestWidgetsFlutterBinding { // The pointer events received with this source has a global position // (see [handlePointerEventForSource]). Transform it to the local // coordinate space used by the testing widgets. + final RenderView renderView = renderViews.firstWhere((RenderView r) => r.flutterView.viewId == event.viewId); final PointerEvent localEvent = event.copyWith(position: globalToLocal(event.position, renderView)); withPointerEventSource(TestBindingEventSource.device, () => super.handlePointerEvent(localEvent) @@ -1987,10 +2032,18 @@ class LiveTestWidgetsFlutterBinding extends TestWidgetsFlutterBinding { } @override - ViewConfiguration createViewConfiguration() { + ViewConfiguration createViewConfigurationFor(RenderView renderView) { + final FlutterView view = renderView.flutterView; + if (view == platformDispatcher.implicitView) { + return TestViewConfiguration.fromView( + size: _surfaceSize ?? _kDefaultTestViewportSize, + view: view, + ); + } + final double devicePixelRatio = view.devicePixelRatio; return TestViewConfiguration.fromView( - size: _surfaceSize ?? _kDefaultTestViewportSize, - view: platformDispatcher.implicitView!, + size: view.physicalSize / devicePixelRatio, + view: view, ); } diff --git a/packages/flutter_test/lib/src/controller.dart b/packages/flutter_test/lib/src/controller.dart index 4033ba0ad87..9443e36ec97 100644 --- a/packages/flutter_test/lib/src/controller.dart +++ b/packages/flutter_test/lib/src/controller.dart @@ -36,7 +36,7 @@ class SemanticsController { /// Creates a [SemanticsController] that uses the given binding. Will be /// automatically created as part of instantiating a [WidgetController], but /// a custom implementation can be passed via the [WidgetController] constructor. - SemanticsController._(WidgetsBinding binding) : _binding = binding; + SemanticsController._(this._controller); static final int _scrollingActions = SemanticsAction.scrollUp.index | @@ -55,7 +55,7 @@ class SemanticsController { SemanticsFlag.isSlider.index | SemanticsFlag.isInMutuallyExclusiveGroup.index; - final WidgetsBinding _binding; + final WidgetController _controller; /// Attempts to find the [SemanticsNode] of first result from `finder`. /// @@ -73,7 +73,7 @@ class SemanticsController { /// if no semantics are found or are not enabled. SemanticsNode find(Finder finder) { TestAsyncUtils.guardSync(); - if (!_binding.semanticsEnabled) { + if (!_controller.binding.semanticsEnabled) { throw StateError('Semantics are not enabled.'); } final Iterable candidates = finder.evaluate(); @@ -109,6 +109,13 @@ class SemanticsController { /// tree. If `end` finds zero elements or more than one element, a /// [StateError] will be thrown. /// + /// If provided, the nodes for `end` and `start` must be part of the same + /// semantics tree, i.e. they must be part of the same view. + /// + /// If neither `start` or `end` is provided, `view` can be provided to specify + /// the semantics tree to traverse. If `view` is left unspecified, + /// [WidgetTester.view] is traversed by default. + /// /// Since the order is simulated, edge cases that differ between platforms /// (such as how the last visible item in a scrollable list is handled) may be /// inconsistent with platform behavior, but are expected to be sufficient for @@ -139,10 +146,47 @@ class SemanticsController { /// parts of the traversal. /// * [orderedEquals], which can be given an [Iterable] to exactly /// match the order of the traversal. - Iterable simulatedAccessibilityTraversal({Finder? start, Finder? end}) { + Iterable simulatedAccessibilityTraversal({Finder? start, Finder? end, FlutterView? view}) { TestAsyncUtils.guardSync(); + FlutterView? startView; + FlutterView? endView; + if (start != null) { + startView = _controller.viewOf(start); + if (view != null && startView != view) { + throw StateError( + 'The start node is not part of the provided view.\n' + 'Finder: ${start.description}\n' + 'View of start node: $startView\n' + 'Specified view: $view' + ); + } + } + if (end != null) { + endView = _controller.viewOf(end); + if (view != null && endView != view) { + throw StateError( + 'The end node is not part of the provided view.\n' + 'Finder: ${end.description}\n' + 'View of end node: $endView\n' + 'Specified view: $view' + ); + } + } + if (endView != null && startView != null && endView != startView) { + throw StateError( + 'The start and end node are in different views.\n' + 'Start finder: ${start!.description}\n' + 'End finder: ${end!.description}\n' + 'View of start node: $startView\n' + 'View of end node: $endView' + ); + } + + final FlutterView actualView = view ?? startView ?? endView ?? _controller.view; + final RenderView renderView = _controller.binding.renderViews.firstWhere((RenderView r) => r.flutterView == actualView); + final List traversal = []; - _traverse(_binding.pipelineOwner.semanticsOwner!.rootSemanticsNode!, traversal); + _traverse(renderView.owner!.semanticsOwner!.rootSemanticsNode!, traversal); int startIndex = 0; int endIndex = traversal.length - 1; @@ -229,8 +273,7 @@ class SemanticsController { /// Concrete subclasses must implement the [pump] method. abstract class WidgetController { /// Creates a widget controller that uses the given binding. - WidgetController(this.binding) - : _semantics = SemanticsController._(binding); + WidgetController(this.binding); /// A reference to the current instance of the binding. final WidgetsBinding binding; @@ -280,7 +323,7 @@ abstract class WidgetController { return _semantics; } - final SemanticsController _semantics; + late final SemanticsController _semantics = SemanticsController._(this); // FINDER API @@ -297,14 +340,16 @@ abstract class WidgetController { /// * [view] which returns the [TestFlutterView] used when only a single /// view is being used. TestFlutterView viewOf(Finder finder) { - final View view = firstWidget( + return _viewOf(finder) as TestFlutterView; + } + + FlutterView _viewOf(Finder finder) { + return firstWidget( find.ancestor( of: finder, matching: find.byType(View), - ) - ); - - return view.view as TestFlutterView; + ), + ).view; } /// Checks if `finder` exists in the tree. @@ -516,7 +561,12 @@ abstract class WidgetController { } /// Returns a list of all the [Layer] objects in the rendering. - List get layers => _walkLayers(binding.renderView.debugLayer!).toList(); + List get layers { + return [ + for (final RenderView renderView in binding.renderViews) + ..._walkLayers(renderView.debugLayer!) + ]; + } Iterable _walkLayers(Layer layer) sync* { TestAsyncUtils.guardSync(); yield layer; @@ -1190,10 +1240,10 @@ abstract class WidgetController { } /// Forwards the given location to the binding's hitTest logic. - HitTestResult hitTestOnBinding(Offset location) { + HitTestResult hitTestOnBinding(Offset location, { int? viewId }) { + viewId ??= view.viewId; final HitTestResult result = HitTestResult(); - // TODO(goderbauer): Support multiple views in flutter_test pointer event handling, https://github.com/flutter/flutter/issues/128281 - binding.hitTest(result, location); // ignore: deprecated_member_use + binding.hitTestInView(result, location, viewId); return result; } @@ -1313,9 +1363,9 @@ abstract class WidgetController { final RenderBox box = element.renderObject! as RenderBox; final Offset location = box.localToGlobal(sizeToPoint(box.size)); if (warnIfMissed) { + final FlutterView view = _viewOf(finder); final HitTestResult result = HitTestResult(); - // TODO(goderbauer): Support multiple views in flutter_test pointer event handling, https://github.com/flutter/flutter/issues/128281 - binding.hitTest(result, location); // ignore: deprecated_member_use + binding.hitTestInView(result, location, view.viewId); bool found = false; for (final HitTestEntry entry in result.path) { if (entry.target == box) { @@ -1324,15 +1374,16 @@ abstract class WidgetController { } } if (!found) { + final RenderView renderView = binding.renderViews.firstWhere((RenderView r) => r.flutterView == view); bool outOfBounds = false; - outOfBounds = !(Offset.zero & binding.renderView.size).contains(location); + outOfBounds = !(Offset.zero & renderView.size).contains(location); if (hitTestWarningShouldBeFatal) { throw FlutterError.fromParts([ ErrorSummary('Finder specifies a widget that would not receive pointer events.'), ErrorDescription('A call to $callee() with finder "$finder" derived an Offset ($location) that would not hit test on the specified widget.'), ErrorHint('Maybe the widget is actually off-screen, or another widget is obscuring it, or the widget cannot receive pointer events.'), if (outOfBounds) - ErrorHint('Indeed, $location is outside the bounds of the root of the render tree, ${binding.renderView.size}.'), + ErrorHint('Indeed, $location is outside the bounds of the root of the render tree, ${renderView.size}.'), box.toDiagnosticsNode(name: 'The finder corresponds to this RenderBox', style: DiagnosticsTreeStyle.singleLine), ErrorDescription('The hit test result at that offset is: $result'), ErrorDescription('If you expected this target not to be able to receive pointer events, pass "warnIfMissed: false" to "$callee()".'), @@ -1343,7 +1394,7 @@ abstract class WidgetController { '\n' 'Warning: A call to $callee() with finder "$finder" derived an Offset ($location) that would not hit test on the specified widget.\n' 'Maybe the widget is actually off-screen, or another widget is obscuring it, or the widget cannot receive pointer events.\n' - '${outOfBounds ? "Indeed, $location is outside the bounds of the root of the render tree, ${binding.renderView.size}.\n" : ""}' + '${outOfBounds ? "Indeed, $location is outside the bounds of the root of the render tree, ${renderView.size}.\n" : ""}' 'The finder corresponds to this RenderBox: $box\n' 'The hit test result at that offset is: $result\n' '${StackTrace.current}' diff --git a/packages/flutter_test/lib/src/finders.dart b/packages/flutter_test/lib/src/finders.dart index 1e881489cb5..e1afcc8fa8c 100644 --- a/packages/flutter_test/lib/src/finders.dart +++ b/packages/flutter_test/lib/src/finders.dart @@ -639,11 +639,11 @@ class _HitTestableFinder extends ChainedFinder { @override Iterable filter(Iterable parentCandidates) sync* { for (final Element candidate in parentCandidates) { + final int viewId = candidate.findAncestorWidgetOfExactType()!.view.viewId; final RenderBox box = candidate.renderObject! as RenderBox; final Offset absoluteOffset = box.localToGlobal(alignment.alongSize(box.size)); final HitTestResult hitResult = HitTestResult(); - // TODO(goderbauer): Support multiple views in flutter_test pointer event handling, https://github.com/flutter/flutter/issues/128281 - WidgetsBinding.instance.hitTest(hitResult, absoluteOffset); // ignore: deprecated_member_use + WidgetsBinding.instance.hitTestInView(hitResult, absoluteOffset, viewId); for (final HitTestEntry entry in hitResult.path) { if (entry.target == candidate.renderObject) { yield candidate; diff --git a/packages/flutter_test/lib/src/widget_tester.dart b/packages/flutter_test/lib/src/widget_tester.dart index 8cd6d50cb02..f63f79d0a44 100644 --- a/packages/flutter_test/lib/src/widget_tester.dart +++ b/packages/flutter_test/lib/src/widget_tester.dart @@ -569,24 +569,12 @@ class WidgetTester extends WidgetController implements HitTestDispatcher, Ticker EnginePhase phase = EnginePhase.sendSemanticsUpdate, ]) { return TestAsyncUtils.guard(() { - return _pumpWidget( - binding.wrapWithDefaultView(widget), - duration, - phase, - ); + binding.attachRootWidget(binding.wrapWithDefaultView(widget)); + binding.scheduleFrame(); + return binding.pump(duration, phase); }); } - Future _pumpWidget( - Widget widget, [ - Duration? duration, - EnginePhase phase = EnginePhase.sendSemanticsUpdate, - ]) { - binding.attachRootWidget(widget); - binding.scheduleFrame(); - return binding.pump(duration, phase); - } - @override Future> handlePointerEventRecord(Iterable records) { assert(records.isNotEmpty); @@ -745,12 +733,14 @@ class WidgetTester extends WidgetController implements HitTestDispatcher, Ticker 'your widget tree in a RootRestorationScope?', ); return TestAsyncUtils.guard(() async { - final Widget widget = ((binding.rootElement! as RenderObjectToWidgetElement).widget as RenderObjectToWidgetAdapter).child!; + final RootWidget widget = binding.rootElement!.widget as RootWidget; final TestRestorationData restorationData = binding.restorationManager.restorationData; runApp(Container(key: UniqueKey())); await pump(); binding.restorationManager.restoreFrom(restorationData); - return _pumpWidget(widget); + binding.attachToBuildOwner(widget); + binding.scheduleFrame(); + return binding.pump(); }); } @@ -837,9 +827,11 @@ class WidgetTester extends WidgetController implements HitTestDispatcher, Ticker bool get hasRunningAnimations => binding.transientCallbackCount > 0; @override - HitTestResult hitTestOnBinding(Offset location) { - location = binding.localToGlobal(location, binding.renderView); - return super.hitTestOnBinding(location); + HitTestResult hitTestOnBinding(Offset location, {int? viewId}) { + viewId ??= view.viewId; + final RenderView renderView = binding.renderViews.firstWhere((RenderView r) => r.flutterView.viewId == viewId); + location = binding.localToGlobal(location, renderView); + return super.hitTestOnBinding(location, viewId: viewId); } @override @@ -861,10 +853,12 @@ class WidgetTester extends WidgetController implements HitTestDispatcher, Ticker .map((HitTestEntry candidate) => candidate.target) .whereType() .first; - final Element? innerTargetElement = _lastWhereOrNull( - collectAllElementsFrom(binding.rootElement!, skipOffstage: true), - (Element element) => element.renderObject == innerTarget, - ); + final Element? innerTargetElement = binding.renderViews.contains(innerTarget) + ? null + : _lastWhereOrNull( + collectAllElementsFrom(binding.rootElement!, skipOffstage: true), + (Element element) => element.renderObject == innerTarget, + ); if (innerTargetElement == null) { printToConsole('No widgets found at ${event.position}.'); return; @@ -1060,6 +1054,8 @@ class WidgetTester extends WidgetController implements HitTestDispatcher, Ticker int? _lastRecordedSemanticsHandles; + // TODO(goderbauer): Only use binding.debugOutstandingSemanticsHandles when deprecated binding.pipelineOwner is removed. + // ignore: deprecated_member_use int get _currentSemanticsHandles => binding.debugOutstandingSemanticsHandles + binding.pipelineOwner.debugOutstandingSemanticsHandles; void _recordNumberOfSemanticsHandles() { diff --git a/packages/flutter_test/test/multi_view_accessibility_test.dart b/packages/flutter_test/test/multi_view_accessibility_test.dart new file mode 100644 index 00000000000..c070ad63107 --- /dev/null +++ b/packages/flutter_test/test/multi_view_accessibility_test.dart @@ -0,0 +1,135 @@ +// 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 'dart:convert'; +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Detects tap targets in all views', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + await pumpViews( + tester: tester, + viewContents: [ + SizedBox( + width: 47.0, + height: 47.0, + child: GestureDetector(onTap: () {}), + ), + SizedBox( + width: 46.0, + height: 46.0, + child: GestureDetector(onTap: () {}), + ), + ], + ); + final Evaluation result = await androidTapTargetGuideline.evaluate(tester); + expect(result.passed, false); + expect( + result.reason, + contains('expected tap target size of at least Size(48.0, 48.0), but found Size(47.0, 47.0)'), + ); + expect( + result.reason, + contains('expected tap target size of at least Size(48.0, 48.0), but found Size(46.0, 46.0)'), + ); + handle.dispose(); + }); + + testWidgets('Detects labeled tap targets in all views', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + await pumpViews( + tester: tester, + viewContents: [ + SizedBox( + width: 47.0, + height: 47.0, + child: GestureDetector(onTap: () {}), + ), + SizedBox( + width: 46.0, + height: 46.0, + child: GestureDetector(onTap: () {}), + ), + ], + ); + final Evaluation result = await labeledTapTargetGuideline.evaluate(tester); + expect(result.passed, false); + final List lines = const LineSplitter().convert(result.reason!); + expect(lines, hasLength(2)); + expect(lines.first, startsWith('SemanticsNode#1(Rect.fromLTRB(0.0, 0.0, 47.0, 47.0)')); + expect(lines.first, endsWith('expected tappable node to have semantic label, but none was found.')); + expect(lines.last, startsWith('SemanticsNode#2(Rect.fromLTRB(0.0, 0.0, 46.0, 46.0)')); + expect(lines.last, endsWith('expected tappable node to have semantic label, but none was found.')); + handle.dispose(); + }); + + testWidgets('Detects contrast problems in all views', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + await pumpViews( + tester: tester, + viewContents: [ + Container( + width: 200.0, + height: 200.0, + color: Colors.yellow, + child: const Text( + 'this is a test', + style: TextStyle(fontSize: 14.0, color: Colors.yellowAccent), + ), + ), + Container( + width: 200.0, + height: 200.0, + color: Colors.yellow, + child: const Text( + 'this is a test', + style: TextStyle(fontSize: 25.0, color: Colors.yellowAccent), + ), + ), + ], + ); + final Evaluation result = await textContrastGuideline.evaluate(tester); + expect(result.passed, false); + expect(result.reason, contains('Expected contrast ratio of at least 4.5 but found 0.88 for a font size of 14.0.')); + expect(result.reason, contains('Expected contrast ratio of at least 3.0 but found 0.88 for a font size of 25.0.')); + handle.dispose(); + }); +} + +Future pumpViews({required WidgetTester tester, required List viewContents}) { + final List views = [ + for (int i = 0; i < viewContents.length; i++) + View( + view: FakeView(tester.view, viewId: i + 100), + child: Center( + child: viewContents[i], + ), + ), + ]; + + tester.binding.attachRootWidget( + Directionality( + textDirection: TextDirection.ltr, + child: ViewCollection( + views: views, + ), + ), + ); + tester.binding.scheduleFrame(); + return tester.binding.pump(); +} + +class FakeView extends TestFlutterView{ + FakeView(FlutterView view, { this.viewId = 100 }) : super( + view: view, + platformDispatcher: view.platformDispatcher as TestPlatformDispatcher, + display: view.display as TestDisplay, + ); + + @override + final int viewId; +} diff --git a/packages/flutter_test/test/multi_view_controller_test.dart b/packages/flutter_test/test/multi_view_controller_test.dart new file mode 100644 index 00000000000..6d74d924c15 --- /dev/null +++ b/packages/flutter_test/test/multi_view_controller_test.dart @@ -0,0 +1,232 @@ +// 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 'dart:ui'; + +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('simulatedAccessibilityTraversal - start and end in same view', (WidgetTester tester) async { + await pumpViews(tester: tester); + expect( + tester.semantics.simulatedAccessibilityTraversal( + start: find.text('View2Child1'), + end: find.text('View2Child3'), + ).map((SemanticsNode node) => node.label), + [ + 'View2Child1', + 'View2Child2', + 'View2Child3', + ], + ); + }); + + testWidgets('simulatedAccessibilityTraversal - start not specified', (WidgetTester tester) async { + await pumpViews(tester: tester); + expect( + tester.semantics.simulatedAccessibilityTraversal( + end: find.text('View2Child3'), + ).map((SemanticsNode node) => node.label), + [ + 'View2Child0', + 'View2Child1', + 'View2Child2', + 'View2Child3', + ], + ); + }); + + testWidgets('simulatedAccessibilityTraversal - end not specified', (WidgetTester tester) async { + await pumpViews(tester: tester); + expect( + tester.semantics.simulatedAccessibilityTraversal( + start: find.text('View2Child1'), + ).map((SemanticsNode node) => node.label), + [ + 'View2Child1', + 'View2Child2', + 'View2Child3', + 'View2Child4', + ], + ); + }); + + testWidgets('simulatedAccessibilityTraversal - nothing specified', (WidgetTester tester) async { + await pumpViews(tester: tester); + expect( + tester.semantics.simulatedAccessibilityTraversal().map((SemanticsNode node) => node.label), + [ + 'View1Child0', + 'View1Child1', + 'View1Child2', + 'View1Child3', + 'View1Child4', + ], + ); + // Should be traversing over tester.view. + expect(tester.viewOf(find.text('View1Child0')), tester.view); + }); + + testWidgets('simulatedAccessibilityTraversal - only view specified', (WidgetTester tester) async { + await pumpViews(tester: tester); + expect( + tester.semantics.simulatedAccessibilityTraversal( + view: tester.viewOf(find.text('View2Child1')), + ).map((SemanticsNode node) => node.label), + [ + 'View2Child0', + 'View2Child1', + 'View2Child2', + 'View2Child3', + 'View2Child4', + ], + ); + }); + + testWidgets('simulatedAccessibilityTraversal - everything specified', (WidgetTester tester) async { + await pumpViews(tester: tester); + expect( + tester.semantics.simulatedAccessibilityTraversal( + start: find.text('View2Child1'), + end: find.text('View2Child3'), + view: tester.viewOf(find.text('View2Child1')), + ).map((SemanticsNode node) => node.label), + [ + 'View2Child1', + 'View2Child2', + 'View2Child3', + ], + ); + }); + + testWidgets('simulatedAccessibilityTraversal - start and end not in same view', (WidgetTester tester) async { + await pumpViews(tester: tester); + expect( + () => tester.semantics.simulatedAccessibilityTraversal( + start: find.text('View2Child1'), + end: find.text('View1Child3'), + ), + throwsA(isStateError.having( + (StateError e) => e.message, + 'message', + contains('The start and end node are in different views.'), + )), + ); + }); + + testWidgets('simulatedAccessibilityTraversal - start is not in view', (WidgetTester tester) async { + await pumpViews(tester: tester); + expect( + () => tester.semantics.simulatedAccessibilityTraversal( + start: find.text('View2Child1'), + end: find.text('View1Child3'), + view: tester.viewOf(find.text('View1Child3')), + ), + throwsA(isStateError.having( + (StateError e) => e.message, + 'message', + contains('The start node is not part of the provided view.'), + )), + ); + }); + + testWidgets('simulatedAccessibilityTraversal - end is not in view', (WidgetTester tester) async { + await pumpViews(tester: tester); + expect( + () => tester.semantics.simulatedAccessibilityTraversal( + start: find.text('View2Child1'), + end: find.text('View1Child3'), + view: tester.viewOf(find.text('View2Child1')), + ), + throwsA(isStateError.having( + (StateError e) => e.message, + 'message', + contains('The end node is not part of the provided view.'), + )), + ); + }); + + testWidgets('viewOf', (WidgetTester tester) async { + await pumpViews(tester: tester); + expect(tester.viewOf(find.text('View0Child0')).viewId, 100); + expect(tester.viewOf(find.text('View1Child1')).viewId, tester.view.viewId); + expect(tester.viewOf(find.text('View2Child2')).viewId, 102); + }); + + testWidgets('layers includes layers from all views', (WidgetTester tester) async { + await pumpViews(tester: tester); + const int numberOfViews = 3; + expect(tester.binding.renderViews.length, numberOfViews); // One RenderView for each FlutterView. + + final List layers = tester.layers; + // Each RenderView contributes a TransformLayer and a PictureLayer. + expect(layers, hasLength(numberOfViews * 2)); + expect(layers.whereType(), hasLength(numberOfViews)); + expect(layers.whereType(), hasLength(numberOfViews)); + expect( + layers.whereType().map((TransformLayer l ) => l.owner), + containsAll(tester.binding.renderViews), + ); + }); + + testWidgets('hitTestOnBinding', (WidgetTester tester) async { + await pumpViews(tester: tester); + // Not specifying a viewId hit tests on tester.view: + HitTestResult result = tester.hitTestOnBinding(Offset.zero); + expect(result.path.map((HitTestEntry h) => h.target).whereType().single.flutterView, tester.view); + // Specifying a viewId is respected: + result = tester.hitTestOnBinding(Offset.zero, viewId: 100); + expect(result.path.map((HitTestEntry h) => h.target).whereType().single.flutterView.viewId, 100); + result = tester.hitTestOnBinding(Offset.zero, viewId: 102); + expect(result.path.map((HitTestEntry h) => h.target).whereType().single.flutterView.viewId, 102); + }); + + testWidgets('hitTestable works in different Views', (WidgetTester tester) async { + await pumpViews(tester: tester); + expect((find.text('View0Child0').hitTestable().evaluate().single.widget as Text).data, 'View0Child0'); + expect((find.text('View1Child1').hitTestable().evaluate().single.widget as Text).data, 'View1Child1'); + expect((find.text('View2Child2').hitTestable().evaluate().single.widget as Text).data, 'View2Child2'); + }); +} + +Future pumpViews({required WidgetTester tester}) { + final List views = [ + for (int i = 0; i < 3; i++) + View( + view: i == 1 ? tester.view : FakeView(tester.view, viewId: i + 100), + child: Center( + child: Column( + children: [ + for (int c = 0; c < 5; c++) + Semantics(container: true, child: Text('View${i}Child$c')), + ], + ), + ), + ), + ]; + + tester.binding.attachRootWidget( + Directionality( + textDirection: TextDirection.ltr, + child: ViewCollection( + views: views, + ), + ), + ); + tester.binding.scheduleFrame(); + return tester.binding.pump(); +} + +class FakeView extends TestFlutterView{ + FakeView(FlutterView view, { this.viewId = 100 }) : super( + view: view, + platformDispatcher: view.platformDispatcher as TestPlatformDispatcher, + display: view.display as TestDisplay, + ); + + @override + final int viewId; +} diff --git a/packages/flutter_test/test/semantics_checker/flutter_test_config.dart b/packages/flutter_test/test/semantics_checker/flutter_test_config.dart index 88b2aee380d..f4a4af6b75e 100644 --- a/packages/flutter_test/test/semantics_checker/flutter_test_config.dart +++ b/packages/flutter_test/test/semantics_checker/flutter_test_config.dart @@ -22,9 +22,9 @@ Future testExecutable(FutureOr Function() testMain) async { void pipelineOwnerTestRun() { testWidgets('open SemanticsHandle from PipelineOwner fails test', (WidgetTester tester) async { - final int outstandingHandles = tester.binding.pipelineOwner.debugOutstandingSemanticsHandles; - tester.binding.pipelineOwner.ensureSemantics(); - expect(tester.binding.pipelineOwner.debugOutstandingSemanticsHandles, outstandingHandles + 1); + final int outstandingHandles = tester.binding.debugOutstandingSemanticsHandles; + tester.binding.ensureSemantics(); + expect(tester.binding.debugOutstandingSemanticsHandles, outstandingHandles + 1); // SemanticsHandle is not disposed on purpose to verify in tearDown that // the test failed due to an active SemanticsHandle. }); diff --git a/packages/flutter_test/test/widget_tester_live_device_test.dart b/packages/flutter_test/test/widget_tester_live_device_test.dart index 4f9871011ec..c6468a2f47f 100644 --- a/packages/flutter_test/test/widget_tester_live_device_test.dart +++ b/packages/flutter_test/test/widget_tester_live_device_test.dart @@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter_test/flutter_test.dart'; // Only check the initial lines of the message, since the message walks the @@ -82,7 +83,7 @@ No widgets found at Offset(1.0, 1.0). ), ); - final Size originalSize = tester.binding.createViewConfiguration().size; + final Size originalSize = tester.binding.createViewConfigurationFor(tester.binding.renderView).size; // ignore: deprecated_member_use await tester.binding.setSurfaceSize(const Size(2000, 1800)); try { await tester.pump(); @@ -126,6 +127,7 @@ class _MockLiveTestWidgetsFlutterBinding extends LiveTestWidgetsFlutterBinding { // real devices touches sends event in the global coordinate system. // See the documentation of [handlePointerEventForSource] for details. if (source == TestBindingEventSource.test) { + final RenderView renderView = renderViews.firstWhere((RenderView r) => r.flutterView.viewId == event.viewId); final PointerEvent globalEvent = event.copyWith(position: localToGlobal(event.position, renderView)); return super.handlePointerEventForSource(globalEvent); } diff --git a/packages/integration_test/lib/integration_test.dart b/packages/integration_test/lib/integration_test.dart index d46276d1bd6..14542e6637e 100644 --- a/packages/integration_test/lib/integration_test.dart +++ b/packages/integration_test/lib/integration_test.dart @@ -100,10 +100,6 @@ https://flutter.dev/docs/testing/integration-tests#testing-on-firebase-test-lab // under debug mode. static bool _firstRun = false; - /// Artificially changes the surface size to `size` on the Widget binding, - /// then flushes microtasks. - /// - /// Set to null to use the default surface size. @override Future setSurfaceSize(Size? size) { return TestAsyncUtils.guard(() async { @@ -117,12 +113,11 @@ https://flutter.dev/docs/testing/integration-tests#testing-on-firebase-test-lab } @override - ViewConfiguration createViewConfiguration() { - final FlutterView view = platformDispatcher.implicitView!; - final double devicePixelRatio = view.devicePixelRatio; - final Size size = _surfaceSize ?? view.physicalSize / devicePixelRatio; + ViewConfiguration createViewConfigurationFor(RenderView renderView) { + final FlutterView view = renderView.flutterView; + final Size? surfaceSize = view == platformDispatcher.implicitView ? _surfaceSize : null; return TestViewConfiguration.fromView( - size: size, + size: surfaceSize ?? view.physicalSize / view.devicePixelRatio, view: view, ); } @@ -442,11 +437,11 @@ https://flutter.dev/docs/testing/integration-tests#testing-on-firebase-test-lab Timeout defaultTestTimeout = Timeout.none; @override - void attachRootWidget(Widget rootWidget) { + Widget wrapWithDefaultView(Widget rootWidget) { // This is a workaround where screenshots of root widgets have incorrect // bounds. // TODO(jiahaog): Remove when https://github.com/flutter/flutter/issues/66006 is fixed. - super.attachRootWidget(RepaintBoundary(child: rootWidget)); + return super.wrapWithDefaultView(RepaintBoundary(child: rootWidget)); } @override