[web] Fix Scene clip bounds. Trigger resize on DPR Change. (flutter/engine#50161)

The Scene of the HTML renderer is passing incorrect size information to the engine, and when DPR<1, it can result in elements being culled off of the viewport.

In addition to that, when an app is embedded, not all changes in DPR cause a Resize event (only those some of the dimensions fails by a rounding error!), so this PR ensures that all DPR events in embedded trigger a resize event.

### Issues

Fixes https://github.com/flutter/flutter/issues/129182

### Testing

Looking good at: https://dit-astral-test.web.app

[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-05 17:48:00 -08:00
committed by GitHub
parent db877dd92a
commit 76129ffb8f
11 changed files with 226 additions and 48 deletions

View File

@@ -6134,6 +6134,7 @@ ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/vector_math.dart + ../../../f
ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/view_embedder/dimensions_provider/custom_element_dimensions_provider.dart + ../../../flutter/LICENSE
ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/view_embedder/dimensions_provider/dimensions_provider.dart + ../../../flutter/LICENSE
ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/view_embedder/dimensions_provider/full_page_dimensions_provider.dart + ../../../flutter/LICENSE
ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/view_embedder/display_dpr_stream.dart + ../../../flutter/LICENSE
ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/view_embedder/dom_manager.dart + ../../../flutter/LICENSE
ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/view_embedder/embedding_strategy/custom_element_embedding_strategy.dart + ../../../flutter/LICENSE
ORIGIN: ../../../flutter/lib/web_ui/lib/src/engine/view_embedder/embedding_strategy/embedding_strategy.dart + ../../../flutter/LICENSE
@@ -8997,6 +8998,7 @@ FILE: ../../../flutter/lib/web_ui/lib/src/engine/vector_math.dart
FILE: ../../../flutter/lib/web_ui/lib/src/engine/view_embedder/dimensions_provider/custom_element_dimensions_provider.dart
FILE: ../../../flutter/lib/web_ui/lib/src/engine/view_embedder/dimensions_provider/dimensions_provider.dart
FILE: ../../../flutter/lib/web_ui/lib/src/engine/view_embedder/dimensions_provider/full_page_dimensions_provider.dart
FILE: ../../../flutter/lib/web_ui/lib/src/engine/view_embedder/display_dpr_stream.dart
FILE: ../../../flutter/lib/web_ui/lib/src/engine/view_embedder/dom_manager.dart
FILE: ../../../flutter/lib/web_ui/lib/src/engine/view_embedder/embedding_strategy/custom_element_embedding_strategy.dart
FILE: ../../../flutter/lib/web_ui/lib/src/engine/view_embedder/embedding_strategy/embedding_strategy.dart

View File

@@ -190,6 +190,7 @@ export 'engine/vector_math.dart';
export 'engine/view_embedder/dimensions_provider/custom_element_dimensions_provider.dart';
export 'engine/view_embedder/dimensions_provider/dimensions_provider.dart';
export 'engine/view_embedder/dimensions_provider/full_page_dimensions_provider.dart';
export 'engine/view_embedder/display_dpr_stream.dart';
export 'engine/view_embedder/dom_manager.dart';
export 'engine/view_embedder/embedding_strategy/custom_element_embedding_strategy.dart';
export 'engine/view_embedder/embedding_strategy/embedding_strategy.dart';

View File

@@ -45,9 +45,15 @@ class PersistedScene extends PersistedContainerSurface {
@override
void recomputeTransformAndClip() {
// The scene clip is the size of the entire window.
final ui.Size screen = window.physicalSize;
localClipBounds = ui.Rect.fromLTRB(0, 0, screen.width, screen.height);
// The scene clip is the size of the entire window **in Logical pixels**.
//
// Even though the majority of the engine uses `physicalSize`, there are some
// bits (like the HTML renderer, or dynamic view sizing) that are implemented
// using CSS, and CSS operates in logical pixels.
//
// See also: [EngineFlutterView.resize].
final ui.Size bounds = window.physicalSize / window.devicePixelRatio;
localClipBounds = ui.Rect.fromLTRB(0, 0, bounds.width, bounds.height);
projectedClip = null;
}

View File

@@ -4,6 +4,7 @@
import 'dart:async';
import 'package:ui/src/engine/display.dart';
import 'package:ui/src/engine/dom.dart';
import 'package:ui/src/engine/window.dart';
import 'package:ui/ui.dart' as ui show Size;
@@ -12,21 +13,36 @@ import 'dimensions_provider.dart';
/// This class provides observable, real-time dimensions of a host element.
///
/// This class needs a `Stream` of `devicePixelRatio` changes, like the one
/// provided by [DisplayDprStream], because html resize observers do not report
/// DPR changes.
///
/// All the measurements returned from this class are potentially *expensive*,
/// and should be cached as needed. Every call to every method on this class
/// WILL perform actual DOM measurements.
///
/// This broadcasts `null` size events, to match the implementation of the
/// FullPageDimensionsProvider, but it could broadcast the size coming from the
/// DomResizeObserverEntry. Further changes in the engine are required for this
/// to be effective.
class CustomElementDimensionsProvider extends DimensionsProvider {
/// Creates a [CustomElementDimensionsProvider] from a [_hostElement].
CustomElementDimensionsProvider(this._hostElement) {
CustomElementDimensionsProvider(this._hostElement, {
Stream<double>? onDprChange,
}) {
// Send a resize event when the page DPR changes.
_dprChangeStreamSubscription = onDprChange?.listen((_) {
_broadcastSize(null);
});
// Hook up a resize observer on the hostElement (if supported!).
_hostElementResizeObserver = createDomResizeObserver((
List<DomResizeObserverEntry> entries,
DomResizeObserver _,
) {
entries
.map((DomResizeObserverEntry entry) =>
ui.Size(entry.contentRect.width, entry.contentRect.height))
.forEach(_broadcastSize);
for (final DomResizeObserverEntry _ in entries) {
_broadcastSize(null);
}
});
assert(() {
@@ -45,11 +61,12 @@ class CustomElementDimensionsProvider extends DimensionsProvider {
// Handle resize events
late DomResizeObserver? _hostElementResizeObserver;
final StreamController<ui.Size> _onResizeStreamController =
StreamController<ui.Size>.broadcast();
late StreamSubscription<double>? _dprChangeStreamSubscription;
final StreamController<ui.Size?> _onResizeStreamController =
StreamController<ui.Size?>.broadcast();
// Broadcasts the last seen `Size`.
void _broadcastSize(ui.Size size) {
void _broadcastSize(ui.Size? size) {
_onResizeStreamController.add(size);
}
@@ -58,16 +75,17 @@ class CustomElementDimensionsProvider extends DimensionsProvider {
super.close();
_hostElementResizeObserver?.disconnect();
// ignore:unawaited_futures
_dprChangeStreamSubscription?.cancel();
// ignore:unawaited_futures
_onResizeStreamController.close();
}
@override
Stream<ui.Size> get onResize => _onResizeStreamController.stream;
Stream<ui.Size?> get onResize => _onResizeStreamController.stream;
@override
ui.Size computePhysicalSize() {
final double devicePixelRatio = getDevicePixelRatio();
final double devicePixelRatio = EngineFlutterDisplay.instance.devicePixelRatio;
return ui.Size(
_hostElement.clientWidth * devicePixelRatio,
_hostElement.clientHeight * devicePixelRatio,

View File

@@ -5,11 +5,11 @@
import 'dart:async';
import 'package:meta/meta.dart';
import 'package:ui/src/engine/dom.dart';
import 'package:ui/src/engine/view_embedder/display_dpr_stream.dart';
import 'package:ui/src/engine/window.dart';
import 'package:ui/ui.dart' as ui show Size;
import '../../display.dart';
import '../../dom.dart';
import 'custom_element_dimensions_provider.dart';
import 'full_page_dimensions_provider.dart';
@@ -32,18 +32,15 @@ abstract class DimensionsProvider {
/// Creates the appropriate DimensionsProvider depending on the incoming [hostElement].
factory DimensionsProvider.create({DomElement? hostElement}) {
if (hostElement != null) {
return CustomElementDimensionsProvider(hostElement);
return CustomElementDimensionsProvider(
hostElement,
onDprChange: DisplayDprStream.instance.dprChanged,
);
} else {
return FullPageDimensionsProvider();
}
}
/// Returns the DPI reported by the browser.
double getDevicePixelRatio() {
// This is overridable in tests.
return EngineFlutterDisplay.instance.devicePixelRatio;
}
/// Returns the [ui.Size] of the "viewport".
///
/// This function is expensive. It triggers browser layout if there are
@@ -57,6 +54,16 @@ abstract class DimensionsProvider {
);
/// Returns a Stream with the changes to [ui.Size] (when cheap to get).
///
/// Currently this Stream always returns `null` measurements because the
/// resize event that we use for [FullPageDimensionsProvider] does not contain
/// the new size, so users of this Stream everywhere immediately retrieve the
/// new `physicalSize` from the window.
///
/// The [CustomElementDimensionsProvider] *could* broadcast the new size, but
/// to keep both implementations consistent (and their consumers), for now all
/// events from this Stream are going to be `null` (until we find a performant
/// way to retrieve the dimensions in full-page mode).
Stream<ui.Size?> get onResize;
/// Whether the [DimensionsProvider] instance has been closed or not.

View File

@@ -5,6 +5,7 @@
import 'dart:async';
import 'package:ui/src/engine/browser_detection.dart';
import 'package:ui/src/engine/display.dart';
import 'package:ui/src/engine/dom.dart';
import 'package:ui/src/engine/window.dart';
import 'package:ui/ui.dart' as ui show Size;
@@ -67,7 +68,7 @@ class FullPageDimensionsProvider extends DimensionsProvider {
late double windowInnerWidth;
late double windowInnerHeight;
final DomVisualViewport? viewport = domWindow.visualViewport;
final double devicePixelRatio = getDevicePixelRatio();
final double devicePixelRatio = EngineFlutterDisplay.instance.devicePixelRatio;
if (viewport != null) {
if (operatingSystem == OperatingSystem.iOs) {
@@ -102,7 +103,7 @@ class FullPageDimensionsProvider extends DimensionsProvider {
double physicalHeight,
bool isEditingOnMobile,
) {
final double devicePixelRatio = getDevicePixelRatio();
final double devicePixelRatio = EngineFlutterDisplay.instance.devicePixelRatio;
final DomVisualViewport? viewport = domWindow.visualViewport;
late double windowInnerHeight;

View File

@@ -0,0 +1,93 @@
// 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 'dart:js_interop';
import 'package:meta/meta.dart';
import 'package:ui/src/engine/display.dart';
import 'package:ui/src/engine/dom.dart';
import 'package:ui/ui.dart' as ui show Display;
/// Provides a stream of `devicePixelRatio` changes for the given display.
///
/// Note that until the Window Management API is generally available, this class
/// only monitors the global `devicePixelRatio` property, provided by the default
/// [EngineFlutterDisplay.instance].
///
/// See: https://developer.mozilla.org/en-US/docs/Web/API/Window_Management_API
class DisplayDprStream {
DisplayDprStream(
this._display, {
@visibleForTesting DebugDisplayDprStreamOverrides? overrides,
}) : _currentDpr = _display.devicePixelRatio,
_debugOverrides = overrides {
// Start listening to DPR changes.
_subscribeToMediaQuery();
}
/// A singleton instance of DisplayDprStream.
static DisplayDprStream instance =
DisplayDprStream(EngineFlutterDisplay.instance);
// The display object that will provide the DPR information.
final ui.Display _display;
// Last reported value of DPR.
double _currentDpr;
// Controls the [dprChanged] broadcast Stream.
final StreamController<double> _dprStreamController =
StreamController<double>.broadcast();
// Object that fires a `change` event for the `_currentDpr`.
late DomEventTarget _dprMediaQuery;
// Creates the media query for the latest known DPR value, and adds a change listener to it.
void _subscribeToMediaQuery() {
if (_debugOverrides?.getMediaQuery != null) {
_dprMediaQuery = _debugOverrides!.getMediaQuery!(_currentDpr);
} else {
_dprMediaQuery = domWindow.matchMedia('(resolution: ${_currentDpr}dppx)');
}
_dprMediaQuery.addEventListenerWithOptions(
'change',
createDomEventListener(_onDprMediaQueryChange),
<String, Object>{
// We only listen `once` because this event only triggers once when the
// DPR changes from `_currentDpr`. Once that happens, we need a new
// `_dprMediaQuery` that is watching the new `_currentDpr`.
//
// By using `once`, we don't need to worry about detaching the event
// listener from the old mediaQuery after we're done with it.
'once': true,
'passive': true,
},
);
}
// Handler of the _dprMediaQuery 'change' event.
//
// This calls subscribe again because events are listened to with `once: true`.
JSVoid _onDprMediaQueryChange(DomEvent _) {
_currentDpr = _display.devicePixelRatio;
_dprStreamController.add(_currentDpr);
// Re-subscribe...
_subscribeToMediaQuery();
}
/// A stream that emits the latest value of [EngineFlutterDisplay.instance.devicePixelRatio].
Stream<double> get dprChanged => _dprStreamController.stream;
// The overrides object that is used for testing.
final DebugDisplayDprStreamOverrides? _debugOverrides;
}
@visibleForTesting
class DebugDisplayDprStreamOverrides {
DebugDisplayDprStreamOverrides({
this.getMediaQuery,
});
final DomEventTarget Function(double currentValue)? getMediaQuery;
}

View File

@@ -2,9 +2,6 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
@TestOn('browser')
library;
import 'dart:async';
import 'package:test/bootstrap/browser.dart';
@@ -109,23 +106,48 @@ void doTests() {
});
test('funnels resize events on sizeSource', () async {
EngineFlutterDisplay.instance.debugOverrideDevicePixelRatio(2.7);
sizeSource
..style.width = '100px'
..style.height = '100px';
expect(await provider.onResize.first, const ui.Size(100, 100));
expect(provider.onResize.first, completes);
expect(provider.computePhysicalSize(), const ui.Size(270, 270));
sizeSource
..style.width = '200px'
..style.height = '200px';
expect(await provider.onResize.first, const ui.Size(200, 200));
expect(provider.onResize.first, completes);
expect(provider.computePhysicalSize(), const ui.Size(540, 540));
sizeSource
..style.width = '300px'
..style.height = '300px';
expect(await provider.onResize.first, const ui.Size(300, 300));
expect(provider.onResize.first, completes);
expect(provider.computePhysicalSize(), const ui.Size(810, 810));
});
test('funnels DPR change events too', () async {
// Override the source of DPR events...
final StreamController<double> dprController =
StreamController<double>.broadcast();
// Inject the dprController stream into the CustomElementDimensionsProvider.
final CustomElementDimensionsProvider provider =
CustomElementDimensionsProvider(
sizeSource,
onDprChange: dprController.stream,
);
// Set and broadcast the mock DPR value
EngineFlutterDisplay.instance.debugOverrideDevicePixelRatio(3.2);
dprController.add(3.2);
expect(provider.onResize.first, completes);
expect(provider.computePhysicalSize(), const ui.Size(32, 32));
});
test('closed by onHotRestart', () async {

View File

@@ -2,9 +2,6 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
@TestOn('browser')
library;
import 'package:test/bootstrap/browser.dart';
import 'package:test/test.dart';
import 'package:ui/src/engine.dart';
@@ -31,15 +28,4 @@ void doTests() {
expect(provider, isA<CustomElementDimensionsProvider>());
});
});
group('getDevicePixelRatio', () {
test('Returns the correct pixelRatio', () async {
// Override the DPI to something known, but weird...
EngineFlutterDisplay.instance.debugOverrideDevicePixelRatio(33930);
final DimensionsProvider provider = DimensionsProvider.create();
expect(provider.getDevicePixelRatio(), 33930);
});
});
}

View File

@@ -2,9 +2,6 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
@TestOn('browser')
library;
import 'dart:async';
import 'package:test/bootstrap/browser.dart';

View File

@@ -0,0 +1,45 @@
// 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';
void main() {
internalBootstrapBrowserTest(() => doTests);
}
void doTests() {
final DomEventTarget eventTarget = createDomElement('div');
group('dprChanged Stream', () {
late DisplayDprStream dprStream;
setUp(() async {
dprStream = DisplayDprStream(EngineFlutterDisplay.instance,
overrides: DebugDisplayDprStreamOverrides(
getMediaQuery: (_) => eventTarget,
));
});
test('funnels display DPR on every mediaQuery "change" event.', () async {
final Future<List<double>> dprs = dprStream.dprChanged
.take(3)
.timeout(const Duration(seconds: 1))
.toList();
// Simulate the events
EngineFlutterDisplay.instance.debugOverrideDevicePixelRatio(6.9);
eventTarget.dispatchEvent(createDomEvent('Event', 'change'));
EngineFlutterDisplay.instance.debugOverrideDevicePixelRatio(4.2);
eventTarget.dispatchEvent(createDomEvent('Event', 'change'));
EngineFlutterDisplay.instance.debugOverrideDevicePixelRatio(0.71);
eventTarget.dispatchEvent(createDomEvent('Event', 'change'));
expect(await dprs, <double>[6.9, 4.2, 0.71]);
});
});
}