blob: cc77d76498ba1069f8befa1d22e8b207583f4ddd [file] [log] [blame]
// 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.
// @dart = 2.6
part of engine;
/// The maximum [semanticsActivationAttempts] before we give up waiting for
/// the user to enable semantics.
///
/// This number is arbitrary and can be adjusted if it doesn't work well.
const int kMaxSemanticsActivationAttempts = 20;
/// After an event related to semantics activation has been received, we consume
/// the consecutive events on the engine. Do not send them to the framework.
/// For example when a 'mousedown' targeting a placeholder received following
/// 'mouseup' is also not sent to the framework.
/// Otherwise these events can cause unintended gestures on the framework side.
const Duration _periodToConsumeEvents = const Duration(milliseconds: 300);
/// The message in the label for the placeholder element used to enable
/// accessibility.
///
/// This uses US English as the default message. Set this value prior to
/// calling `runApp` to translate to another language.
String placeholderMessage = 'Enable accessibility';
/// A helper for [EngineSemanticsOwner].
///
/// [SemanticsHelper] prepares and placeholder to enable semantics.
///
/// It decides if an event is purely semantics enabling related or a regular
/// event which should be forwarded to the framework.
///
/// It does this by using a [SemanticsEnabler]. The [SemanticsEnabler]
/// implementation is choosen using form factor type.
///
/// See [DesktopSemanticsEnabler], [MobileSemanticsEnabler].
class SemanticsHelper {
SemanticsEnabler _semanticsEnabler =
isDesktop ? DesktopSemanticsEnabler() : MobileSemanticsEnabler();
@visibleForTesting
set semanticsEnabler(SemanticsEnabler semanticsEnabler) {
this._semanticsEnabler = semanticsEnabler;
}
bool shouldEnableSemantics(html.Event event) {
return _semanticsEnabler.shouldEnableSemantics(event);
}
html.Element prepareAccesibilityPlaceholder() {
return _semanticsEnabler.prepareAccesibilityPlaceholder();
}
}
@visibleForTesting
abstract class SemanticsEnabler {
/// Whether to enable semantics.
///
/// Semantics should be enabled if the web engine is no longer waiting for
/// extra signals from the user events. See [isWaitingToEnableSemantics].
///
/// Or if the received [html.Event] is suitable/enough for enabling the
/// semantics. See [tryEnableSemantics].
bool shouldEnableSemantics(html.Event event) {
if (!isWaitingToEnableSemantics) {
// Forward to framework as normal.
return true;
} else {
return tryEnableSemantics(event);
}
}
/// Attempts to activate semantics.
///
/// Returns true if the `event` is not related to semantics activation and
/// should be forwarded to the framework.
bool tryEnableSemantics(html.Event event);
/// Creates the placeholder for accesibility.
///
/// Puts it inside the glasspane.
///
/// On focus the element announces that accessibility can be enabled by
/// tapping/clicking. (Announcement depends on the assistive technology)
html.Element prepareAccesibilityPlaceholder();
/// Whether platform is still consisering enabling semantics.
///
/// At this stage a relevant set of events are always assessed to see if
/// they activate the semantics.
///
/// If not they are sent to framework as normal events.
bool get isWaitingToEnableSemantics;
}
@visibleForTesting
class DesktopSemanticsEnabler extends SemanticsEnabler {
/// We do not immediately enable semantics when the user requests it, but
/// instead wait for a short period of time before doing it. This is because
/// the request comes as an event targeted on the [_semanticsPlaceholder].
/// This event, depending on the browser, comes as a burst of events.
/// For example, Safari on MacOS sends "pointerup", "pointerdown". So during a
/// short time period we consume all events and prevent forwarding to the
/// framework. Otherwise, the events will be interpreted twice, once as a
/// request to activate semantics, and a second time by Flutter's gesture
/// recognizers.
@visibleForTesting
Timer semanticsActivationTimer;
/// A temporary placeholder used to capture a request to activate semantics.
html.Element _semanticsPlaceholder;
/// The number of events we processed that could potentially activate
/// semantics.
int semanticsActivationAttempts = 0;
/// Instructs [_tryEnableSemantics] to remove [_semanticsPlaceholder].
///
/// The placeholder is removed upon any next event.
bool _schedulePlaceholderRemoval = false;
/// Whether we are waiting for the user to enable semantics.
@override
bool get isWaitingToEnableSemantics => _semanticsPlaceholder != null;
@override
bool tryEnableSemantics(html.Event event) {
if (_schedulePlaceholderRemoval) {
_semanticsPlaceholder.remove();
_semanticsPlaceholder = null;
semanticsActivationTimer = null;
return true;
}
if (EngineSemanticsOwner.instance.semanticsEnabled) {
// Semantics already enabled, forward to framework as normal.
return true;
}
// In touch screen laptops, the touch is received as a mouse click
const Set<String> kInterestingEventTypes = <String>{
'click',
'keyup',
'keydown',
'mouseup',
'mousedown',
'pointerdown',
'pointerup',
};
if (!kInterestingEventTypes.contains(event.type)) {
// The event is not relevant, forward to framework as normal.
return true;
}
semanticsActivationAttempts += 1;
if (semanticsActivationAttempts >= kMaxSemanticsActivationAttempts) {
// We have received multiple user events, none of which resulted in
// semantics activation. This is a signal that the user is not interested
// in semantics, and so we will stop waiting for it.
_schedulePlaceholderRemoval = true;
return true;
}
if (semanticsActivationTimer != null) {
// We are in a waiting period to activate a timer. While the timer is
// active we should consume events pertaining to semantics activation.
// Otherwise the event will also be interpreted by the framework and
// potentially result in activating a gesture in the app.
return false;
}
// Check for the event target.
final bool enableConditionPassed = (event.target == _semanticsPlaceholder);
if (enableConditionPassed) {
assert(semanticsActivationTimer == null);
semanticsActivationTimer = Timer(_periodToConsumeEvents, () {
EngineSemanticsOwner.instance.semanticsEnabled = true;
_schedulePlaceholderRemoval = true;
});
return false;
}
// This was not a semantics activating event; forward as normal.
return true;
}
@override
html.Element prepareAccesibilityPlaceholder() {
_semanticsPlaceholder = html.Element.tag('flt-semantics-placeholder');
// Only listen to "click" because other kinds of events are reported via
// PointerBinding.
_semanticsPlaceholder.addEventListener('click', (html.Event event) {
tryEnableSemantics(event);
}, true);
// Adding roles to semantics placeholder. 'aria-live' will make sure that
// the content is announced to the assistive technology user as soon as the
// page receives focus. 'tab-index' makes sure the button is the first
// target of tab. 'aria-label' is used to define the placeholder message
// to the assistive technology user.
_semanticsPlaceholder
..setAttribute('role', 'button')
..setAttribute('aria-live', 'true')
..setAttribute('tabindex', '0')
..setAttribute('aria-label', placeholderMessage);
_semanticsPlaceholder.style
..position = 'absolute'
..left = '-1px'
..top = '-1px'
..width = '1px'
..height = '1px';
return _semanticsPlaceholder;
}
}
@visibleForTesting
class MobileSemanticsEnabler extends SemanticsEnabler {
/// We do not immediately enable semantics when the user requests it, but
/// instead wait for a short period of time before doing it. This is because
/// the request comes as an event targeted on the [_semanticsPlaceholder].
/// This event, depending on the browser, comes as a burst of events.
/// For example, Safari on IOS sends "touchstart", "touchend", and "click".
/// So during a short time period we consume all events and prevent forwarding
/// to the framework. Otherwise, the events will be interpreted twice, once as
/// a request to activate semantics, and a second time by Flutter's gesture
/// recognizers.
@visibleForTesting
Timer semanticsActivationTimer;
/// A temporary placeholder used to capture a request to activate semantics.
html.Element _semanticsPlaceholder;
/// The number of events we processed that could potentially activate
/// semantics.
int semanticsActivationAttempts = 0;
/// Instructs [_tryEnableSemantics] to remove [_semanticsPlaceholder].
///
/// For Blink browser engine the placeholder is removed upon any next event.
///
/// For Webkit browser engine the placeholder is removed upon the next
/// "touchend" event. This is to prevent Safari from swallowing the event
/// that happens on an element that's being removed. Blink doesn't have
/// this issue.
bool _schedulePlaceholderRemoval = false;
/// Whether we are waiting for the user to enable semantics.
@override
bool get isWaitingToEnableSemantics => _semanticsPlaceholder != null;
@override
bool tryEnableSemantics(html.Event event) {
if (_schedulePlaceholderRemoval) {
final bool removeNow =
(browserEngine != BrowserEngine.webkit || event.type == 'touchend');
if (removeNow) {
_semanticsPlaceholder.remove();
_semanticsPlaceholder = null;
semanticsActivationTimer = null;
}
return true;
}
if (EngineSemanticsOwner.instance.semanticsEnabled) {
// Semantics already enabled, forward to framework as normal.
return true;
}
semanticsActivationAttempts += 1;
if (semanticsActivationAttempts >= kMaxSemanticsActivationAttempts) {
// We have received multiple user events, none of which resulted in
// semantics activation. This is a signal that the user is not interested
// in semantics, and so we will stop waiting for it.
_schedulePlaceholderRemoval = true;
return true;
}
const Set<String> kInterestingEventTypes = <String>{
'click',
'touchstart',
'touchend',
};
if (!kInterestingEventTypes.contains(event.type)) {
// The event is not relevant, forward to framework as normal.
return true;
}
if (semanticsActivationTimer != null) {
// We are in a waiting period to activate a timer. While the timer is
// active we should consume events pertaining to semantics activation.
// Otherwise the event will also be interpreted by the framework and
// potentially result in activating a gesture in the app.
return false;
}
// In Chrome the debouncing works well enough to detect accessibility
// request.
final bool blinkEnableConditionPassed =
browserEngine == BrowserEngine.blink &&
EngineSemanticsOwner.instance.gestureMode ==
GestureMode.browserGestures;
// In Safari debouncing doesn't work. Instead we look at where exactly
// (within 1 pixel) the event landed. If it landed exactly in the middle of
// the placeholder we interpret it as a signal to enable accessibility. This
// is because when VoiceOver generates a tap it lands it in the middle of
// the focused element. This method is a bit flawed in that a user's finger
// could theoretically land in the middle of the element too. However, the
// chance of that happening is very small. Even low-end phones typically
// have >2 million pixels (e.g. Moto G4). It is very unlikely that a user
// will land their finger exactly in the middle. In the worst case an
// unlucky user would accidentally enable accessibility and the app will be
// slightly slower than normal, but the app will continue functioning as
// normal. Our semantics tree is designed to not interfere with Flutter's
// gesture detection.
bool safariEnableConditionPassed = false;
if (browserEngine == BrowserEngine.webkit) {
html.Point<num> activationPoint;
switch (event.type) {
case 'click':
final html.MouseEvent click = event;
activationPoint = click.offset;
break;
case 'touchstart':
case 'touchend':
final html.TouchEvent touch = event;
activationPoint = touch.changedTouches.first.client;
break;
default:
// The event is not relevant, forward to framework as normal.
return true;
}
assert(activationPoint != null);
final html.Rectangle<num> activatingElementRect =
domRenderer.glassPaneElement.getBoundingClientRect();
final double midX = activatingElementRect.left +
(activatingElementRect.right - activatingElementRect.left) / 2;
final double midY = activatingElementRect.top +
(activatingElementRect.bottom - activatingElementRect.top) / 2;
final double deltaX = activationPoint.x - midX;
final double deltaY = activationPoint.y - midY;
final double deltaSquared = deltaX * deltaX + deltaY * deltaY;
if (deltaSquared < 1.0) {
safariEnableConditionPassed = true;
}
}
if (blinkEnableConditionPassed || safariEnableConditionPassed) {
assert(semanticsActivationTimer == null);
semanticsActivationTimer = Timer(_periodToConsumeEvents, () {
EngineSemanticsOwner.instance.semanticsEnabled = true;
_schedulePlaceholderRemoval = true;
});
return false;
}
// This was not a semantics activating event; forward as normal.
return true;
}
@override
html.Element prepareAccesibilityPlaceholder() {
_semanticsPlaceholder = html.Element.tag('flt-semantics-placeholder');
// Only listen to "click" because other kinds of events are reported via
// PointerBinding.
_semanticsPlaceholder.addEventListener('click', (html.Event event) {
tryEnableSemantics(event);
}, true);
_semanticsPlaceholder
..setAttribute('role', 'button')
..setAttribute('aria-label', placeholderMessage);
_semanticsPlaceholder.style
..position = 'absolute'
..left = '0'
..top = '0'
..right = '0'
..bottom = '0';
return _semanticsPlaceholder;
}
}