Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 11 commits into from
Feb 15, 2024
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) {
ditman marked this conversation as resolved.
Show resolved Hide resolved
// 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