diff --git a/lib/web_ui/lib/platform_dispatcher.dart b/lib/web_ui/lib/platform_dispatcher.dart index c557820969f16..fb351b27c298e 100644 --- a/lib/web_ui/lib/platform_dispatcher.dart +++ b/lib/web_ui/lib/platform_dispatcher.dart @@ -90,8 +90,6 @@ abstract class PlatformDispatcher { void scheduleFrame(); - Future render(Scene scene, [FlutterView view]); - AccessibilityFeatures get accessibilityFeatures; VoidCallback? get onAccessibilityFeaturesChanged; diff --git a/lib/web_ui/lib/src/engine/js_interop/js_app.dart b/lib/web_ui/lib/src/engine/js_interop/js_app.dart index 2e213aa4700d4..49feafaa27c58 100644 --- a/lib/web_ui/lib/src/engine/js_interop/js_app.dart +++ b/lib/web_ui/lib/src/engine/js_interop/js_app.dart @@ -25,9 +25,38 @@ extension JsFlutterViewOptionsExtension on JsFlutterViewOptions { return _hostElement!; } + @JS('viewConstraints') + external JsViewConstraints? get _viewConstraints; + JsViewConstraints? get viewConstraints { + return _viewConstraints; + } + external JSAny? get initialData; } +/// The JS bindings for a [ViewConstraints] object. +@JS() +@anonymous +@staticInterop +class JsViewConstraints { + external factory JsViewConstraints({ + double? minWidth, + double? maxWidth, + double? minHeight, + double? maxHeight, + }); +} + +/// The attributes of a [JsViewConstraints] object. +/// +/// These attributes are expressed in *logical* pixels. +extension JsViewConstraintsExtension on JsViewConstraints { + external double? get maxHeight; + external double? get maxWidth; + external double? get minHeight; + external double? get minWidth; +} + /// The public JS API of a running Flutter Web App. @JS() @anonymous diff --git a/lib/web_ui/lib/src/engine/platform_dispatcher.dart b/lib/web_ui/lib/src/engine/platform_dispatcher.dart index 3be345954a669..29b4527ae9f6d 100644 --- a/lib/web_ui/lib/src/engine/platform_dispatcher.dart +++ b/lib/web_ui/lib/src/engine/platform_dispatcher.dart @@ -797,27 +797,25 @@ class EnginePlatformDispatcher extends ui.PlatformDispatcher { /// scheduling of frames. /// * [RendererBinding], the Flutter framework class which manages layout and /// painting. - @override Future render(ui.Scene scene, [ui.FlutterView? view]) async { - assert(view != null || implicitView != null, - 'Calling render without a FlutterView'); - if (view == null && implicitView == null) { + final EngineFlutterView? target = (view ?? implicitView) as EngineFlutterView?; + assert(target != null, 'Calling render without a FlutterView'); + if (target == null) { // If there is no view to render into, then this is a no-op. return; } - final ui.FlutterView viewToRender = view ?? implicitView!; // Only render in an `onDrawFrame` or `onBeginFrame` scope. This is checked // by checking if the `_viewsRenderedInCurrentFrame` is non-null and this // view hasn't been rendered already in this scope. final bool shouldRender = - _viewsRenderedInCurrentFrame?.add(viewToRender) ?? false; + _viewsRenderedInCurrentFrame?.add(target) ?? false; // TODO(harryterkelsen): HTML renderer needs to violate the render rule in // order to perform golden tests in Flutter framework because on the HTML // renderer, golden tests render to DOM and then take a browser screenshot, // https://github.com/flutter/flutter/issues/137073. if (shouldRender || renderer.rendererTag == 'html') { - await renderer.renderScene(scene, viewToRender); + await renderer.renderScene(scene, target); } } diff --git a/lib/web_ui/lib/src/engine/view_embedder/flutter_view_manager.dart b/lib/web_ui/lib/src/engine/view_embedder/flutter_view_manager.dart index fc6cced9eefe0..e93956f0325a1 100644 --- a/lib/web_ui/lib/src/engine/view_embedder/flutter_view_manager.dart +++ b/lib/web_ui/lib/src/engine/view_embedder/flutter_view_manager.dart @@ -39,8 +39,11 @@ class FlutterViewManager { EngineFlutterView createAndRegisterView( JsFlutterViewOptions jsViewOptions, ) { - final EngineFlutterView view = - EngineFlutterView(_dispatcher, jsViewOptions.hostElement); + final EngineFlutterView view = EngineFlutterView( + _dispatcher, + jsViewOptions.hostElement, + viewConstraints: jsViewOptions.viewConstraints, + ); registerView(view, jsViewOptions: jsViewOptions); return view; } diff --git a/lib/web_ui/lib/src/engine/window.dart b/lib/web_ui/lib/src/engine/window.dart index 4365bc67f5d35..c82edb24f332d 100644 --- a/lib/web_ui/lib/src/engine/window.dart +++ b/lib/web_ui/lib/src/engine/window.dart @@ -15,6 +15,7 @@ import 'configuration.dart'; import 'display.dart'; import 'dom.dart'; import 'initialization.dart'; +import 'js_interop/js_app.dart'; import 'mouse/context_menu.dart'; import 'mouse/cursor.dart'; import 'navigation/history.dart'; @@ -50,7 +51,9 @@ base class EngineFlutterView implements ui.FlutterView { /// the Flutter view will be rendered. factory EngineFlutterView( EnginePlatformDispatcher platformDispatcher, - DomElement hostElement, + DomElement hostElement, { + JsViewConstraints? viewConstraints, + } ) = _EngineFlutterViewImpl; EngineFlutterView._( @@ -59,8 +62,11 @@ base class EngineFlutterView implements ui.FlutterView { // This is nullable to accommodate the legacy `EngineFlutterWindow`. In // multi-view mode, the host element is required for each view (as reflected // by the public `EngineFlutterView` constructor). - DomElement? hostElement, - ) : embeddingStrategy = EmbeddingStrategy.create(hostElement: hostElement), + DomElement? hostElement, { + JsViewConstraints? viewConstraints, + } + ) : _jsViewConstraints = viewConstraints, + embeddingStrategy = EmbeddingStrategy.create(hostElement: hostElement), dimensionsProvider = DimensionsProvider.create(hostElement: hostElement) { // The embeddingStrategy will take care of cleaning up the rootElement on // hot restart. @@ -117,7 +123,9 @@ base class EngineFlutterView implements ui.FlutterView { @override void render(ui.Scene scene, {ui.Size? size}) { assert(!isDisposed, 'Trying to render a disposed EngineFlutterView.'); - // TODO(goderbauer): Respect the provided size when "physicalConstraints" are not always tight. See TODO on "physicalConstraints". + if (size != null) { + resize(size); + } platformDispatcher.render(scene, this); } @@ -145,9 +153,14 @@ base class EngineFlutterView implements ui.FlutterView { late final PointerBinding pointerBinding; - // TODO(goderbauer): Provide API to configure constraints. See also TODO in "render". @override - ViewConstraints get physicalConstraints => ViewConstraints.tight(physicalSize); + ViewConstraints get physicalConstraints { + final double dpr = devicePixelRatio; + final ui.Size currentLogicalSize = physicalSize / dpr; + return ViewConstraints.fromJs(_jsViewConstraints, currentLogicalSize) * dpr; + } + + final JsViewConstraints? _jsViewConstraints; late final EngineSemanticsOwner semantics = EngineSemanticsOwner(dom.semanticsHost); @@ -156,6 +169,54 @@ base class EngineFlutterView implements ui.FlutterView { return _physicalSize ??= _computePhysicalSize(); } + /// Resizes the `rootElement` to `newPhysicalSize` by changing its CSS style. + /// + /// This is used by the [render] method, when the framework sends new dimensions + /// for the current Flutter View. + /// + /// Dimensions from the framework are constrained by the [physicalConstraints] + /// that can be configured by the user when adding a view to the app. + /// + /// In practice, this method changes the size of the `rootElement` of the app + /// so it can push/shrink inside its `hostElement`. That way, a Flutter app + /// can change the layout of the container page. + /// + /// ``` + ///

