Skip to content
This repository was archived by the owner on Feb 25, 2025. It is now read-only.

[web] Fix Scene clip bounds. Trigger resize on DPR Change. #50161

Merged
merged 12 commits into from
Feb 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions ci/licenses_golden/licenses_flutter
Original file line number Diff line number Diff line change
Expand Up @@ -6136,6 +6136,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
Expand Down Expand Up @@ -9001,6 +9002,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
Expand Down
1 change: 1 addition & 0 deletions lib/web_ui/lib/src/engine.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
12 changes: 9 additions & 3 deletions lib/web_ui/lib/src/engine/html/scene.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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(() {
Expand All @@ -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 =
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we also change the type to StreamController<void> or StreamController<Null>?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nope, I really really want this to be a StreamController<ui.Size>, it's just that we don't know how to implement it efficiently now :P

StreamController<ui.Size?>.broadcast();

// Broadcasts the last seen `Size`.
void _broadcastSize(ui.Size size) {
void _broadcastSize(ui.Size? size) {
_onResizeStreamController.add(size);
}

Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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
Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;

Expand Down
93 changes: 93 additions & 0 deletions lib/web_ui/lib/src/engine/view_embedder/display_dpr_stream.dart
Original file line number Diff line number Diff line change
@@ -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);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want to continue with the singleton pattern? I was under the impression that we were removing all singletons except one top-level class (was it EnginePlatformDispatcher?), which would do a kind of dependency injection.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, we're still using the singleton pattern for things that are ONE only (browser globals). devicePixelRatio is determined by the browser window. This is similar to the "brightness/dark mode" detector, and implemented in an almost identical way.

(I tried to make this instance-based (by passing a Display), hoping that this API1 will be eventually usable, but it's still unclear to me from the docs how it can be used to monitor DPR changes, though!)

Footnotes

  1. https://developer.mozilla.org/en-US/docs/Web/API/Window_Management_API

Copy link
Contributor

@mdebbar mdebbar Feb 5, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In one of our recent-ish discussions, we entertained the idea of keeping only ONE singleton in the engine (e.g. EnginePlatformDispatcher), and make all other singletons a member of this ONE singleton.

E.g. instead of DisplayDprStream.instance, we would have EnginePlatformDispatcher.instance.displayDprStream.

It was just a discussion though. We haven't made any real changes in the code base following that discussion. But I think at some point we should start moving to that pattern.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, I understand now! Yes, I could move this to a singleton of the EnginePlatformDispatcher instead of being a loose object.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the litmus test is that code that consumes an external service should not go through a static to get a reference to that service. EnginePlatformDispatcher.instance.displayDprStream still accesses DisplayDprStream through a static getter. In fact, it may be worse, the code only needed a DisplayDprStream but it depends on EnginePlatformDispatcher, which vends more dependencies than it needs. It still has the same testability issues, where to mock out DisplayDprStream you need to mock out the EnginePlatformDispatcher.instance static getter.

I don't insist that this is fixed in this PR, unless we already switched modes to a singleton-free design.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I changed this a little bit. The CustomElement dimensions provider now just requires a Stream passed by its constructor. For now, the abstract factory class of DimensionsProviders knows where to get that stream from (the singleton instance), but it's very likely that it could just be created from there.

Anyway, I'm going to autosubmit this, and if you find anything egregious, feel free to raise an issue or ping me so I can address it ASAP.


// 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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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);
});
});
}
Loading