Skip to content

Commit 6a5f306

Browse files
authored
[web] Add dynamic view sizing (v2) (flutter#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 flutter#137444 * Closes flutter/engine#48541 [C++, Objective-C, Java style guides]: https://github.com/flutter/engine/blob/main/CONTRIBUTING.md#style
1 parent 6eb067e commit 6a5f306

8 files changed

Lines changed: 397 additions & 26 deletions

File tree

lib/web_ui/lib/platform_dispatcher.dart

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,8 +90,6 @@ abstract class PlatformDispatcher {
9090

9191
void scheduleFrame();
9292

93-
Future<void> render(Scene scene, [FlutterView view]);
94-
9593
AccessibilityFeatures get accessibilityFeatures;
9694

9795
VoidCallback? get onAccessibilityFeaturesChanged;

lib/web_ui/lib/src/engine/js_interop/js_app.dart

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,38 @@ extension JsFlutterViewOptionsExtension on JsFlutterViewOptions {
2525
return _hostElement!;
2626
}
2727

28+
@JS('viewConstraints')
29+
external JsViewConstraints? get _viewConstraints;
30+
JsViewConstraints? get viewConstraints {
31+
return _viewConstraints;
32+
}
33+
2834
external JSAny? get initialData;
2935
}
3036

37+
/// The JS bindings for a [ViewConstraints] object.
38+
@JS()
39+
@anonymous
40+
@staticInterop
41+
class JsViewConstraints {
42+
external factory JsViewConstraints({
43+
double? minWidth,
44+
double? maxWidth,
45+
double? minHeight,
46+
double? maxHeight,
47+
});
48+
}
49+
50+
/// The attributes of a [JsViewConstraints] object.
51+
///
52+
/// These attributes are expressed in *logical* pixels.
53+
extension JsViewConstraintsExtension on JsViewConstraints {
54+
external double? get maxHeight;
55+
external double? get maxWidth;
56+
external double? get minHeight;
57+
external double? get minWidth;
58+
}
59+
3160
/// The public JS API of a running Flutter Web App.
3261
@JS()
3362
@anonymous

lib/web_ui/lib/src/engine/platform_dispatcher.dart

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -797,27 +797,25 @@ class EnginePlatformDispatcher extends ui.PlatformDispatcher {
797797
/// scheduling of frames.
798798
/// * [RendererBinding], the Flutter framework class which manages layout and
799799
/// painting.
800-
@override
801800
Future<void> render(ui.Scene scene, [ui.FlutterView? view]) async {
802-
assert(view != null || implicitView != null,
803-
'Calling render without a FlutterView');
804-
if (view == null && implicitView == null) {
801+
final EngineFlutterView? target = (view ?? implicitView) as EngineFlutterView?;
802+
assert(target != null, 'Calling render without a FlutterView');
803+
if (target == null) {
805804
// If there is no view to render into, then this is a no-op.
806805
return;
807806
}
808-
final ui.FlutterView viewToRender = view ?? implicitView!;
809807

810808
// Only render in an `onDrawFrame` or `onBeginFrame` scope. This is checked
811809
// by checking if the `_viewsRenderedInCurrentFrame` is non-null and this
812810
// view hasn't been rendered already in this scope.
813811
final bool shouldRender =
814-
_viewsRenderedInCurrentFrame?.add(viewToRender) ?? false;
812+
_viewsRenderedInCurrentFrame?.add(target) ?? false;
815813
// TODO(harryterkelsen): HTML renderer needs to violate the render rule in
816814
// order to perform golden tests in Flutter framework because on the HTML
817815
// renderer, golden tests render to DOM and then take a browser screenshot,
818816
// https://github.com/flutter/flutter/issues/137073.
819817
if (shouldRender || renderer.rendererTag == 'html') {
820-
await renderer.renderScene(scene, viewToRender);
818+
await renderer.renderScene(scene, target);
821819
}
822820
}
823821

lib/web_ui/lib/src/engine/view_embedder/flutter_view_manager.dart

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,11 @@ class FlutterViewManager {
3939
EngineFlutterView createAndRegisterView(
4040
JsFlutterViewOptions jsViewOptions,
4141
) {
42-
final EngineFlutterView view =
43-
EngineFlutterView(_dispatcher, jsViewOptions.hostElement);
42+
final EngineFlutterView view = EngineFlutterView(
43+
_dispatcher,
44+
jsViewOptions.hostElement,
45+
viewConstraints: jsViewOptions.viewConstraints,
46+
);
4447
registerView(view, jsViewOptions: jsViewOptions);
4548
return view;
4649
}

lib/web_ui/lib/src/engine/window.dart

Lines changed: 129 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import 'configuration.dart';
1515
import 'display.dart';
1616
import 'dom.dart';
1717
import 'initialization.dart';
18+
import 'js_interop/js_app.dart';
1819
import 'mouse/context_menu.dart';
1920
import 'mouse/cursor.dart';
2021
import 'navigation/history.dart';
@@ -50,7 +51,9 @@ base class EngineFlutterView implements ui.FlutterView {
5051
/// the Flutter view will be rendered.
5152
factory EngineFlutterView(
5253
EnginePlatformDispatcher platformDispatcher,
53-
DomElement hostElement,
54+
DomElement hostElement, {
55+
JsViewConstraints? viewConstraints,
56+
}
5457
) = _EngineFlutterViewImpl;
5558

5659
EngineFlutterView._(
@@ -59,8 +62,11 @@ base class EngineFlutterView implements ui.FlutterView {
5962
// This is nullable to accommodate the legacy `EngineFlutterWindow`. In
6063
// multi-view mode, the host element is required for each view (as reflected
6164
// by the public `EngineFlutterView` constructor).
62-
DomElement? hostElement,
63-
) : embeddingStrategy = EmbeddingStrategy.create(hostElement: hostElement),
65+
DomElement? hostElement, {
66+
JsViewConstraints? viewConstraints,
67+
}
68+
) : _jsViewConstraints = viewConstraints,
69+
embeddingStrategy = EmbeddingStrategy.create(hostElement: hostElement),
6470
dimensionsProvider = DimensionsProvider.create(hostElement: hostElement) {
6571
// The embeddingStrategy will take care of cleaning up the rootElement on
6672
// hot restart.
@@ -117,7 +123,9 @@ base class EngineFlutterView implements ui.FlutterView {
117123
@override
118124
void render(ui.Scene scene, {ui.Size? size}) {
119125
assert(!isDisposed, 'Trying to render a disposed EngineFlutterView.');
120-
// TODO(goderbauer): Respect the provided size when "physicalConstraints" are not always tight. See TODO on "physicalConstraints".
126+
if (size != null) {
127+
resize(size);
128+
}
121129
platformDispatcher.render(scene, this);
122130
}
123131

@@ -145,9 +153,14 @@ base class EngineFlutterView implements ui.FlutterView {
145153

146154
late final PointerBinding pointerBinding;
147155

148-
// TODO(goderbauer): Provide API to configure constraints. See also TODO in "render".
149156
@override
150-
ViewConstraints get physicalConstraints => ViewConstraints.tight(physicalSize);
157+
ViewConstraints get physicalConstraints {
158+
final double dpr = devicePixelRatio;
159+
final ui.Size currentLogicalSize = physicalSize / dpr;
160+
return ViewConstraints.fromJs(_jsViewConstraints, currentLogicalSize) * dpr;
161+
}
162+
163+
final JsViewConstraints? _jsViewConstraints;
151164

152165
late final EngineSemanticsOwner semantics = EngineSemanticsOwner(dom.semanticsHost);
153166

@@ -156,6 +169,54 @@ base class EngineFlutterView implements ui.FlutterView {
156169
return _physicalSize ??= _computePhysicalSize();
157170
}
158171

172+
/// Resizes the `rootElement` to `newPhysicalSize` by changing its CSS style.
173+
///
174+
/// This is used by the [render] method, when the framework sends new dimensions
175+
/// for the current Flutter View.
176+
///
177+
/// Dimensions from the framework are constrained by the [physicalConstraints]
178+
/// that can be configured by the user when adding a view to the app.
179+
///
180+
/// In practice, this method changes the size of the `rootElement` of the app
181+
/// so it can push/shrink inside its `hostElement`. That way, a Flutter app
182+
/// can change the layout of the container page.
183+
///
184+
/// ```
185+
/// <p>Some HTML content...</p>
186+
/// +--- (div) hostElement ------------------------------------+
187+
/// | +--- rootElement ---------------------+ |
188+
/// | | | |
189+
/// | | | container |
190+
/// | | size applied to *this* | must be able |
191+
/// | | | to reflow |
192+
/// | | | |
193+
/// | +-------------------------------------+ |
194+
/// +----------------------------------------------------------+
195+
/// <p>More HTML content...</p>
196+
/// ```
197+
///
198+
/// The `hostElement` needs to be styled in a way that allows its size to flow
199+
/// with its contents. Things like `max-height: 100px; overflow: hidden` will
200+
/// work as expected (by hiding the overflowing part of the flutter app), but
201+
/// if in that case flutter is not made aware of that max-height with
202+
/// `physicalConstraints`, it will end up rendering more pixels that are visible
203+
/// on the screen, with a possible hit to performance.
204+
///
205+
/// TL;DR: The `viewConstraints` of a Flutter view, must take into consideration
206+
/// the CSS box-model restrictions imposed on its `hostElement` (especially when
207+
/// hiding `overflow`). Flutter does not attempt to interpret the styles of
208+
/// `hostElement` to compute its `physicalConstraints`, only its current size.
209+
void resize(ui.Size newPhysicalSize) {
210+
// The browser uses CSS, and CSS operates in logical sizes.
211+
final ui.Size logicalSize = newPhysicalSize / devicePixelRatio;
212+
dom.rootElement.style
213+
..width = '${logicalSize.width}px'
214+
..height = '${logicalSize.height}px';
215+
216+
// Force an update of the physicalSize so it's ready for the renderer.
217+
_computePhysicalSize();
218+
}
219+
159220
/// Lazily populated and cleared at the end of the frame.
160221
ui.Size? _physicalSize;
161222

@@ -278,8 +339,10 @@ base class EngineFlutterView implements ui.FlutterView {
278339
final class _EngineFlutterViewImpl extends EngineFlutterView {
279340
_EngineFlutterViewImpl(
280341
EnginePlatformDispatcher platformDispatcher,
281-
DomElement hostElement,
282-
) : super._(_nextViewId++, platformDispatcher, hostElement);
342+
DomElement hostElement, {
343+
JsViewConstraints? viewConstraints,
344+
}
345+
) : super._(_nextViewId++, platformDispatcher, hostElement, viewConstraints: viewConstraints);
283346
}
284347

285348
/// The Web implementation of [ui.SingletonFlutterWindow].
@@ -708,6 +771,27 @@ class ViewConstraints implements ui.ViewConstraints {
708771
minHeight = size.height,
709772
maxHeight = size.height;
710773

774+
/// Converts JsViewConstraints into ViewConstraints.
775+
///
776+
/// Since JsViewConstraints are expressed by the user, in logical pixels, this
777+
/// conversion uses logical pixels for the current size as well.
778+
///
779+
/// The resulting ViewConstraints object will be multiplied by devicePixelRatio
780+
/// later to compute the physicalViewConstraints, which is what the framework
781+
/// uses.
782+
factory ViewConstraints.fromJs(
783+
JsViewConstraints? constraints, ui.Size currentLogicalSize) {
784+
if (constraints == null) {
785+
return ViewConstraints.tight(currentLogicalSize);
786+
}
787+
return ViewConstraints(
788+
minWidth: _computeMinConstraintValue(constraints.minWidth, currentLogicalSize.width),
789+
minHeight: _computeMinConstraintValue(constraints.minHeight, currentLogicalSize.height),
790+
maxWidth: _computeMaxConstraintValue(constraints.maxWidth, currentLogicalSize.width),
791+
maxHeight: _computeMaxConstraintValue(constraints.maxHeight, currentLogicalSize.height),
792+
);
793+
}
794+
711795
@override
712796
final double minWidth;
713797
@override
@@ -726,6 +810,15 @@ class ViewConstraints implements ui.ViewConstraints {
726810
@override
727811
bool get isTight => minWidth >= maxWidth && minHeight >= maxHeight;
728812

813+
ViewConstraints operator*(double factor) {
814+
return ViewConstraints(
815+
minWidth: minWidth * factor,
816+
maxWidth: maxWidth * factor,
817+
minHeight: minHeight * factor,
818+
maxHeight: maxHeight * factor,
819+
);
820+
}
821+
729822
@override
730823
ViewConstraints operator/(double factor) {
731824
return ViewConstraints(
@@ -774,3 +867,31 @@ class ViewConstraints implements ui.ViewConstraints {
774867
return 'ViewConstraints($width, $height)';
775868
}
776869
}
870+
871+
// Computes the "min" value for a constraint that takes into account user `desired`
872+
// configuration and the actual available value.
873+
//
874+
// Returns the `desired` value unless it is `null`, in which case it returns the
875+
// `available` value.
876+
double _computeMinConstraintValue(double? desired, double available) {
877+
assert(desired == null || desired >= 0, 'Minimum constraint must be >= 0 if set.');
878+
assert(desired == null || desired.isFinite, 'Minimum constraint must be finite.');
879+
return desired ?? available;
880+
}
881+
882+
// Computes the "max" value for a constraint that takes into account user `desired`
883+
// configuration and the `available` size.
884+
//
885+
// Returns the `desired` value unless it is `null`, in which case it returns the
886+
// `available` value.
887+
//
888+
// A `desired` value of `Infinity` or `Number.POSITIVE_INFINITY` (from JS) means
889+
// "unconstrained".
890+
//
891+
// This method allows returning values larger than `available`, so the Flutter
892+
// app is able to stretch its container up to a certain value, without being
893+
// fully unconstrained.
894+
double _computeMaxConstraintValue(double? desired, double available) {
895+
assert(desired == null || desired >= 0, 'Maximum constraint must be >= 0 if set.');
896+
return desired ?? available;
897+
}

lib/web_ui/test/common/frame_timings_common.dart

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,36 +5,39 @@
55
import 'dart:async';
66

77
import 'package:test/test.dart';
8+
import 'package:ui/src/engine.dart' show EnginePlatformDispatcher;
89
import 'package:ui/ui.dart' as ui;
910

1011
/// Tests frame timings in a renderer-agnostic way.
1112
///
1213
/// See CanvasKit-specific and HTML-specific test files `frame_timings_test.dart`.
1314
Future<void> runFrameTimingsTest() async {
15+
final EnginePlatformDispatcher dispatcher = ui.PlatformDispatcher.instance as EnginePlatformDispatcher;
16+
1417
List<ui.FrameTiming>? timings;
15-
ui.PlatformDispatcher.instance.onReportTimings = (List<ui.FrameTiming> data) {
18+
dispatcher.onReportTimings = (List<ui.FrameTiming> data) {
1619
timings = data;
1720
};
1821
Completer<void> frameDone = Completer<void>();
19-
ui.PlatformDispatcher.instance.onDrawFrame = () {
22+
dispatcher.onDrawFrame = () {
2023
final ui.SceneBuilder sceneBuilder = ui.SceneBuilder();
2124
sceneBuilder
2225
..pushOffset(0, 0)
2326
..pop();
24-
ui.PlatformDispatcher.instance.render(sceneBuilder.build()).then((_) {
27+
dispatcher.render(sceneBuilder.build()).then((_) {
2528
frameDone.complete();
2629
});
2730
};
2831

2932
// Frame 1.
30-
ui.PlatformDispatcher.instance.scheduleFrame();
33+
dispatcher.scheduleFrame();
3134
await frameDone.future;
3235
expect(timings, isNull, reason: "100 ms hasn't passed yet");
3336
await Future<void>.delayed(const Duration(milliseconds: 150));
3437

3538
// Frame 2.
3639
frameDone = Completer<void>();
37-
ui.PlatformDispatcher.instance.scheduleFrame();
40+
dispatcher.scheduleFrame();
3841
await frameDone.future;
3942
expect(timings, hasLength(2), reason: '100 ms passed. 2 frames pumped.');
4043
for (final ui.FrameTiming timing in timings!) {

0 commit comments

Comments
 (0)