Some HTML content...

+ /// +--- (div) hostElement ------------------------------------+ + /// | +--- rootElement ---------------------+ | + /// | | | | + /// | | | container | + /// | | size applied to *this* | must be able | + /// | | | to reflow | + /// | | | | + /// | +-------------------------------------+ | + /// +----------------------------------------------------------+ + ///

More HTML content...

+ /// ``` + /// + /// The `hostElement` needs to be styled in a way that allows its size to flow + /// with its contents. Things like `max-height: 100px; overflow: hidden` will + /// work as expected (by hiding the overflowing part of the flutter app), but + /// if in that case flutter is not made aware of that max-height with + /// `physicalConstraints`, it will end up rendering more pixels that are visible + /// on the screen, with a possible hit to performance. + /// + /// TL;DR: The `viewConstraints` of a Flutter view, must take into consideration + /// the CSS box-model restrictions imposed on its `hostElement` (especially when + /// hiding `overflow`). Flutter does not attempt to interpret the styles of + /// `hostElement` to compute its `physicalConstraints`, only its current size. + void resize(ui.Size newPhysicalSize) { + // The browser uses CSS, and CSS operates in logical sizes. + final ui.Size logicalSize = newPhysicalSize / devicePixelRatio; + dom.rootElement.style + ..width = '${logicalSize.width}px' + ..height = '${logicalSize.height}px'; + + // Force an update of the physicalSize so it's ready for the renderer. + _computePhysicalSize(); + } + /// Lazily populated and cleared at the end of the frame. ui.Size? _physicalSize; @@ -278,8 +339,10 @@ base class EngineFlutterView implements ui.FlutterView { final class _EngineFlutterViewImpl extends EngineFlutterView { _EngineFlutterViewImpl( EnginePlatformDispatcher platformDispatcher, - DomElement hostElement, - ) : super._(_nextViewId++, platformDispatcher, hostElement); + DomElement hostElement, { + JsViewConstraints? viewConstraints, + } + ) : super._(_nextViewId++, platformDispatcher, hostElement, viewConstraints: viewConstraints); } /// The Web implementation of [ui.SingletonFlutterWindow]. @@ -708,6 +771,27 @@ class ViewConstraints implements ui.ViewConstraints { minHeight = size.height, maxHeight = size.height; + /// Converts JsViewConstraints into ViewConstraints. + /// + /// Since JsViewConstraints are expressed by the user, in logical pixels, this + /// conversion uses logical pixels for the current size as well. + /// + /// The resulting ViewConstraints object will be multiplied by devicePixelRatio + /// later to compute the physicalViewConstraints, which is what the framework + /// uses. + factory ViewConstraints.fromJs( + JsViewConstraints? constraints, ui.Size currentLogicalSize) { + if (constraints == null) { + return ViewConstraints.tight(currentLogicalSize); + } + return ViewConstraints( + minWidth: _computeMinConstraintValue(constraints.minWidth, currentLogicalSize.width), + minHeight: _computeMinConstraintValue(constraints.minHeight, currentLogicalSize.height), + maxWidth: _computeMaxConstraintValue(constraints.maxWidth, currentLogicalSize.width), + maxHeight: _computeMaxConstraintValue(constraints.maxHeight, currentLogicalSize.height), + ); + } + @override final double minWidth; @override @@ -726,6 +810,15 @@ class ViewConstraints implements ui.ViewConstraints { @override bool get isTight => minWidth >= maxWidth && minHeight >= maxHeight; + ViewConstraints operator*(double factor) { + return ViewConstraints( + minWidth: minWidth * factor, + maxWidth: maxWidth * factor, + minHeight: minHeight * factor, + maxHeight: maxHeight * factor, + ); + } + @override ViewConstraints operator/(double factor) { return ViewConstraints( @@ -774,3 +867,31 @@ class ViewConstraints implements ui.ViewConstraints { return 'ViewConstraints($width, $height)'; } } + +// Computes the "min" value for a constraint that takes into account user `desired` +// configuration and the actual available value. +// +// Returns the `desired` value unless it is `null`, in which case it returns the +// `available` value. +double _computeMinConstraintValue(double? desired, double available) { + assert(desired == null || desired >= 0, 'Minimum constraint must be >= 0 if set.'); + assert(desired == null || desired.isFinite, 'Minimum constraint must be finite.'); + return desired ?? available; +} + +// Computes the "max" value for a constraint that takes into account user `desired` +// configuration and the `available` size. +// +// Returns the `desired` value unless it is `null`, in which case it returns the +// `available` value. +// +// A `desired` value of `Infinity` or `Number.POSITIVE_INFINITY` (from JS) means +// "unconstrained". +// +// This method allows returning values larger than `available`, so the Flutter +// app is able to stretch its container up to a certain value, without being +// fully unconstrained. +double _computeMaxConstraintValue(double? desired, double available) { + assert(desired == null || desired >= 0, 'Maximum constraint must be >= 0 if set.'); + return desired ?? available; +} diff --git a/lib/web_ui/test/common/frame_timings_common.dart b/lib/web_ui/test/common/frame_timings_common.dart index 2bcc08438eb2a..314e1a808861e 100644 --- a/lib/web_ui/test/common/frame_timings_common.dart +++ b/lib/web_ui/test/common/frame_timings_common.dart @@ -5,36 +5,39 @@ import 'dart:async'; import 'package:test/test.dart'; +import 'package:ui/src/engine.dart' show EnginePlatformDispatcher; import 'package:ui/ui.dart' as ui; /// Tests frame timings in a renderer-agnostic way. /// /// See CanvasKit-specific and HTML-specific test files `frame_timings_test.dart`. Future runFrameTimingsTest() async { + final EnginePlatformDispatcher dispatcher = ui.PlatformDispatcher.instance as EnginePlatformDispatcher; + List? timings; - ui.PlatformDispatcher.instance.onReportTimings = (List data) { + dispatcher.onReportTimings = (List data) { timings = data; }; Completer frameDone = Completer(); - ui.PlatformDispatcher.instance.onDrawFrame = () { + dispatcher.onDrawFrame = () { final ui.SceneBuilder sceneBuilder = ui.SceneBuilder(); sceneBuilder ..pushOffset(0, 0) ..pop(); - ui.PlatformDispatcher.instance.render(sceneBuilder.build()).then((_) { + dispatcher.render(sceneBuilder.build()).then((_) { frameDone.complete(); }); }; // Frame 1. - ui.PlatformDispatcher.instance.scheduleFrame(); + dispatcher.scheduleFrame(); await frameDone.future; expect(timings, isNull, reason: "100 ms hasn't passed yet"); await Future.delayed(const Duration(milliseconds: 150)); // Frame 2. frameDone = Completer(); - ui.PlatformDispatcher.instance.scheduleFrame(); + dispatcher.scheduleFrame(); await frameDone.future; expect(timings, hasLength(2), reason: '100 ms passed. 2 frames pumped.'); for (final ui.FrameTiming timing in timings!) { diff --git a/lib/web_ui/test/engine/view/view_constraints_test.dart b/lib/web_ui/test/engine/view/view_constraints_test.dart new file mode 100644 index 0000000000000..3ea1c09517ed5 --- /dev/null +++ b/lib/web_ui/test/engine/view/view_constraints_test.dart @@ -0,0 +1,155 @@ +// Copyright 2013 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:async'; + +import 'package:test/bootstrap/browser.dart'; +import 'package:test/test.dart'; +import 'package:ui/src/engine.dart'; +import 'package:ui/ui.dart' as ui; + +import '../../common/matchers.dart'; + +void main() { + internalBootstrapBrowserTest(() => testMain); +} + +Future testMain() async { + const ui.Size size = ui.Size(640, 480); + + group('ViewConstraints.fromJs', () { + test('Negative min constraints -> Assertion error.', () async { + expect( + () => ViewConstraints.fromJs( + JsViewConstraints( + minWidth: -1, + ), + size), + throwsAssertionError); + expect( + () => ViewConstraints.fromJs( + JsViewConstraints( + minHeight: -1, + ), + size), + throwsAssertionError); + }); + + test('Infinite min constraints -> Assertion error.', () async { + expect( + () => ViewConstraints.fromJs( + JsViewConstraints( + minWidth: double.infinity, + ), + size), + throwsAssertionError); + expect( + () => ViewConstraints.fromJs( + JsViewConstraints( + minHeight: double.infinity, + ), + size), + throwsAssertionError); + }); + + test('Negative max constraints -> Assertion error.', () async { + expect( + () => ViewConstraints.fromJs( + JsViewConstraints( + maxWidth: -1, + ), + size), + throwsAssertionError); + expect( + () => ViewConstraints.fromJs( + JsViewConstraints( + maxHeight: -1, + ), + size), + throwsAssertionError); + }); + + test('null JS Constraints -> Tight to size', () async { + expect( + ViewConstraints.fromJs(null, size), + const ViewConstraints( + minWidth: 640, maxWidth: 640, // + minHeight: 480, maxHeight: 480, // + )); + }); + + test('non-null JS Constraints -> Computes sizes', () async { + final JsViewConstraints constraints = JsViewConstraints( + minWidth: 500, maxWidth: 600, // + minHeight: 300, maxHeight: 400, // + ); + expect( + ViewConstraints.fromJs(constraints, size), + const ViewConstraints( + minWidth: 500, maxWidth: 600, // + minHeight: 300, maxHeight: 400, // + )); + }); + + test('null JS Width -> Tight to width. Computes height.', () async { + final JsViewConstraints constraints = JsViewConstraints( + minHeight: 200, + maxHeight: 320, + ); + expect( + ViewConstraints.fromJs(constraints, size), + const ViewConstraints( + minWidth: 640, maxWidth: 640, // + minHeight: 200, maxHeight: 320, // + )); + }); + + test('null JS Height -> Tight to height. Computed width.', () async { + final JsViewConstraints constraints = JsViewConstraints( + minWidth: 200, + maxWidth: 320, + ); + expect( + ViewConstraints.fromJs(constraints, size), + const ViewConstraints( + minWidth: 200, maxWidth: 320, // + minHeight: 480, maxHeight: 480, // + )); + }); + + test( + 'non-null JS Constraints -> Computes sizes. Max values can be greater than available size.', + () async { + final JsViewConstraints constraints = JsViewConstraints( + minWidth: 500, maxWidth: 1024, // + minHeight: 300, maxHeight: 768, // + ); + expect( + ViewConstraints.fromJs(constraints, size), + const ViewConstraints( + minWidth: 500, maxWidth: 1024, // + minHeight: 300, maxHeight: 768, // + )); + }); + + test( + 'non-null JS Constraints -> Computes sizes. Max values can be unconstrained.', + () async { + final JsViewConstraints constraints = JsViewConstraints( + minWidth: 500, + maxWidth: double.infinity, + minHeight: 300, + maxHeight: double.infinity, + ); + expect( + ViewConstraints.fromJs(constraints, size), + const ViewConstraints( + // ignore: avoid_redundant_argument_values + minWidth: 500, maxWidth: double.infinity, + // ignore: avoid_redundant_argument_values + minHeight: 300, maxHeight: double.infinity, + )); + }); + }); +} diff --git a/lib/web_ui/test/engine/window_test.dart b/lib/web_ui/test/engine/window_test.dart index 35b6f237e9a14..522cc5742c477 100644 --- a/lib/web_ui/test/engine/window_test.dart +++ b/lib/web_ui/test/engine/window_test.dart @@ -584,10 +584,11 @@ Future testMain() async { ..width = '10px' ..height = '10px'; domDocument.body!.append(host); + // Let the DOM settle before starting the test, so we don't get the first // 10,10 Size in the test. Otherwise, the ResizeObserver may trigger // unexpectedly after the test has started, and break our "first" result. - await Future.delayed(const Duration(milliseconds: 250)); + await view.onResize.first; metricsChangedCount = 0; view.platformDispatcher.onMetricsChanged = () { @@ -607,7 +608,7 @@ Future testMain() async { expect(view.physicalSize, const ui.Size(25.0, 25.0)); expect(metricsChangedCount, 0); - // Resize the host to 20x20. + // Simulate the browser resizing the host to 20x20. host.style ..width = '20px' ..height = '20px'; @@ -632,5 +633,68 @@ Future testMain() async { // The view should maintain the debugPhysicalSizeOverride. expect(view.physicalSize, const ui.Size(100.0, 100.0)); }); + + test('can resize host', () async { + // Reset host style, so it tightly wraps the rootElement of the view. + // This style change will trigger a "onResize" event when all the DOM + // operations settle that we must await before taking measurements. + host.style + ..display = 'inline-block' + ..width = 'auto' + ..height = 'auto'; + + // Resize the host to 20x20 (physical pixels). + view.resize(const ui.Size.square(50)); + + await view.onResize.first; + + // The host tightly wraps the rootElement: + expect(view.physicalSize, const ui.Size(50.0, 50.0)); + + // Inspect the rootElement directly: + expect(view.dom.rootElement.clientWidth, 50 / view.devicePixelRatio); + expect(view.dom.rootElement.clientHeight, 50 / view.devicePixelRatio); + }); + }); + + group('physicalConstraints', () { + const double dpr = 2.5; + late DomHTMLDivElement host; + late EngineFlutterView view; + + setUp(() async { + EngineFlutterDisplay.instance.debugOverrideDevicePixelRatio(dpr); + host = createDomHTMLDivElement() + ..style.width = '640px' + ..style.height = '480px'; + domDocument.body!.append(host); + }); + + tearDown(() { + host.remove(); + EngineFlutterDisplay.instance.debugOverrideDevicePixelRatio(null); + }); + + test('JsViewConstraints are passed and used to compute physicalConstraints', () async { + view = EngineFlutterView( + EnginePlatformDispatcher.instance, + host, + viewConstraints: JsViewConstraints( + minHeight: 320, + maxHeight: double.infinity, + )); + + // All the metrics until now have been expressed in logical pixels, because + // they're coming from CSS/the browser, which works in logical pixels. + expect(view.physicalConstraints, const ViewConstraints( + minHeight: 320, + // ignore: avoid_redundant_argument_values + maxHeight: double.infinity, + minWidth: 640, + maxWidth: 640, + // However the framework expects physical pixels, so we multiply our expectations + // by the current DPR (2.5) + ) * dpr); + }); }); }