Revert "[web] fix clicks on merged semantic nodes (#43620)" (flutter/engine#46067)

This reverts commit 6dee3dd14e.

The commit caused https://github.com/flutter/flutter/issues/134842. I'm going to try again, this time accounting for nested clickables/tappables.
This commit is contained in:
Yegor
2023-09-21 12:57:55 -07:00
committed by GitHub
parent 3d3a76280d
commit df462f2b6e
6 changed files with 117 additions and 731 deletions

View File

@@ -2,7 +2,6 @@
// 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 'dart:math' as math;
@@ -26,7 +25,7 @@ const bool _debugLogPointerEvents = false;
const bool _debugLogFlutterEvents = false;
/// The signature of a callback that handles pointer events.
typedef _PointerDataCallback = void Function(DomEvent event, List<ui.PointerData>);
typedef _PointerDataCallback = void Function(Iterable<ui.PointerData>);
// The mask for the bitfield of event buttons. Buttons not contained in this
// mask are cut off.
@@ -104,14 +103,11 @@ class PointerBinding {
}
}
final ClickDebouncer clickDebouncer = ClickDebouncer();
/// Performs necessary clean up for PointerBinding including removing event listeners
/// and clearing the existing pointer state
void dispose() {
_adapter.clearListeners();
_pointerDataConverter.clearPointerState();
clickDebouncer.reset();
}
final DomElement flutterViewElement;
@@ -160,247 +156,20 @@ class PointerBinding {
// TODO(dit): remove old API fallbacks, https://github.com/flutter/flutter/issues/116141
_BaseAdapter _createAdapter() {
if (_detector.hasPointerEvents) {
return _PointerAdapter(clickDebouncer.onPointerData, flutterViewElement, _pointerDataConverter, _keyboardConverter);
return _PointerAdapter(_onPointerData, flutterViewElement, _pointerDataConverter, _keyboardConverter);
}
// Fallback for Safari Mobile < 13. To be removed.
if (_detector.hasTouchEvents) {
return _TouchAdapter(clickDebouncer.onPointerData, flutterViewElement, _pointerDataConverter, _keyboardConverter);
return _TouchAdapter(_onPointerData, flutterViewElement, _pointerDataConverter, _keyboardConverter);
}
// Fallback for Safari Desktop < 13. To be removed.
if (_detector.hasMouseEvents) {
return _MouseAdapter(clickDebouncer.onPointerData, flutterViewElement, _pointerDataConverter, _keyboardConverter);
return _MouseAdapter(_onPointerData, flutterViewElement, _pointerDataConverter, _keyboardConverter);
}
throw UnsupportedError('This browser does not support pointer, touch, or mouse events.');
}
}
@visibleForTesting
typedef QueuedEvent = ({ DomEvent event, Duration timeStamp, List<ui.PointerData> data });
@visibleForTesting
typedef DebounceState = ({
DomElement target,
Timer timer,
List<QueuedEvent> queue,
});
/// Disambiguates taps and clicks that are produced both by the framework from
/// `pointerdown`/`pointerup` events and those detected as DOM "click" events by
/// the browser.
///
/// The implementation is waiting for a `pointerdown`, and as soon as it sees
/// one stops forwarding pointer events to the framework, and instead queues
/// them in a list. The queuing process stops as soon as one of the following
/// two conditions happens first:
///
/// * 200ms passes after the `pointerdown` event. Most clicks, even slow ones,
/// are typically done by then. Importantly, screen readers simulate clicks
/// much faster than 200ms. So if the timer expires, it is likely the user is
/// not interested in producing a click, so the debouncing process stops and
/// all queued events are forwarded to the framework. If, for example, a
/// tappable node is inside a scrollable viewport, the events can be
/// intrepreted by the framework to initiate scrolling.
/// * A `click` event arrives. If the event queue has not been flushed to the
/// framework, the event is forwarded to the framework as a
/// `SemanticsAction.tap`, and all the pointer events are dropped. If, by the
/// time the click event arrives, the queue was flushed (but no more than 50ms
/// ago), then the click event is dropped instead under the assumption that
/// the flushed pointer events are interpreted by the framework as the desired
/// gesture.
///
/// This mechanism is in place to deal with https://github.com/flutter/flutter/issues/130162.
class ClickDebouncer {
DebounceState? _state;
@visibleForTesting
DebounceState? get debugState => _state;
// The timestamp of the last "pointerup" DOM event that was flushed.
//
// Not to be confused with the time when it was flushed. The two may be far
// apart because the flushing can happen after a delay due to timer, or events
// that happen after the said "pointerup".
Duration? _lastFlushedPointerUpTimeStamp;
/// Returns true if the debouncer has a non-empty queue of pointer events that
/// were withheld from the framework.
///
/// This value is normally false, and it flips to true when the first
/// pointerdown is observed that lands on a tappable semantics node, denoted
/// by the presence of the `flt-tappable` attribute.
bool get isDebouncing => _state != null;
/// Processes a pointer event.
///
/// If semantics are off, simply forwards the event to the framework.
///
/// If currently debouncing events (see [isDebouncing]), adds the event to
/// the debounce queue, unless the target of the event is different from the
/// target that initiated the debouncing process, in which case stops
/// debouncing and flushes pointer events to the framework.
///
/// If the event is a `pointerdown` and the target is `flt-tappable`, begins
/// debouncing events.
///
/// In all other situations forwards the event to the framework.
void onPointerData(DomEvent event, List<ui.PointerData> data) {
if (!EnginePlatformDispatcher.instance.semanticsEnabled) {
_sendToFramework(event, data);
return;
}
if (isDebouncing) {
_debounce(event, data);
} else if (event.type == 'pointerdown') {
_startDebouncing(event, data);
} else {
_sendToFramework(event, data);
}
}
/// Notifies the debouncer of the browser-detected "click" DOM event.
///
/// Forwards the event to the framework, unless it is deduplicated because
/// the corresponding pointer down/up events were recently flushed to the
/// framework already.
void onClick(DomEvent click, int semanticsNodeId, bool isListening) {
assert(click.type == 'click');
if (!isDebouncing) {
// There's no pending queue of pointer events that are being debounced. It
// is a standalone click event. Unless pointer down/up were flushed
// recently and if the node is currently listening to event, forward to
// the framework.
if (isListening && _shouldSendClickEventToFramework(click)) {
EnginePlatformDispatcher.instance.invokeOnSemanticsAction(
semanticsNodeId, ui.SemanticsAction.tap, null);
}
return;
}
if (isListening) {
// There's a pending queue of pointer events. Prefer sending the tap action
// instead of pointer events, because the pointer events may not land on the
// combined semantic node and miss the click/tap.
final DebounceState state = _state!;
_state = null;
state.timer.cancel();
EnginePlatformDispatcher.instance.invokeOnSemanticsAction(
semanticsNodeId, ui.SemanticsAction.tap, null);
} else {
// The semantic node is not listening to taps. Flush the pointer events
// for the framework to figure out what to do with them. It's possible
// the framework is interested in gestures other than taps.
_flush();
}
}
void _startDebouncing(DomEvent event, List<ui.PointerData> data) {
assert(
_state == null,
'Cannot start debouncing. Already debouncing.'
);
assert(
event.type == 'pointerdown',
'Click debouncing must begin with a pointerdown'
);
final DomEventTarget? target = event.target;
if (target is DomElement && target.hasAttribute('flt-tappable')) {
_state = (
target: target,
// The 200ms duration was chosen empirically by testing tapping, mouse
// clicking, trackpad tapping and clicking, as well as the following
// screen readers: TalkBack on Android, VoiceOver on macOS, Narrator/
// NVDA/JAWS on Windows. 200ms seemed to hit the sweet spot by
// satisfying the following:
// * It was short enough that delaying the `pointerdown` still allowed
// drag gestures to begin reasonably soon (e.g. scrolling).
// * It was long enough to register taps and clicks.
// * It was successful at detecting taps generated by all tested
// screen readers.
timer: Timer(const Duration(milliseconds: 200), _onTimerExpired),
queue: <QueuedEvent>[(
event: event,
timeStamp: _BaseAdapter._eventTimeStampToDuration(event.timeStamp!),
data: data,
)],
);
} else {
// The event landed on an non-tappable target. Assume this won't lead to
// double clicks and forward the event to the framework.
_sendToFramework(event, data);
}
}
void _debounce(DomEvent event, List<ui.PointerData> data) {
assert(
_state != null,
'Cannot debounce event. Debouncing state not established by _startDebouncing.'
);
final DebounceState state = _state!;
state.queue.add((
event: event,
timeStamp: _BaseAdapter._eventTimeStampToDuration(event.timeStamp!),
data: data,
));
// It's only interesting to debounce clicks when both `pointerdown` and
// `pointerup` land on the same element.
if (event.type == 'pointerup') {
// TODO(yjbanov): this is a bit mouthful, but see https://github.com/dart-lang/sdk/issues/53070
final DomEventTarget? eventTarget = event.target;
final DomElement stateTarget = state.target;
final bool targetChanged = eventTarget != stateTarget;
if (targetChanged) {
_flush();
}
}
}
void _onTimerExpired() {
if (!isDebouncing) {
return;
}
_flush();
}
// If the click event happens soon after the last `pointerup` event that was
// already flushed to the framework, the click event is dropped to avoid
// double click.
bool _shouldSendClickEventToFramework(DomEvent click) {
final Duration? lastFlushedPointerUpTimeStamp = _lastFlushedPointerUpTimeStamp;
if (lastFlushedPointerUpTimeStamp == null) {
// We haven't seen a pointerup. It's standalone click event. Let it through.
return true;
}
final Duration clickTimeStamp = _BaseAdapter._eventTimeStampToDuration(click.timeStamp!);
final Duration delta = clickTimeStamp - lastFlushedPointerUpTimeStamp;
return delta >= const Duration(milliseconds: 50);
}
void _flush() {
assert(_state != null);
final DebounceState state = _state!;
state.timer.cancel();
final List<ui.PointerData> aggregateData = <ui.PointerData>[];
for (final QueuedEvent queuedEvent in state.queue) {
if (queuedEvent.event.type == 'pointerup') {
_lastFlushedPointerUpTimeStamp = queuedEvent.timeStamp;
}
aggregateData.addAll(queuedEvent.data);
}
_sendToFramework(null, aggregateData);
_state = null;
}
void _sendToFramework(DomEvent? event, List<ui.PointerData> data) {
void _onPointerData(Iterable<ui.PointerData> data) {
final ui.PointerDataPacket packet = ui.PointerDataPacket(data: data.toList());
if (_debugLogFlutterEvents) {
for(final ui.PointerData datum in data) {
@@ -409,16 +178,6 @@ class ClickDebouncer {
}
EnginePlatformDispatcher.instance.invokeOnPointerDataPacket(packet);
}
/// Cancels any pending debounce process and forgets anything that happened so
/// far.
///
/// This object can be used as if it was just initialized.
void reset() {
_state?.timer.cancel();
_state = null;
_lastFlushedPointerUpTimeStamp = null;
}
}
class PointerSupportDetector {
@@ -439,50 +198,65 @@ class _Listener {
required this.target,
required this.handler,
required this.useCapture,
required this.isNative,
});
/// Registers a listener for the given `event` on a `target`.
///
/// If `passive` is null uses the default behavior determined by the event
/// type. If `passive` is true, marks the handler as non-blocking for the
/// built-in browser behavior. This means the browser will not wait for the
/// handler to finish execution before performing the default action
/// associated with this event. If `passive` is false, the browser will wait
/// for the handler to finish execution before performing the respective
/// action.
/// Registers a listener for the given [event] on [target] using the Dart-to-JS API.
factory _Listener.register({
required String event,
required DomEventTarget target,
required DartDomEventListener handler,
bool capture = false,
bool? passive,
}) {
final DomEventListener jsHandler = createDomEventListener(handler);
if (passive == null) {
target.addEventListener(event, jsHandler, capture);
} else {
final Map<String, Object> eventOptions = <String, Object>{
'capture': capture,
'passive': passive,
};
target.addEventListenerWithOptions(event, jsHandler, eventOptions);
}
return _Listener._(
final _Listener listener = _Listener._(
event: event,
target: target,
handler: jsHandler,
useCapture: capture,
isNative: false,
);
target.addEventListener(event, jsHandler, capture);
return listener;
}
/// Registers a listener for the given [event] on [target] using the native JS API.
factory _Listener.registerNative({
required String event,
required DomEventTarget target,
required DomEventListener jsHandler,
bool capture = false,
bool passive = false,
}) {
final Map<String, Object> eventOptions = <String, Object>{
'capture': capture,
'passive': passive,
};
final _Listener listener = _Listener._(
event: event,
target: target,
handler: jsHandler,
useCapture: capture,
isNative: true,
);
target.addEventListenerWithOptions(event, jsHandler, eventOptions);
return listener;
}
final String event;
final DomEventTarget target;
final DomEventListener handler;
final bool useCapture;
final bool isNative;
void unregister() {
target.removeEventListener(event, handler, useCapture);
if (isNative) {
target.removeEventListener(event, handler, useCapture);
} else {
target.removeEventListener(event, handler, useCapture);
}
}
}
@@ -722,11 +496,10 @@ mixin _WheelEventListenerMixin on _BaseAdapter {
}
void _addWheelEventListener(DartDomEventListener handler) {
_listeners.add(_Listener.register(
_listeners.add(_Listener.registerNative(
event: 'wheel',
target: flutterViewElement,
handler: handler,
passive: false,
jsHandler: createDomEventListener(handler),
));
}
@@ -736,7 +509,7 @@ mixin _WheelEventListenerMixin on _BaseAdapter {
if (_debugLogPointerEvents) {
print(event.type);
}
_callback(e, _convertWheelEventToPointerData(event));
_callback(_convertWheelEventToPointerData(event));
// Prevent default so mouse wheel event doesn't get converted to
// a scroll event that semantic nodes would process.
//
@@ -986,7 +759,7 @@ class _PointerAdapter extends _BaseAdapter with _WheelEventListenerMixin {
buttons: event.buttons!.toInt(),
);
_convertEventsToPointerData(data: pointerData, event: event, details: down);
_callback(event, pointerData);
_callback(pointerData);
});
// Why `domWindow` you ask? See this fiddle: https://jsfiddle.net/ditman/7towxaqp
@@ -1003,7 +776,7 @@ class _PointerAdapter extends _BaseAdapter with _WheelEventListenerMixin {
final _SanitizedDetails move = sanitizer.sanitizeMoveEvent(buttons: event.buttons!.toInt());
_convertEventsToPointerData(data: pointerData, event: event, details: move);
}
_callback(event, pointerData);
_callback(pointerData);
});
_addPointerEventListener(flutterViewElement, 'pointerleave', (DomPointerEvent event) {
@@ -1013,7 +786,7 @@ class _PointerAdapter extends _BaseAdapter with _WheelEventListenerMixin {
final _SanitizedDetails? details = sanitizer.sanitizeLeaveEvent(buttons: event.buttons!.toInt());
if (details != null) {
_convertEventsToPointerData(data: pointerData, event: event, details: details);
_callback(event, pointerData);
_callback(pointerData);
}
}, useCapture: false, checkModifiers: false);
@@ -1026,7 +799,7 @@ class _PointerAdapter extends _BaseAdapter with _WheelEventListenerMixin {
_removePointerIfUnhoverable(event);
if (details != null) {
_convertEventsToPointerData(data: pointerData, event: event, details: details);
_callback(event, pointerData);
_callback(pointerData);
}
}
});
@@ -1042,7 +815,7 @@ class _PointerAdapter extends _BaseAdapter with _WheelEventListenerMixin {
final _SanitizedDetails details = _getSanitizer(device).sanitizeCancelEvent();
_removePointerIfUnhoverable(event);
_convertEventsToPointerData(data: pointerData, event: event, details: details);
_callback(event, pointerData);
_callback(pointerData);
}
}, checkModifiers: false);
@@ -1181,7 +954,7 @@ class _TouchAdapter extends _BaseAdapter {
);
}
}
_callback(event, pointerData);
_callback(pointerData);
});
_addTouchEventListener(flutterViewElement, 'touchmove', (DomTouchEvent event) {
@@ -1200,7 +973,7 @@ class _TouchAdapter extends _BaseAdapter {
);
}
}
_callback(event, pointerData);
_callback(pointerData);
});
_addTouchEventListener(flutterViewElement, 'touchend', (DomTouchEvent event) {
@@ -1222,7 +995,7 @@ class _TouchAdapter extends _BaseAdapter {
);
}
}
_callback(event, pointerData);
_callback(pointerData);
});
_addTouchEventListener(flutterViewElement, 'touchcancel', (DomTouchEvent event) {
@@ -1241,7 +1014,7 @@ class _TouchAdapter extends _BaseAdapter {
);
}
}
_callback(event, pointerData);
_callback(pointerData);
});
}
@@ -1339,7 +1112,7 @@ class _MouseAdapter extends _BaseAdapter with _WheelEventListenerMixin {
buttons: event.buttons!.toInt(),
);
_convertEventsToPointerData(data: pointerData, event: event, details: sanitizedDetails);
_callback(event, pointerData);
_callback(pointerData);
});
// Why `domWindow` you ask? See this fiddle: https://jsfiddle.net/ditman/7towxaqp
@@ -1351,7 +1124,7 @@ class _MouseAdapter extends _BaseAdapter with _WheelEventListenerMixin {
}
final _SanitizedDetails move = _sanitizer.sanitizeMoveEvent(buttons: event.buttons!.toInt());
_convertEventsToPointerData(data: pointerData, event: event, details: move);
_callback(event, pointerData);
_callback(pointerData);
});
_addMouseEventListener(flutterViewElement, 'mouseleave', (DomMouseEvent event) {
@@ -1359,7 +1132,7 @@ class _MouseAdapter extends _BaseAdapter with _WheelEventListenerMixin {
final _SanitizedDetails? details = _sanitizer.sanitizeLeaveEvent(buttons: event.buttons!.toInt());
if (details != null) {
_convertEventsToPointerData(data: pointerData, event: event, details: details);
_callback(event, pointerData);
_callback(pointerData);
}
}, useCapture: false);
@@ -1369,7 +1142,7 @@ class _MouseAdapter extends _BaseAdapter with _WheelEventListenerMixin {
final _SanitizedDetails? sanitizedDetails = _sanitizer.sanitizeUpEvent(buttons: event.buttons?.toInt());
if (sanitizedDetails != null) {
_convertEventsToPointerData(data: pointerData, event: event, details: sanitizedDetails);
_callback(event, pointerData);
_callback(pointerData);
}
});

