[web] Add dynamic view sizing (v2) (flutter/engine#50271)

### Changes

* Introduces a new `viewConstraints` JS configuration parameter to configure max/min width/height constraints for a view. Those can have the following values:
  * An integer `>= 0`: max/min size in pixels
  * `Infinity` (or `Number.POSITIVE_INFINITY`): (only for max values) -> **unconstrained**.
  * When any value is not set, it defaults to "tight to the current size".
    * See [Understanding constraints](https://docs.flutter.dev/ui/layout/constraints).
* Computes the correct `physicalConstraints` of a view off of its `physicalSize` and its `viewConstraints` for the framework to use during layout.
  * When no constraints are passed, the current behavior is preserved: the default constraints are "tight" to the `physicalSize`.
* Resizes the current view DOM when requested by the framework and updates its internal physicalSize, then continues with the render procedure.

### Example

This is how we can configure a view to "take as much vertical space as needed":

```js
flutterApp.addView({
  viewConstraints: {
    minHeight: 0,
    maxHeight: Infinity,
  },
  hostElement: ...,
});
```

### TODO

* Needs actual unit tests

### Issues

* Fixes https://github.com/flutter/flutter/issues/137444
* Closes https://github.com/flutter/engine/pull/48541

[C++, Objective-C, Java style guides]: https://github.com/flutter/engine/blob/main/CONTRIBUTING.md#style
This commit is contained in:
David Iglesias 2024-02-14 16:56:57 -08:00 committed by GitHub
parent a499590726
commit 45adab9726
8 changed files with 397 additions and 26 deletions

View File

@ -90,8 +90,6 @@ abstract class PlatformDispatcher {
void scheduleFrame();
Future<void> render(Scene scene, [FlutterView view]);
AccessibilityFeatures get accessibilityFeatures;
VoidCallback? get onAccessibilityFeaturesChanged;

View File

@ -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

View File

@ -797,27 +797,25 @@ class EnginePlatformDispatcher extends ui.PlatformDispatcher {
/// scheduling of frames.
/// * [RendererBinding], the Flutter framework class which manages layout and
/// painting.
@override
Future<void> 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);
}
}

View File

@ -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;
}

View File

@ -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.
///
/// ```
/// <p>Some HTML content...</p>
/// +--- (div) hostElement ------------------------------------+
/// | +--- rootElement ---------------------+ |
/// | | | |
/// | | | container |
/// | | size applied to *this* | must be able |
/// | | | to reflow |
/// | | | |
/// | +-------------------------------------+ |
/// +----------------------------------------------------------+
/// <p>More HTML content...</p>
/// ```
///
/// 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;
}

View File

@ -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<void> runFrameTimingsTest() async {
final EnginePlatformDispatcher dispatcher = ui.PlatformDispatcher.instance as EnginePlatformDispatcher;
List<ui.FrameTiming>? timings;
ui.PlatformDispatcher.instance.onReportTimings = (List<ui.FrameTiming> data) {
dispatcher.onReportTimings = (List<ui.FrameTiming> data) {
timings = data;
};
Completer<void> frameDone = Completer<void>();
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<void>.delayed(const Duration(milliseconds: 150));
// Frame 2.
frameDone = Completer<void>();
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!) {

View File

@ -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<void> 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,
));
});
});
}

View File

@ -584,10 +584,11 @@ Future<void> 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<void>.delayed(const Duration(milliseconds: 250));
await view.onResize.first;
metricsChangedCount = 0;
view.platformDispatcher.onMetricsChanged = () {
@ -607,7 +608,7 @@ Future<void> 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<void> 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);
});
});
}