Skip to content

Commit 26766ac

Browse files
authored
Implements engine-side declarative pointer event handling for semantics. (flutter#176974)
Implements engine-side declarative pointer event handling for semantics. Framework-side to be implemented in next PR. Fixes flutter#149001. **Before change** https://dialog-dismiss-before.web.app/ Click on the "Show Dialog" button. Click anywhere inside the dialog that is not a form field. Observe the dialog being dismissed. **After change** https://dialog-dimiss-after.web.app/ Click on the "Show Dialog" button. Click anywhere inside the dialog that is not a form field. Observe the dialog not dismissed.
1 parent 5c21cf8 commit 26766ac

File tree

11 files changed

+376
-15
lines changed

11 files changed

+376
-15
lines changed

engine/src/flutter/lib/ui/semantics.dart

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1156,6 +1156,59 @@ enum Tristate {
11561156
}
11571157
}
11581158

1159+
/// Describes how a semantic node should behave during hit testing.
1160+
///
1161+
/// This enum allows the framework to communicate pointer event handling
1162+
/// behavior to the platform's accessibility layer. Different platforms
1163+
/// may implement this behavior differently based on their accessibility
1164+
/// infrastructure.
1165+
///
1166+
/// See also:
1167+
/// * [SemanticsUpdateBuilder.updateNode], which accepts this enum.
1168+
enum SemanticsHitTestBehavior {
1169+
/// Defer to the platform's default hit test behavior inference.
1170+
///
1171+
/// When set to defer, the platform will infer the appropriate behavior
1172+
/// based on the semantic node's properties such as interactive behaviors,
1173+
/// route scoping, etc.
1174+
///
1175+
/// On the web, the default inferred behavior is `transparent` for
1176+
/// non-interactive semantic nodes, allowing pointer events to pass through.
1177+
///
1178+
/// This is the default value and provides backward compatibility.
1179+
defer,
1180+
1181+
/// The semantic element is opaque to hit testing, consuming any pointer
1182+
/// events within its bounds and preventing them from reaching elements
1183+
/// behind it in Z-order (siblings and ancestors).
1184+
///
1185+
/// Children of this node can still receive pointer events normally.
1186+
/// Only elements that are visually behind this node (lower in the stacking
1187+
/// order) will be blocked from receiving events.
1188+
///
1189+
/// This is typically used for modal surfaces like dialogs, bottom sheets,
1190+
/// and drawers that should block interaction with content behind them while
1191+
/// still allowing interaction with their own content.
1192+
///
1193+
/// Platform implementations:
1194+
/// * On the web, this results in `pointer-events: all` CSS property.
1195+
opaque,
1196+
1197+
/// The semantic element is transparent to hit testing.
1198+
///
1199+
/// Transparent nodes do not receive hit test events and allow events to pass
1200+
/// through to elements behind them.
1201+
///
1202+
/// Note: This differs from the framework's `HitTestBehavior.translucent`,
1203+
/// which receives events while also allowing pass-through. Web's binary
1204+
/// `pointer-events` property (all or none) cannot support true translucent
1205+
/// behavior.
1206+
///
1207+
/// Platform implementations:
1208+
/// * On the web, this results in `pointer-events: none` CSS property.
1209+
transparent,
1210+
}
1211+
11591212
/// Represents a collection of boolean flags that convey semantic information
11601213
/// about a widget's accessibility state and properties.
11611214
///
@@ -1792,12 +1845,25 @@ abstract class SemanticsUpdateBuilder {
17921845
/// not use this argument should use other ways to communicate validation
17931846
/// errors to the user, such as embedding validation error text in the label.
17941847
///
1848+
/// The `hitTestBehavior` describes how this node should behave during hit
1849+
/// testing. When set to [SemanticsHitTestBehavior.defer] (the default), the
1850+
/// platform will infer appropriate behavior based on other semantic properties
1851+
/// of the node itself (not inherited from parent). Different platforms may
1852+
/// implement this differently.
1853+
///
1854+
/// For example, modal surfaces like dialogs can set this to
1855+
/// [SemanticsHitTestBehavior.opaque] to block pointer events from reaching
1856+
/// content behind them, while non-interactive decorative elements can set it
1857+
/// to [SemanticsHitTestBehavior.transparent] to allow pointer events to pass
1858+
/// through.
1859+
///
17951860
/// See also:
17961861
///
17971862
/// * https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/heading_role
17981863
/// * https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-level
17991864
/// * [SemanticsValidationResult], that describes possible values for the
18001865
/// `validationResult` argument.
1866+
/// * [SemanticsHitTestBehavior], which describes how hit testing behaves.
18011867
void updateNode({
18021868
required int id,
18031869
required SemanticsFlags flags,
@@ -1835,6 +1901,7 @@ abstract class SemanticsUpdateBuilder {
18351901
SemanticsRole role = SemanticsRole.none,
18361902
required List<String>? controlsNodes,
18371903
SemanticsValidationResult validationResult = SemanticsValidationResult.none,
1904+
SemanticsHitTestBehavior hitTestBehavior = SemanticsHitTestBehavior.defer,
18381905
required SemanticsInputType inputType,
18391906
required Locale? locale,
18401907
});
@@ -1913,6 +1980,7 @@ base class _NativeSemanticsUpdateBuilder extends NativeFieldWrapperClass1
19131980
SemanticsRole role = SemanticsRole.none,
19141981
required List<String>? controlsNodes,
19151982
SemanticsValidationResult validationResult = SemanticsValidationResult.none,
1983+
SemanticsHitTestBehavior hitTestBehavior = SemanticsHitTestBehavior.defer,
19161984
required SemanticsInputType inputType,
19171985
required Locale? locale,
19181986
}) {
@@ -1961,6 +2029,7 @@ base class _NativeSemanticsUpdateBuilder extends NativeFieldWrapperClass1
19612029
role.index,
19622030
controlsNodes,
19632031
validationResult.index,
2032+
hitTestBehavior.index,
19642033
inputType.index,
19652034
locale?.toLanguageTag() ?? '',
19662035
);
@@ -2009,6 +2078,7 @@ base class _NativeSemanticsUpdateBuilder extends NativeFieldWrapperClass1
20092078
Handle,
20102079
Int32,
20112080
Int32,
2081+
Int32,
20122082
Handle,
20132083
)
20142084
>(symbol: 'SemanticsUpdateBuilder::updateNode')
@@ -2052,6 +2122,7 @@ base class _NativeSemanticsUpdateBuilder extends NativeFieldWrapperClass1
20522122
int role,
20532123
List<String>? controlsNodes,
20542124
int validationResultIndex,
2125+
int hitTestBehaviorIndex,
20552126
int inputType,
20562127
String locale,
20572128
);