View File

@@ -1980,9 +1980,8 @@ class EngineSemanticsOwner {
}
/// Receives DOM events from the pointer event system to correlate with the
/// semantics events.
///
/// Returns true if the event should be forwarded to the framework.
/// semantics events; returns true if the event should be forwarded to the
/// framework.
///
/// The browser sends us both raw pointer events and gestures from
/// [SemanticsObject.element]s. There could be three possibilities:

View File

@@ -2,9 +2,12 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:ui/src/engine.dart';
import 'package:ui/ui.dart' as ui;
import '../dom.dart';
import '../platform_dispatcher.dart';
import 'semantics.dart';
/// Sets the "button" ARIA role.
class Button extends PrimaryRoleManager {
Button(SemanticsObject semanticsObject) : super.withBasics(PrimaryRole.button, semanticsObject) {
@@ -30,45 +33,41 @@ class Button extends PrimaryRoleManager {
/// the browser may not send us pointer events. In that mode we forward HTML
/// click as [ui.SemanticsAction.tap].
class Tappable extends RoleManager {
Tappable(SemanticsObject semanticsObject) : super(Role.tappable, semanticsObject) {
_clickListener = createDomEventListener((DomEvent click) {
PointerBinding.instance!.clickDebouncer.onClick(
click,
semanticsObject.id,
_isListening,
);
});
semanticsObject.element.addEventListener('click', _clickListener);
}
Tappable(SemanticsObject semanticsObject)
: super(Role.tappable, semanticsObject);
DomEventListener? _clickListener;
bool _isListening = false;
@override
void update() {
final bool wasListening = _isListening;
_isListening = semanticsObject.enabledState() != EnabledState.disabled && semanticsObject.isTappable;
if (wasListening != _isListening) {
_updateAttribute();
if (!semanticsObject.isTappable || semanticsObject.enabledState() == EnabledState.disabled) {
_stopListening();
} else {
if (_clickListener == null) {
_clickListener = createDomEventListener((DomEvent event) {
if (semanticsObject.owner.gestureMode != GestureMode.browserGestures) {
return;
}
EnginePlatformDispatcher.instance.invokeOnSemanticsAction(
semanticsObject.id, ui.SemanticsAction.tap, null);
});
semanticsObject.element.addEventListener('click', _clickListener);
}
}
}
void _updateAttribute() {
// The `flt-tappable` attribute marks the element for the ClickDebouncer to
// to know that it should debounce click events on this element. The
// contract is that the element that has this attribute is also the element
// that receives pointer and "click" events.
if (_isListening) {
semanticsObject.element.setAttribute('flt-tappable', '');
} else {
semanticsObject.element.removeAttribute('flt-tappable');
void _stopListening() {
if (_clickListener == null) {
return;
}
semanticsObject.element.removeEventListener('click', _clickListener);
_clickListener = null;
}
@override
void dispose() {
semanticsObject.element.removeEventListener('click', _clickListener);
_clickListener = null;
super.dispose();
_stopListening();
}
}

View File

@@ -293,23 +293,17 @@ String canonicalizeHtml(
html_package.Element.tag(replacementTag);
if (mode != HtmlComparisonMode.noAttributes) {
// Sort the attributes so tests are not sensitive to their order, which
// does not matter in terms of functionality.
final List<String> attributeNames = original.attributes.keys.cast<String>().toList();
attributeNames.sort();
for (final String name in attributeNames) {
final String value = original.attributes[name]!;
if (name == 'style') {
// The style attribute is handled separately because it contains substructure.
continue;
original.attributes.forEach((dynamic name, String value) {
if (name is! String) {
throw ArgumentError('"$name" should be String but was ${name.runtimeType}.');
}
// These are the only attributes we're interested in testing. This list
// can change over time.
if (name.startsWith('aria-') || name.startsWith('flt-') || name == 'role') {
if (name == 'style') {
return;
}
if (name.startsWith('aria-')) {
replacement.attributes[name] = value;
}
}
});
if (original.attributes.containsKey('style')) {
final String styleValue = original.attributes['style']!;

View File

@@ -3087,385 +3087,6 @@ void testMain() {
packets.clear();
},
);
group('ClickDebouncer', () {
_testClickDebouncer();
});
}
typedef CapturedSemanticsEvent = ({
ui.SemanticsAction type,
int nodeId,
});
void _testClickDebouncer() {
final DateTime testTime = DateTime(2018, 12, 17);
late List<ui.PointerChange> pointerPackets;
late List<CapturedSemanticsEvent> semanticsActions;
late _PointerEventContext context;
late PointerBinding binding;
void testWithSemantics(
String description,
Future<void> Function() body,
) {
test(
description,
() async {
EngineSemanticsOwner.instance
..debugOverrideTimestampFunction(() => testTime)
..semanticsEnabled = true;
await body();
EngineSemanticsOwner.instance.semanticsEnabled = false;
},
);
}
setUp(() {
context = _PointerEventContext();
pointerPackets = <ui.PointerChange>[];
semanticsActions = <CapturedSemanticsEvent>[];
ui.window.onPointerDataPacket = (ui.PointerDataPacket packet) {
for (final ui.PointerData data in packet.data) {
pointerPackets.add(data.change);
}
};
EnginePlatformDispatcher.instance.onSemanticsActionEvent = (ui.SemanticsActionEvent event) {
semanticsActions.add((type: event.type, nodeId: event.nodeId));
};
binding = PointerBinding.instance!;
binding.debugOverrideDetector(context);
binding.clickDebouncer.reset();
});
tearDown(() {
binding.clickDebouncer.reset();
});
test('Forwards to framework when semantics is off', () {
expect(EnginePlatformDispatcher.instance.semanticsEnabled, false);
expect(binding.clickDebouncer.isDebouncing, false);
flutterViewEmbedder.flutterViewElement.dispatchEvent(context.primaryDown());
expect(pointerPackets, <ui.PointerChange>[
ui.PointerChange.add,
ui.PointerChange.down,
]);
expect(binding.clickDebouncer.isDebouncing, false);
expect(semanticsActions, isEmpty);
});
testWithSemantics('Forwards to framework when not debouncing', () async {
expect(EnginePlatformDispatcher.instance.semanticsEnabled, true);
expect(binding.clickDebouncer.isDebouncing, false);
// This test DOM element is missing the `flt-tappable` attribute on purpose
// so that the debouncer does not debounce events and simply lets
// everything through.
final DomElement testElement = createDomElement('flt-semantics');
flutterViewEmbedder.semanticsHostElement!.appendChild(testElement);
testElement.dispatchEvent(context.primaryDown());
testElement.dispatchEvent(context.primaryUp());
expect(binding.clickDebouncer.isDebouncing, false);
expect(pointerPackets, <ui.PointerChange>[
ui.PointerChange.add,
ui.PointerChange.down,
ui.PointerChange.up,
]);
expect(semanticsActions, isEmpty);
});
testWithSemantics('Accumulates pointer events starting from pointerdown', () async {
expect(EnginePlatformDispatcher.instance.semanticsEnabled, true);
expect(binding.clickDebouncer.isDebouncing, false);
final DomElement testElement = createDomElement('flt-semantics');
testElement.setAttribute('flt-tappable', '');
flutterViewEmbedder.semanticsHostElement!.appendChild(testElement);
testElement.dispatchEvent(context.primaryDown());
expect(
reason: 'Should start debouncing at first pointerdown',
binding.clickDebouncer.isDebouncing,
true,
);
testElement.dispatchEvent(context.primaryUp());
expect(
reason: 'Should still be debouncing after pointerup',
binding.clickDebouncer.isDebouncing,
true,
);
expect(
reason: 'Events are withheld from the framework while debouncing',
pointerPackets,
<ui.PointerChange>[],
);
expect(
binding.clickDebouncer.debugState!.target,
testElement,
);
expect(
binding.clickDebouncer.debugState!.timer.isActive,
isTrue,
);
expect(
binding.clickDebouncer.debugState!.queue.map<String>((QueuedEvent e) => e.event.type),
<String>['pointerdown', 'pointerup'],
);
await Future<void>.delayed(const Duration(milliseconds: 250));
expect(
reason: 'Should stop debouncing after timer expires.',
binding.clickDebouncer.isDebouncing,
false,
);
expect(
reason: 'Queued up events should be flushed to the framework.',
pointerPackets,
<ui.PointerChange>[
ui.PointerChange.add,
ui.PointerChange.down,
ui.PointerChange.up,
],
);
expect(semanticsActions, isEmpty);
});
testWithSemantics('Flushes events to framework when target changes', () async {
expect(EnginePlatformDispatcher.instance.semanticsEnabled, true);
expect(binding.clickDebouncer.isDebouncing, false);
final DomElement testElement = createDomElement('flt-semantics');
testElement.setAttribute('flt-tappable', '');
flutterViewEmbedder.semanticsHostElement!.appendChild(testElement);
testElement.dispatchEvent(context.primaryDown());
expect(
reason: 'Should start debouncing at first pointerdown',
binding.clickDebouncer.isDebouncing,
true,
);
final DomElement newTarget = createDomElement('flt-semantics');
newTarget.setAttribute('flt-tappable', '');
flutterViewEmbedder.semanticsHostElement!.appendChild(newTarget);
newTarget.dispatchEvent(context.primaryUp());
expect(
reason: 'Should stop debouncing when target changes.',
binding.clickDebouncer.isDebouncing,
false,
);
expect(
reason: 'The state should be cleaned up after stopping debouncing.',
binding.clickDebouncer.debugState,
isNull,
);
expect(
reason: 'Queued up events should be flushed to the framework.',
pointerPackets,
<ui.PointerChange>[
ui.PointerChange.add,
ui.PointerChange.down,
ui.PointerChange.up,
],
);
expect(semanticsActions, isEmpty);
});
testWithSemantics('Forwards click to framework when not debouncing but listening', () async {
expect(binding.clickDebouncer.isDebouncing, false);
final DomElement testElement = createDomElement('flt-semantics');
testElement.setAttribute('flt-tappable', '');
flutterViewEmbedder.semanticsHostElement!.appendChild(testElement);
final DomEvent click = createDomMouseEvent(
'click',
<Object?, Object?>{
'clientX': testElement.getBoundingClientRect().x,
'clientY': testElement.getBoundingClientRect().y,
}
);
binding.clickDebouncer.onClick(click, 42, true);
expect(binding.clickDebouncer.isDebouncing, false);
expect(pointerPackets, isEmpty);
expect(semanticsActions, <CapturedSemanticsEvent>[
(type: ui.SemanticsAction.tap, nodeId: 42)
]);
});
testWithSemantics('Forwards click to framework when debouncing and listening', () async {
expect(binding.clickDebouncer.isDebouncing, false);
final DomElement testElement = createDomElement('flt-semantics');
testElement.setAttribute('flt-tappable', '');
flutterViewEmbedder.semanticsHostElement!.appendChild(testElement);
testElement.dispatchEvent(context.primaryDown());
expect(binding.clickDebouncer.isDebouncing, true);
final DomEvent click = createDomMouseEvent(
'click',
<Object?, Object?>{
'clientX': testElement.getBoundingClientRect().x,
'clientY': testElement.getBoundingClientRect().y,
}
);
binding.clickDebouncer.onClick(click, 42, true);
expect(pointerPackets, isEmpty);
expect(semanticsActions, <CapturedSemanticsEvent>[
(type: ui.SemanticsAction.tap, nodeId: 42)
]);
});
testWithSemantics('Dedupes click if debouncing but not listening', () async {
expect(binding.clickDebouncer.isDebouncing, false);
final DomElement testElement = createDomElement('flt-semantics');
testElement.setAttribute('flt-tappable', '');
flutterViewEmbedder.semanticsHostElement!.appendChild(testElement);
testElement.dispatchEvent(context.primaryDown());
expect(binding.clickDebouncer.isDebouncing, true);
final DomEvent click = createDomMouseEvent(
'click',
<Object?, Object?>{
'clientX': testElement.getBoundingClientRect().x,
'clientY': testElement.getBoundingClientRect().y,
}
);
binding.clickDebouncer.onClick(click, 42, false);
expect(
reason: 'When tappable declares that it is not listening to click events '
'the debouncer flushes the pointer events to the framework and '
'lets it sort it out.',
pointerPackets,
<ui.PointerChange>[
ui.PointerChange.add,
ui.PointerChange.down,
],
);
expect(semanticsActions, isEmpty);
});
testWithSemantics('Dedupes click if pointer down/up flushed recently', () async {
expect(EnginePlatformDispatcher.instance.semanticsEnabled, true);
expect(binding.clickDebouncer.isDebouncing, false);
final DomElement testElement = createDomElement('flt-semantics');
testElement.setAttribute('flt-tappable', '');
flutterViewEmbedder.semanticsHostElement!.appendChild(testElement);
testElement.dispatchEvent(context.primaryDown());
// Simulate the user holding the pointer down for some time before releasing,
// such that the pointerup event happens close to timer expiration. This
// will create the situation that the click event arrives just after the
// pointerup is flushed. Forwarding the click to the framework would look
// like a double-click, so the click event is deduped.
await Future<void>.delayed(const Duration(milliseconds: 190));
testElement.dispatchEvent(context.primaryUp());
expect(binding.clickDebouncer.isDebouncing, true);
expect(
reason: 'Timer has not expired yet',
pointerPackets, isEmpty,
);
// Wait for the timer to expire to make sure pointer events are flushed.
await Future<void>.delayed(const Duration(milliseconds: 20));
expect(
reason: 'Queued up events should be flushed to the framework because the '
'time expired before the click event arrived.',
pointerPackets,
<ui.PointerChange>[
ui.PointerChange.add,
ui.PointerChange.down,
ui.PointerChange.up,
],
);
final DomEvent click = createDomMouseEvent(
'click',
<Object?, Object?>{
'clientX': testElement.getBoundingClientRect().x,
'clientY': testElement.getBoundingClientRect().y,
}
);
binding.clickDebouncer.onClick(click, 42, true);
expect(
reason: 'Because the DOM click event was deduped.',
semanticsActions,
isEmpty,
);
});
testWithSemantics('Forwards click if enough time passed after the last flushed pointerup', () async {
expect(EnginePlatformDispatcher.instance.semanticsEnabled, true);
expect(binding.clickDebouncer.isDebouncing, false);
final DomElement testElement = createDomElement('flt-semantics');
testElement.setAttribute('flt-tappable', '');
flutterViewEmbedder.semanticsHostElement!.appendChild(testElement);
testElement.dispatchEvent(context.primaryDown());
// Simulate the user holding the pointer down for some time before releasing,
// such that the pointerup event happens close to timer expiration. This
// makes it possible for the click to arrive early. However, this test in
// particular will delay the click to check that the delay is checked
// correctly. The inverse situation was already tested in the previous test.
await Future<void>.delayed(const Duration(milliseconds: 190));
testElement.dispatchEvent(context.primaryUp());
expect(binding.clickDebouncer.isDebouncing, true);
expect(
reason: 'Timer has not expired yet',
pointerPackets, isEmpty,
);
// Wait for the timer to expire to make sure pointer events are flushed.
await Future<void>.delayed(const Duration(milliseconds: 100));
expect(
reason: 'Queued up events should be flushed to the framework because the '
'time expired before the click event arrived.',
pointerPackets,
<ui.PointerChange>[
ui.PointerChange.add,
ui.PointerChange.down,
ui.PointerChange.up,
],
);
final DomEvent click = createDomMouseEvent(
'click',
<Object?, Object?>{
'clientX': testElement.getBoundingClientRect().x,
'clientY': testElement.getBoundingClientRect().y,
}
);
binding.clickDebouncer.onClick(click, 42, true);
expect(
reason: 'The DOM click should still be sent to the framework because it '
'happened far enough from the last pointerup that it is unlikely '
'to be a duplicate.',
semanticsActions,
<CapturedSemanticsEvent>[
(type: ui.SemanticsAction.tap, nodeId: 42)
],
);
});
}
class MockSafariPointerEventWorkaround implements SafariPointerEventWorkaround {

View File

@@ -377,7 +377,7 @@ void _testEngineSemanticsOwner() {
expectSemanticsTree('''
<sem style="$rootSemanticStyle">
<sem-c>
<sem role="text" aria-label="Hello"></sem>
<sem aria-label="Hello"></sem>
</sem-c>
</sem>''');
@@ -387,7 +387,7 @@ void _testEngineSemanticsOwner() {
expectSemanticsTree('''
<sem style="$rootSemanticStyle">
<sem-c>
<sem role="text" aria-label="World"></sem>
<sem aria-label="World"></sem>
</sem-c>
</sem>''');
@@ -397,7 +397,7 @@ void _testEngineSemanticsOwner() {
expectSemanticsTree('''
<sem style="$rootSemanticStyle">
<sem-c>
<sem role="text"></sem>
<sem></sem>
</sem-c>
</sem>''');
@@ -430,7 +430,7 @@ void _testEngineSemanticsOwner() {
expectSemanticsTree('''
<sem style="$rootSemanticStyle">
<sem-c>
<sem role="text" aria-label="tooltip\nHello"></sem>
<sem aria-label="tooltip\nHello"></sem>
</sem-c>
</sem>''');
@@ -440,7 +440,7 @@ void _testEngineSemanticsOwner() {
expectSemanticsTree('''
<sem style="$rootSemanticStyle">
<sem-c>
<sem role="text"></sem>
<sem></sem>
</sem-c>
</sem>''');
@@ -1388,7 +1388,7 @@ void _testIncrementables() {
semantics().updateSemantics(builder.build());
expectSemanticsTree('''
<sem style="$rootSemanticStyle">
<input role="slider" aria-valuenow="1" aria-valuetext="d" aria-valuemax="1" aria-valuemin="1">
<input aria-valuenow="1" aria-valuetext="d" aria-valuemax="1" aria-valuemin="1">
</sem>''');
final SemanticsObject node = semantics().debugSemanticsTree![0]!;
@@ -1421,7 +1421,7 @@ void _testIncrementables() {
semantics().updateSemantics(builder.build());
expectSemanticsTree('''
<sem style="$rootSemanticStyle">
<input role="slider" aria-valuenow="1" aria-valuetext="d" aria-valuemax="2" aria-valuemin="1">
<input aria-valuenow="1" aria-valuetext="d" aria-valuemax="2" aria-valuemin="1">
</sem>''');
final DomHTMLInputElement input =
@@ -1454,7 +1454,7 @@ void _testIncrementables() {
semantics().updateSemantics(builder.build());
expectSemanticsTree('''
<sem style="$rootSemanticStyle">
<input role="slider" aria-valuenow="1" aria-valuetext="d" aria-valuemax="1" aria-valuemin="0">
<input aria-valuenow="1" aria-valuetext="d" aria-valuemax="1" aria-valuemin="0">
</sem>''');
final DomHTMLInputElement input =
@@ -1489,7 +1489,7 @@ void _testIncrementables() {
semantics().updateSemantics(builder.build());
expectSemanticsTree('''
<sem style="$rootSemanticStyle">
<input role="slider" aria-valuenow="1" aria-valuetext="d" aria-valuemax="2" aria-valuemin="0">
<input aria-valuenow="1" aria-valuetext="d" aria-valuemax="2" aria-valuemin="0">
</sem>''');
semantics().semanticsEnabled = false;
@@ -1632,7 +1632,7 @@ void _testCheckables() {
semantics().updateSemantics(builder.build());
expectSemanticsTree('''
<sem aria-label="test label" flt-tappable role="switch" aria-checked="true" style="$rootSemanticStyle"></sem>
<sem aria-label="test label" role="switch" aria-checked="true" style="$rootSemanticStyle"></sem>
''');
final SemanticsObject node = semantics().debugSemanticsTree![0]!;
@@ -1690,7 +1690,7 @@ void _testCheckables() {
semantics().updateSemantics(builder.build());
expectSemanticsTree('''
<sem role="switch" flt-tappable aria-checked="false" style="$rootSemanticStyle"></sem>
<sem role="switch" aria-checked="false" style="$rootSemanticStyle"></sem>
''');
semantics().semanticsEnabled = false;
@@ -1716,7 +1716,7 @@ void _testCheckables() {
semantics().updateSemantics(builder.build());
expectSemanticsTree('''
<sem role="checkbox" flt-tappable aria-checked="true" style="$rootSemanticStyle"></sem>
<sem role="checkbox" aria-checked="true" style="$rootSemanticStyle"></sem>
''');
semantics().semanticsEnabled = false;
@@ -1766,7 +1766,7 @@ void _testCheckables() {
semantics().updateSemantics(builder.build());
expectSemanticsTree('''
<sem role="checkbox" flt-tappable aria-checked="false" style="$rootSemanticStyle"></sem>
<sem role="checkbox" aria-checked="false" style="$rootSemanticStyle"></sem>
''');
semantics().semanticsEnabled = false;
@@ -1793,7 +1793,7 @@ void _testCheckables() {
semantics().updateSemantics(builder.build());
expectSemanticsTree('''
<sem role="radio" flt-tappable aria-checked="true" style="$rootSemanticStyle"></sem>
<sem role="radio" aria-checked="true" style="$rootSemanticStyle"></sem>
''');
semantics().semanticsEnabled = false;
@@ -1845,7 +1845,7 @@ void _testCheckables() {
semantics().updateSemantics(builder.build());
expectSemanticsTree('''
<sem role="radio" flt-tappable aria-checked="false" style="$rootSemanticStyle"></sem>
<sem role="radio" aria-checked="false" style="$rootSemanticStyle"></sem>
''');
semantics().semanticsEnabled = false;
@@ -1918,7 +1918,7 @@ void _testTappable() {
tester.apply();
expectSemanticsTree('''
<sem role="button" flt-tappable style="$rootSemanticStyle"></sem>
<sem role="button" style="$rootSemanticStyle"></sem>
''');
final SemanticsObject node = semantics().debugSemanticsTree![0]!;
@@ -1979,14 +1979,14 @@ void _testTappable() {
'<sem role="button" aria-disabled="true" style="$rootSemanticStyle"></sem>');
updateTappable(enabled: true);
expectSemanticsTree('<sem role="button" flt-tappable style="$rootSemanticStyle"></sem>');
expectSemanticsTree('<sem role="button" style="$rootSemanticStyle"></sem>');
updateTappable(enabled: false);
expectSemanticsTree(
'<sem role="button" aria-disabled="true" style="$rootSemanticStyle"></sem>');
updateTappable(enabled: true);
expectSemanticsTree('<sem role="button" flt-tappable style="$rootSemanticStyle"></sem>');
expectSemanticsTree('<sem role="button" style="$rootSemanticStyle"></sem>');
semantics().semanticsEnabled = false;
});
@@ -2623,11 +2623,11 @@ void _testDialog() {
tester.apply();
expectSemanticsTree('''
<sem role="dialog" aria-describedby="flt-semantic-node-2" style="$rootSemanticStyle">
<sem aria-describedby="flt-semantic-node-2" style="$rootSemanticStyle">
<sem-c>
<sem>
<sem-c>
<sem role="text" aria-label="$label"></sem>
<sem aria-label="$label"></sem>
</sem-c>
</sem>
</sem-c>
@@ -2716,7 +2716,7 @@ void _testDialog() {
<sem-c>
<sem>
<sem-c>
<sem role="text" aria-label="Hello"></sem>
<sem aria-label="Hello"></sem>
</sem-c>
</sem>
</sem-c>
@@ -2853,9 +2853,9 @@ void _testFocusable() {
}
expectSemanticsTree('''
<sem style="$rootSemanticStyle">
<sem role="group" style="$rootSemanticStyle">
<sem-c>
<sem role="text" aria-label="focusable text"></sem>
<sem aria-label="focusable text"></sem>
</sem-c>
</sem>
''');