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

Commit 56b99a6

Browse files
Casey Hillersyjbanov
andauthored
[flutter_releases] Cherrypick web engine accessibility and canvas fixes (#32867)
* [web] do not allocate canvases just for text (#30804) * [web:a11y] fix traversal and hit-test orders (#32712) * [web:a11y] implement traversal and hit-test orders * remove unused intersectionIndicesNew * canvaskit: fix platform view offset and scene host initialization * remove "we" in a bunch of comments Co-authored-by: Yegor <[email protected]>
1 parent 6e9953c commit 56b99a6

17 files changed

+916
-337
lines changed

lib/web_ui/lib/src/engine/canvas_pool.dart

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -76,13 +76,6 @@ class CanvasPool extends _SaveStackTracking {
7676
translate(transform.dx, transform.dy);
7777
}
7878

79-
/// Returns true if no canvas has been allocated yet.
80-
bool get isEmpty => _canvas == null;
81-
82-
/// Returns true if a canvas has been allocated for use.
83-
bool get isNotEmpty => _canvas != null;
84-
85-
8679
/// Returns [CanvasRenderingContext2D] api to draw into this canvas.
8780
html.CanvasRenderingContext2D get context {
8881
html.CanvasRenderingContext2D? ctx = _context;
@@ -106,12 +99,28 @@ class CanvasPool extends _SaveStackTracking {
10699
return _contextHandle!;
107100
}
108101

109-
/// Prevents active canvas to be used for rendering and prepares a new
110-
/// canvas allocation on next drawing request that will require one.
102+
/// Returns true if a canvas is currently available for drawing.
103+
///
104+
/// Calling [contextHandle] or, transitively, any of the `draw*` methods while
105+
/// this returns true will reuse the existing canvas. Otherwise, a new canvas
106+
/// will be allocated.
107+
///
108+
/// Previously allocated and closed canvases (see [closeCanvas]) are not
109+
/// considered by this getter.
110+
bool get hasCanvas => _canvas != null;
111+
112+
/// Stops the currently available canvas from receiving any further drawing
113+
/// commands.
114+
///
115+
/// After calling this method, a subsequent call to [contextHandle] or,
116+
/// transitively, any of the `draw*` methods will cause a new canvas to be
117+
/// allocated.
111118
///
112-
/// Saves current canvas so we can dispose
113-
/// and replay the clip/transform stack on top of new canvas.
114-
void closeCurrentCanvas() {
119+
/// The closed canvas becomes an "active" canvas, that is a canvas that's used
120+
/// to render picture content in the current frame. Active canvases may be
121+
/// reused in other pictures if their contents are no longer needed for this
122+
/// picture.
123+
void closeCanvas() {
115124
assert(_rootElement != null);
116125
// Place clean copy of current canvas with context stack restored and paint
117126
// reset into pool.

lib/web_ui/lib/src/engine/canvaskit/embedded_views.dart

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,7 @@ class HtmlViewEmbedder {
242242
}
243243

244244
// Apply mutators to the slot
245-
_applyMutators(params.mutators, slot, viewId);
245+
_applyMutators(params, slot, viewId);
246246
}
247247

248248
int _countClips(MutatorsStack mutators) {
@@ -309,9 +309,12 @@ class HtmlViewEmbedder {
309309
}
310310

311311
void _applyMutators(
312-
MutatorsStack mutators, html.Element embeddedView, int viewId) {
312+
EmbeddedViewParams params, html.Element embeddedView, int viewId) {
313+
final MutatorsStack mutators = params.mutators;
313314
html.Element head = embeddedView;
314-
Matrix4 headTransform = Matrix4.identity();
315+
Matrix4 headTransform = params.offset == ui.Offset.zero
316+
? Matrix4.identity()
317+
: Matrix4.translationValues(params.offset.dx, params.offset.dy, 0);
315318
double embeddedOpacity = 1.0;
316319
_resetAnchor(head);
317320
_cleanUpClipDefs(viewId);

lib/web_ui/lib/src/engine/canvaskit/surface.dart

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,18 @@ class Surface {
282282
height: _pixelHeight,
283283
);
284284
this.htmlCanvas = htmlCanvas;
285+
286+
// The DOM elements used to render pictures are used purely to put pixels on
287+
// the screen. They have no semantic information. If an assistive technology
288+
// attempts to scan picture content it will look like garbage and confuse
289+
// users. UI semantics are exported as a separate DOM tree rendered parallel
290+
// to pictures.
291+
//
292+
// Why are layer and scene elements not hidden from ARIA? Because those
293+
// elements may contain platform views, and platform views must be
294+
// accessible.
295+
htmlCanvas.setAttribute('aria-hidden', 'true');
296+
285297
htmlCanvas.style.position = 'absolute';
286298
_updateLogicalHtmlCanvasSize();
287299

lib/web_ui/lib/src/engine/embedder.dart

Lines changed: 49 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,15 @@ import 'window.dart';
3737
/// - [semanticsHostElement], hosts the ARIA-annotated semantics tree.
3838
class FlutterViewEmbedder {
3939
FlutterViewEmbedder() {
40-
reset();
4140
assert(() {
4241
_setupHotRestart();
4342
return true;
4443
}());
44+
reset();
45+
assert(() {
46+
_registerHotRestartCleanUp();
47+
return true;
48+
}());
4549
}
4650

4751
// The tag name for the root view of the flutter app (glass-pane)
@@ -83,7 +87,7 @@ class FlutterViewEmbedder {
8387
/// This element is created and inserted in the HTML DOM once. It is never
8488
/// removed or moved.
8589
///
86-
/// We render semantics inside the glasspane for proper focus and event
90+
/// Render semantics inside the glasspane for proper focus and event
8791
/// handling. If semantics is behind the glasspane, the phone will disable
8892
/// focusing by touch, only by tabbing around the UI. If semantics is in
8993
/// front of glasspane, then DOM event won't bubble up to the glasspane so
@@ -99,11 +103,15 @@ class FlutterViewEmbedder {
99103
html.Element? _sceneElement;
100104

101105
/// This is state persistent across hot restarts that indicates what
102-
/// to clear. We delay removal of old visible state to make the
106+
/// to clear. Delay removal of old visible state to make the
103107
/// transition appear smooth.
104108
static const String _staleHotRestartStore = '__flutter_state';
105109
List<html.Element?>? _staleHotRestartState;
106110

111+
/// Creates a container for DOM elements that need to be cleaned up between
112+
/// hot restarts.
113+
///
114+
/// If a contains already exists, reuses the existing one.
107115
void _setupHotRestart() {
108116
// This persists across hot restarts to clear stale DOM.
109117
_staleHotRestartState = getJsProperty<List<html.Element?>?>(html.window, _staleHotRestartStore);
@@ -112,7 +120,12 @@ class FlutterViewEmbedder {
112120
setJsProperty(
113121
html.window, _staleHotRestartStore, _staleHotRestartState);
114122
}
123+
}
115124

125+
/// Registers DOM elements that need to be cleaned up before hot restarting.
126+
///
127+
/// [_setupHotRestart] must have been called prior to calling this method.
128+
void _registerHotRestartCleanUp() {
116129
registerHotRestartListener(() {
117130
_resizeSubscription?.cancel();
118131
_localeSubscription?.cancel();
@@ -133,11 +146,11 @@ class FlutterViewEmbedder {
133146
}
134147
}
135148

136-
/// We don't want to unnecessarily move DOM nodes around. If a DOM node is
149+
/// Don't unnecessarily move DOM nodes around. If a DOM node is
137150
/// already in the right place, skip DOM mutation. This is both faster and
138151
/// more correct, because moving DOM nodes loses internal state, such as
139152
/// text selection.
140-
void renderScene(html.Element? sceneElement) {
153+
void addSceneToSceneHost(html.Element? sceneElement) {
141154
if (sceneElement != _sceneElement) {
142155
_sceneElement?.remove();
143156
_sceneElement = sceneElement;
@@ -203,15 +216,15 @@ class FlutterViewEmbedder {
203216
setElementStyle(bodyElement, 'padding', '0');
204217
setElementStyle(bodyElement, 'margin', '0');
205218

206-
// TODO(yjbanov): fix this when we support KVM I/O. Currently we scroll
219+
// TODO(yjbanov): fix this when KVM I/O support is added. Currently scroll
207220
// using drag, and text selection interferes.
208221
setElementStyle(bodyElement, 'user-select', 'none');
209222
setElementStyle(bodyElement, '-webkit-user-select', 'none');
210223
setElementStyle(bodyElement, '-ms-user-select', 'none');
211224
setElementStyle(bodyElement, '-moz-user-select', 'none');
212225

213226
// This is required to prevent the browser from doing any native touch
214-
// handling. If we don't do this, the browser doesn't report 'pointermove'
227+
// handling. If this is not done, the browser doesn't report 'pointermove'
215228
// events properly.
216229
setElementStyle(bodyElement, 'touch-action', 'none');
217230

@@ -227,7 +240,7 @@ class FlutterViewEmbedder {
227240
for (final html.Element viewportMeta
228241
in html.document.head!.querySelectorAll('meta[name="viewport"]')) {
229242
if (assertionsEnabled) {
230-
// Filter out the meta tag that we ourselves placed on the page. This is
243+
// Filter out the meta tag that the engine placed on the page. This is
231244
// to avoid UI flicker during hot restart. Hot restart will clean up the
232245
// old meta tag synchronously with the first post-restart frame.
233246
if (!viewportMeta.hasAttribute('flt-viewport')) {
@@ -265,7 +278,8 @@ class FlutterViewEmbedder {
265278
..bottom = '0'
266279
..left = '0';
267280

268-
// This must be appended to the body, so we can create a host node properly.
281+
// This must be appended to the body, so the engine can create a host node
282+
// properly.
269283
bodyElement.append(glassPaneElement);
270284

271285
// Create a [HostNode] under the glass pane element, and attach everything
@@ -277,6 +291,14 @@ class FlutterViewEmbedder {
277291
_sceneHostElement = html.document.createElement('flt-scene-host')
278292
..style.pointerEvents = 'none';
279293

294+
/// CanvasKit uses a static scene element that never gets replaced, so it's
295+
/// added eagerly during initialization here and never touched, unless the
296+
/// system is reset due to hot restart or in a test.
297+
if (useCanvasKit) {
298+
skiaSceneHost = html.Element.tag('flt-scene');
299+
addSceneToSceneHost(skiaSceneHost);
300+
}
301+
280302
final html.Element semanticsHostElement =
281303
html.document.createElement('flt-semantics-host');
282304
semanticsHostElement.style
@@ -290,25 +312,31 @@ class FlutterViewEmbedder {
290312
.prepareAccessibilityPlaceholder();
291313

292314
glassPaneElementHostNode.nodes.addAll(<html.Node>[
293-
semanticsHostElement,
294315
_accessibilityPlaceholder,
295316
_sceneHostElement!,
317+
318+
// The semantic host goes last because hit-test order-wise it must be
319+
// first. If semantics goes under the scene host, platform views will
320+
// obscure semantic elements.
321+
//
322+
// You may be wondering: wouldn't semantics obscure platform views and
323+
// make then not accessible? At least with some careful planning, that
324+
// should not be the case. The semantics tree makes all of its non-leaf
325+
// elements transparent. This way, if a platform view appears among other
326+
// interactive Flutter widgets, as long as those widgets do not intersect
327+
// with the platform view, the platform view will be reachable.
328+
semanticsHostElement,
296329
]);
297330

298331
// When debugging semantics, make the scene semi-transparent so that the
299-
// semantics tree is visible.
332+
// semantics tree is more prominent.
300333
if (configuration.debugShowSemanticsNodes) {
301334
_sceneHostElement!.style.opacity = '0.3';
302335
}
303336

304337
PointerBinding.initInstance(glassPaneElement);
305338
KeyboardBinding.initInstance(glassPaneElement);
306339

307-
// Hide the DOM nodes used to render the scene from accessibility, because
308-
// the accessibility tree is built from the SemanticsNode tree as a parallel
309-
// DOM tree.
310-
_sceneHostElement!.setAttribute('aria-hidden', 'true');
311-
312340
if (html.window.visualViewport == null && isWebKit) {
313341
// Older Safari versions sometimes give us bogus innerWidth/innerHeight
314342
// values when the page loads. When it changes the values to correct ones
@@ -321,10 +349,11 @@ class FlutterViewEmbedder {
321349
//
322350
// VisualViewport API is not enabled in Firefox as well. On the other hand
323351
// Firefox returns correct values for innerHeight, innerWidth.
324-
// Firefox also triggers html.window.onResize therefore we don't need this
325-
// timer to be set up for Firefox.
352+
// Firefox also triggers html.window.onResize therefore this timer does
353+
// not need to be set up for Firefox.
326354
final int initialInnerWidth = html.window.innerWidth!;
327-
// Counts how many times we checked screen size. We check up to 5 times.
355+
// Counts how many times screen size was checked. It is checked up to 5
356+
// times.
328357
int checkCount = 0;
329358
Timer.periodic(const Duration(milliseconds: 100), (Timer t) {
330359
checkCount += 1;
@@ -361,7 +390,7 @@ class FlutterViewEmbedder {
361390
}
362391

363392
/// The framework specifies semantics in physical pixels, but CSS uses
364-
/// logical pixels. To compensate, we inject an inverse scale at the root
393+
/// logical pixels. To compensate, an inverse scale is injected at the root
365394
/// level.
366395
void updateSemanticsScreenProperties() {
367396
_semanticsHostElement!.style.transform =

lib/web_ui/lib/src/engine/html/bitmap_canvas.dart

Lines changed: 26 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -370,7 +370,7 @@ class BitmapCanvas extends EngineCanvas {
370370
_renderStrategy.isInsideSvgFilterTree ||
371371
(_preserveImageData == false && _contains3dTransform) ||
372372
(_childOverdraw &&
373-
_canvasPool.isEmpty &&
373+
!_canvasPool.hasCanvas &&
374374
paint.maskFilter == null &&
375375
paint.shader == null &&
376376
paint.style != ui.PaintingStyle.stroke);
@@ -384,7 +384,7 @@ class BitmapCanvas extends EngineCanvas {
384384
((_childOverdraw ||
385385
_renderStrategy.hasImageElements ||
386386
_renderStrategy.hasParagraphs) &&
387-
_canvasPool.isEmpty &&
387+
!_canvasPool.hasCanvas &&
388388
paint.maskFilter == null &&
389389
paint.shader == null);
390390

@@ -469,7 +469,7 @@ class BitmapCanvas extends EngineCanvas {
469469
element.style.mixBlendMode = blendModeToCssMixBlendMode(blendMode) ?? '';
470470
}
471471
// Switch to preferring DOM from now on, and close the current canvas.
472-
_closeCurrentCanvas();
472+
_closeCanvas();
473473
}
474474

475475
@override
@@ -626,7 +626,7 @@ class BitmapCanvas extends EngineCanvas {
626626
_applyTargetSize(
627627
imageElement, image.width.toDouble(), image.height.toDouble());
628628
}
629-
_closeCurrentCanvas();
629+
_closeCanvas();
630630
}
631631

632632
html.ImageElement _reuseOrCreateImage(HtmlImage htmlImage) {
@@ -770,7 +770,7 @@ class BitmapCanvas extends EngineCanvas {
770770
restore();
771771
}
772772
}
773-
_closeCurrentCanvas();
773+
_closeCanvas();
774774
}
775775

776776
void _applyTargetSize(
@@ -882,8 +882,8 @@ class BitmapCanvas extends EngineCanvas {
882882
// |--- <img>
883883
// Any drawing operations after these tags should allocate a new canvas,
884884
// instead of drawing into earlier canvas.
885-
void _closeCurrentCanvas() {
886-
_canvasPool.closeCurrentCanvas();
885+
void _closeCanvas() {
886+
_canvasPool.closeCanvas();
887887
_childOverdraw = true;
888888
_cachedLastCssFont = null;
889889
}
@@ -939,16 +939,24 @@ class BitmapCanvas extends EngineCanvas {
939939
void drawParagraph(CanvasParagraph paragraph, ui.Offset offset) {
940940
assert(paragraph.isLaidOut);
941941

942-
/// - paragraph.drawOnCanvas checks that the text styling doesn't include
943-
/// features that prevent text from being rendered correctly using canvas.
944-
/// - _childOverdraw check prevents sandwitching multiple canvas elements
945-
/// when we have alternating paragraphs and other drawing commands that are
946-
/// suitable for canvas.
947-
/// - To make sure an svg filter is applied correctly to paragraph we
948-
/// check isInsideSvgFilterTree to make sure dom node doesn't have any
949-
/// parents that apply one.
950-
if (paragraph.drawOnCanvas && _childOverdraw == false &&
951-
!_renderStrategy.isInsideSvgFilterTree) {
942+
// Normally, text is composited as a plain HTML <p> tag. However, if a
943+
// bitmap canvas was used for a preceding drawing command, then it's more
944+
// efficient to continue compositing into the existing canvas, if possible.
945+
// Whether it's possible to composite a paragraph into a 2D canvas depends
946+
// on the following:
947+
final bool canCompositeIntoBitmapCanvas =
948+
// Cannot composite if the paragraph cannot be drawn into bitmap canvas
949+
// in the first place.
950+
paragraph.canDrawOnCanvas &&
951+
// Cannot composite if there's no bitmap canvas to composite into.
952+
// Creating a new bitmap canvas just to draw text doesn't make sense.
953+
_canvasPool.hasCanvas &&
954+
!_childOverdraw &&
955+
// Bitmap canvas introduces correctness issues in the presence of SVG
956+
// filters, so prefer plain HTML in this case.
957+
!_renderStrategy.isInsideSvgFilterTree;
958+
959+
if (canCompositeIntoBitmapCanvas) {
952960
paragraph.paint(this, offset);
953961
return;
954962
}
@@ -977,7 +985,7 @@ class BitmapCanvas extends EngineCanvas {
977985
paragraphElement.style
978986
..left = '0px'
979987
..top = '0px';
980-
_closeCurrentCanvas();
988+
_closeCanvas();
981989
}
982990

983991
/// Draws vertices on a gl context.

lib/web_ui/lib/src/engine/html/picture.dart

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,20 @@ class PersistedPicture extends PersistedLeafSurface {
122122

123123
@override
124124
html.Element createElement() {
125-
return defaultCreateElement('flt-picture');
125+
final html.Element element = defaultCreateElement('flt-picture');
126+
127+
// The DOM elements used to render pictures are used purely to put pixels on
128+
// the screen. They have no semantic information. If an assistive technology
129+
// attempts to scan picture content it will look like garbage and confuse
130+
// users. UI semantics are exported as a separate DOM tree rendered parallel
131+
// to pictures.
132+
//
133+
// Why are layer and scene elements not hidden from ARIA? Because those
134+
// elements may contain platform views, and platform views must be
135+
// accessible.
136+
element.setAttribute('aria-hidden', 'true');
137+
138+
return element;
126139
}
127140

128141
@override

0 commit comments

Comments
 (0)