[web] Avoid absolute positioning for base CanvasKit canvas (#188337)
fix for #176327
Partial fix for #181511
**Problem**
With CanvasKit and semantics enabled, scrolling regular Flutter Web
content on iPhone WebKit browsers can be very janky. The base CanvasKit
canvas was always styled with `position:absolute`, which is expensive
for WebKit to scroll together with the semantics DOM.
**Fix**
Only platform-view overlay canvases keep `position:absolute`. The normal
base CanvasKit canvas now uses regular positioning, which fixes the
plain Flutter scroll case while preserving `HtmlElementView` stacking.
One limitation: mixed Flutter and `HtmlElementView` scenes may still
show iOS WebKit scroll jank because platform-view overlays still require
`position:absolute`.
**Demo**
| Before | After |
|---|---|
| https://flutter-demo-09-before.web.app |
https://flutter-demo-09-after.web.app |
Steps: open the page on iPhone Chrome or Safari, keep semantics enabled,
then scroll the page continuously.
Before: the on-screen jank log shows severe scroll jank, including runs
over 100 janky frames per 5 seconds.
After: the same regular Flutter content stays smooth, with low jank
summaries around 1 to 10 janky frames per 5 seconds in validation runs.
diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/surface.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/surface.dart
index f6b6d2f..ab26a7a 100644
--- a/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/surface.dart
+++ b/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/surface.dart
@@ -392,6 +392,18 @@
}
@override
+ void setIsOverlay(bool isOverlay) {
+ final DomCSSStyleDeclaration style = (canvas as DomHTMLCanvasElement).style;
+ if (isOverlay) {
+ if (style.position != 'absolute') {
+ style.position = 'absolute';
+ }
+ } else if (style.position == 'absolute') {
+ style.removeProperty('position');
+ }
+ }
+
+ @override
Future<void> triggerContextLoss() async {
_handledContextLostEvent = Completer<void>();
final WebGLContext gl = (canvas as DomHTMLCanvasElement).getGlContext(webGLVersion);
diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/compositing/canvas_provider.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/compositing/canvas_provider.dart
index b9f55e0..f48fa59 100644
--- a/engine/src/flutter/lib/web_ui/lib/src/engine/compositing/canvas_provider.dart
+++ b/engine/src/flutter/lib/web_ui/lib/src/engine/compositing/canvas_provider.dart
@@ -105,7 +105,6 @@
final cssHeight = '${size.height / ratio}px';
canvas.style
..width = cssWidth
- ..height = cssHeight
- ..position = 'absolute';
+ ..height = cssHeight;
}
}
diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/compositing/rasterizer.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/compositing/rasterizer.dart
index 8ee65ca..4a7f35c 100644
--- a/engine/src/flutter/lib/web_ui/lib/src/engine/compositing/rasterizer.dart
+++ b/engine/src/flutter/lib/web_ui/lib/src/engine/compositing/rasterizer.dart
@@ -164,6 +164,9 @@
/// Initialize the overlay.
void initialize();
+ /// Marks whether this canvas is used as an overlay between platform views.
+ void setIsOverlay(bool isOverlay) {}
+
/// Disposes this overlay.
void dispose();
}
diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/platform_views/embedder.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/platform_views/embedder.dart
index 7cdb059..dddbb02 100644
--- a/engine/src/flutter/lib/web_ui/lib/src/engine/platform_views/embedder.dart
+++ b/engine/src/flutter/lib/web_ui/lib/src/engine/platform_views/embedder.dart
@@ -454,6 +454,19 @@
// The composition has not changed, so no DOM manipulation is needed.
return;
}
+ // A canvas needs to be stacked as an overlay (and thus styled with
+ // `position: absolute`) whenever it shares the composition with a
+ // platform view, since the platform view's DOM element sits between
+ // canvases in the stacking order. It also needs overlay positioning
+ // whenever there's more than one canvas, even without platform views,
+ // since multiple canvases need to occupy the same area without
+ // affecting each other's layout. A single canvas with no platform
+ // views can use normal positioning, which is cheaper for the browser
+ // to scroll.
+ final bool hasPlatformViews = composition.entities.any(
+ (CompositionEntity entity) => entity is CompositionPlatformView,
+ );
+ final bool needsOverlayPositioning = hasPlatformViews || composition.canvases.length > 1;
final List<int> indexMap = _getIndexMapFromPreviousComposition(_activeComposition, composition);
final List<int> existingIndexMap = indexMap.where((int index) => index != -1).toList();
@@ -501,6 +514,7 @@
// canvas needs a display canvas.
canvas.displayCanvas = rasterizer.getOverlay();
}
+ canvas.displayCanvas!.setIsOverlay(needsOverlayPositioning);
}
// At this point, the DOM contains the static elements and the elements from
diff --git a/engine/src/flutter/lib/web_ui/test/ui/platform_view_position_test.dart b/engine/src/flutter/lib/web_ui/test/ui/platform_view_position_test.dart
index f01ff23..170c4c7 100644
--- a/engine/src/flutter/lib/web_ui/test/ui/platform_view_position_test.dart
+++ b/engine/src/flutter/lib/web_ui/test/ui/platform_view_position_test.dart
@@ -22,7 +22,48 @@
Future<void> testMain() async {
setUpUnitTests(withImplicitView: true, setUpTestViewDimensions: false);
- test('Onscreen canvas has position: absolute', () async {
+ test('base onscreen canvas is not positioned as an overlay', () async {
+ if (!isCanvasKit) {
+ return;
+ }
+
+ // Force multi-surface mode to ensure OnscreenCanvasProvider is used.
+ engine.debugOverrideJsConfiguration(
+ <String, Object?>{'canvasKitForceMultiSurfaceRasterizer': true}.jsify()
+ as engine.JsFlutterConfiguration?,
+ );
+ // Reset the renderer to ensure it is created with the new configuration.
+ engine.renderer.debugResetRasterizer();
+ engine.renderer.debugClear();
+
+ // Create a scene with only Flutter content. This uses the base canvas, not
+ // an overlay canvas interleaved with platform views.
+ final recorder = ui.PictureRecorder();
+ final canvas = ui.Canvas(recorder);
+ canvas.drawRect(
+ const ui.Rect.fromLTWH(0, 0, 100, 100),
+ ui.Paint()..color = const ui.Color(0xFFFF0000),
+ );
+
+ final sb = ui.SceneBuilder();
+ sb.addPicture(ui.Offset.zero, recorder.endRecording());
+
+ await renderScene(sb.build());
+
+ final DomElement canvasElement = (implicitView as engine.EngineFlutterView).dom.sceneHost
+ .querySelectorAll('canvas')
+ .single;
+
+ expect(
+ canvasElement.style.position,
+ isNot('absolute'),
+ reason: 'Base canvas should not be styled as a platform-view overlay.',
+ );
+
+ engine.debugOverrideJsConfiguration(null);
+ });
+
+ test('Onscreen overlay canvas has position: absolute', () async {
// Force multi-surface mode to ensure OnscreenCanvasProvider is used.
engine.debugOverrideJsConfiguration(
<String, Object?>{'canvasKitForceMultiSurfaceRasterizer': true}.jsify()
@@ -57,16 +98,14 @@
await renderScene(sb.build());
- // Find the canvas element.
final DomElement canvasElement = (implicitView as engine.EngineFlutterView).dom.sceneHost
.querySelectorAll('canvas')
.single;
- // Verify position is absolute.
expect(
canvasElement.style.position,
'absolute',
- reason: 'Canvas should have position: absolute',
+ reason: 'Overlay canvas should have position: absolute.',
);
engine.debugOverrideJsConfiguration(null);