engine/src/flutter/lib/ui/semantics/semantics_update_builder.cc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ void SemanticsUpdateBuilder::updateNode(
7070
int role,
7171
const std::vector<std::string>& controlsNodes,
7272
int validationResult,
73+
int hitTestBehavior,
7374
int inputType,
7475
std::string locale) {
7576
FML_CHECK(scrollChildren == 0 ||

engine/src/flutter/lib/ui/semantics/semantics_update_builder.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ class SemanticsUpdateBuilder
6969
int role,
7070
const std::vector<std::string>& controlsNodes,
7171
int validationResult,
72+
int hitTestBehavior,
7273
int inputType,
7374
std::string locale);
7475

engine/src/flutter/lib/web_ui/lib/semantics.dart

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -669,6 +669,8 @@ class LocaleStringAttribute extends StringAttribute {
669669

670670
enum SemanticsValidationResult { none, valid, invalid }
671671

672+
enum SemanticsHitTestBehavior { defer, opaque, transparent }
673+
672674
class SemanticsUpdateBuilder {
673675
SemanticsUpdateBuilder();
674676

@@ -710,6 +712,7 @@ class SemanticsUpdateBuilder {
710712
SemanticsRole role = SemanticsRole.none,
711713
required List<String>? controlsNodes,
712714
SemanticsValidationResult validationResult = SemanticsValidationResult.none,
715+
SemanticsHitTestBehavior hitTestBehavior = SemanticsHitTestBehavior.defer,
713716
required SemanticsInputType inputType,
714717
required Locale? locale,
715718
}) {
@@ -754,6 +757,7 @@ class SemanticsUpdateBuilder {
754757
role: role,
755758
controlsNodes: controlsNodes,
756759
validationResult: validationResult,
760+
hitTestBehavior: hitTestBehavior,
757761
inputType: inputType,
758762
locale: locale,
759763
),

engine/src/flutter/lib/web_ui/lib/src/engine/semantics/incrementable.dart

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,14 @@ class SemanticIncrementable extends SemanticRole {
7171
_focusManager.manage(semanticsObject.id, _element);
7272
}
7373

74+
@override
75+
bool get acceptsPointerEvents {
76+
return switch (semanticsObject.hitTestBehavior) {
77+
ui.SemanticsHitTestBehavior.transparent => false,
78+
_ => true,
79+
};
80+
}
81+
7482
@override
7583
bool focusAsRouteDefault() {
7684
_element.focusWithoutScroll();

engine/src/flutter/lib/web_ui/lib/src/engine/semantics/semantics.dart

Lines changed: 53 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,7 @@ class SemanticsNodeUpdate {
270270
required this.role,
271271
required this.controlsNodes,
272272
required this.validationResult,
273+
this.hitTestBehavior = ui.SemanticsHitTestBehavior.defer,
273274
required this.inputType,
274275
required this.locale,
275276
});
@@ -382,6 +383,9 @@ class SemanticsNodeUpdate {
382383
/// See [ui.SemanticsUpdateBuilder.updateNode].
383384
final ui.SemanticsValidationResult validationResult;
384385

386+
/// See [ui.SemanticsUpdateBuilder.updateNode].
387+
final ui.SemanticsHitTestBehavior hitTestBehavior;
388+
385389
/// See [ui.SemanticsUpdateBuilder.updateNode].
386390
final ui.SemanticsInputType inputType;
387391

@@ -581,20 +585,46 @@ abstract class SemanticRole {
581585
///
582586
/// This boolean decides whether to set the `pointer-events` CSS property to
583587
/// `all` or to `none` on the semantics [element].
588+
///
589+
/// The behavior is determined by [ui.SemanticsHitTestBehavior]:
590+
/// - `opaque`: Accepts pointer events (blocks elements behind)
591+
/// - `transparent`: Rejects pointer events (passes through)
592+
/// - `defer`: Infers based on semantic properties (interactive behaviors, etc.)
584593
bool get acceptsPointerEvents {
594+
final hitTestBehavior = semanticsObject.hitTestBehavior;
595+
596+
switch (hitTestBehavior) {
597+
case ui.SemanticsHitTestBehavior.opaque:
598+
// Absorb pointer events, blocking them from reaching elements behind.
599+
// Used by modal surfaces like dialogs, bottom sheets, drawers.
600+
return true;
601+
case ui.SemanticsHitTestBehavior.transparent:
602+
// Pass through pointer events to elements behind.
603+
// Used for non-interactive decorative elements.
604+
return false;
605+
case ui.SemanticsHitTestBehavior.defer:
606+
return _inferAcceptsPointerEvents();
607+
}
608+
}
609+
610+
/// Infers whether pointer events should be accepted based on semantic properties.
611+
bool _inferAcceptsPointerEvents() {
612+
assert(semanticsObject.hitTestBehavior == ui.SemanticsHitTestBehavior.defer);
613+
614+
// Check if any interactive behavior requires pointer events.
615+
// Interactive behaviors (Tappable, SemanticTextField, SemanticIncrementable)
616+
// override this to return true, ensuring buttons, text fields, and other
617+
// interactive elements receive pointer events when framework defers.
585618
final behaviors = _behaviors;
586619
if (behaviors != null) {
587620
for (final behavior in behaviors) {
588-
if (behavior.acceptsPointerEvents) {
621+
if (behavior.shouldAcceptPointerEvents) {
589622
return true;
590623
}
591624
}
592625
}
593-
// Ignore pointer events on all container nodes.
594-
if (semanticsObject.hasChildren) {
595-
return false;
596-
}
597-
return true;
626+
627+
return false;
598628
}
599629

600630
/// Semantic behaviors provided by this role, if any.
@@ -1014,7 +1044,7 @@ abstract class SemanticBehavior {
10141044
///
10151045
/// This boolean decides whether to set the `pointer-events` CSS property to
10161046
/// `all` or to `none` on [SemanticsObject.element].
1017-
bool get acceptsPointerEvents => false;
1047+
bool get shouldAcceptPointerEvents => false;
10181048

10191049
/// Called immediately after the [semanticsObject] updates some of its fields.
10201050
///
@@ -1466,6 +1496,17 @@ class SemanticsObject {
14661496
_dirtyFields |= _validationResultIndex;
14671497
}
14681498

1499+
/// See [ui.SemanticsUpdateBuilder.updateNode].
1500+
ui.SemanticsHitTestBehavior get hitTestBehavior => _hitTestBehavior;
1501+
ui.SemanticsHitTestBehavior _hitTestBehavior = ui.SemanticsHitTestBehavior.defer;
1502+
1503+
static const int _hitTestBehaviorIndex = 1 << 28;
1504+
1505+
bool get isHitTestBehaviorDirty => _isDirty(_hitTestBehaviorIndex);
1506+
void _markHitTestBehaviorDirty() {
1507+
_dirtyFields |= _hitTestBehaviorIndex;
1508+
}
1509+
14691510
/// A unique permanent identifier of the semantics node in the tree.
14701511
final int id;
14711512

@@ -1778,6 +1819,11 @@ class SemanticsObject {
17781819
_markValidationResultDirty();
17791820
}
17801821

1822+
if (_hitTestBehavior != update.hitTestBehavior) {
1823+
_hitTestBehavior = update.hitTestBehavior;
1824+
_markHitTestBehaviorDirty();
1825+
}
1826+
17811827
role = update.role;
17821828

17831829
inputType = update.inputType;

engine/src/flutter/lib/web_ui/lib/src/engine/semantics/tappable.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ class Tappable extends SemanticBehavior {
5151
}
5252

5353
@override
54-
bool get acceptsPointerEvents => true;
54+
bool get shouldAcceptPointerEvents => true;
5555

5656
DomEventListener? _clickListener;
5757
bool _isListening = false;

engine/src/flutter/lib/web_ui/lib/src/engine/semantics/text_field.dart

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,12 @@ class SemanticTextField extends SemanticRole {
208208
}
209209

210210
@override
211-
bool get acceptsPointerEvents => true;
211+
bool get acceptsPointerEvents {
212+
return switch (semanticsObject.hitTestBehavior) {
213+
ui.SemanticsHitTestBehavior.transparent => false,
214+
_ => true,
215+
};
216+
}
212217

213218
/// The element used for editing, e.g. `<input>`, `<textarea>`, which is
214219
/// different from the host [element].

0 commit comments

Comments
 (0)