Skip to content

Commit

Permalink
[web] Add dynamic view sizing (v2) (#50271)
Browse files Browse the repository at this point in the history
### 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 flutter/flutter#137444
* Closes #48541

[C++, Objective-C, Java style guides]: https://github.com/flutter/engine/blob/main/CONTRIBUTING.md#style
  • Loading branch information
ditman authored Feb 15, 2024
1 parent 6eb067e commit 6a5f306
Show file tree
Hide file tree
Showing 8 changed files with 397 additions and 26 deletions.
2 changes: 0 additions & 2 deletions lib/web_ui/lib/platform_dispatcher.dart
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,6 @@ abstract class PlatformDispatcher {

void scheduleFrame();

Future<void> render(Scene scene, [FlutterView view]);

AccessibilityFeatures get accessibilityFeatures;

VoidCallback? get onAccessibilityFeaturesChanged;
Expand Down
29 changes: 29 additions & 0 deletions lib/web_ui/lib/src/engine/js_interop/js_app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 5 additions & 7 deletions lib/web_ui/lib/src/engine/platform_dispatcher.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
137 changes: 129 additions & 8 deletions lib/web_ui/lib/src/engine/window.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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._(
Expand All @@ -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.
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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);

Expand All @@ -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;

Expand Down Expand Up @@ -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].
Expand Down Expand Up @@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -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;
}
13 changes: 8 additions & 5 deletions lib/web_ui/test/common/frame_timings_common.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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!) {
Expand Down
Loading

0 comments on commit 6a5f306

Please sign in to comment.