blob: 0c818314a26f1bdfd2f61fc63b8c2350eb45d16e [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;
/// Implements vertical and horizontal scrolling functionality for semantics
/// objects.
///
/// Scrolling is implemented using a "joystick" method. The absolute value of
/// "scrollTop" in HTML is not important. We only need to know in whether the
/// value changed in the positive or negative direction. If it changes in the
/// positive direction we send a [ui.SemanticsAction.scrollUp]. Otherwise, we
/// send [ui.SemanticsAction.scrollDown]. The actual scrolling is then handled
/// by the framework and we receive a [ui.SemanticsUpdate] containing the new
/// [scrollPosition] and child positions.
///
/// "scrollTop" or "scrollLeft" is always reset to an arbitrarily chosen non-
/// zero "neutral" scroll position value. This is done so we have a
/// predictable range of DOM scroll position values. When the amount of
/// contents is less than the size of the viewport the browser snaps
/// "scrollTop" back to zero. If there is more content than available in the
/// viewport "scrollTop" may take positive values.
class Scrollable extends RoleManager {
Scrollable(SemanticsObject semanticsObject)
: super(Role.scrollable, semanticsObject);
/// Disables browser-driven scrolling in the presence of pointer events.
GestureModeCallback _gestureModeListener;
/// Listens to HTML "scroll" gestures detected by the browser.
///
/// This gesture is converted to [ui.SemanticsAction.scrollUp] or
/// [ui.SemanticsAction.scrollDown], depending on the direction.
html.EventListener _scrollListener;
/// The value of the "scrollTop" or "scrollLeft" property of this object's
/// [element] that has zero offset relative to the [scrollPosition].
int _effectiveNeutralScrollPosition = 0;
/// Responds to browser-detected "scroll" gestures.
void _recomputeScrollPosition() {
if (_domScrollPosition != _effectiveNeutralScrollPosition) {
if (!semanticsObject.owner.shouldAcceptBrowserGesture('scroll')) {
return;
}
final bool doScrollForward =
_domScrollPosition > _effectiveNeutralScrollPosition;
_neutralizeDomScrollPosition();
semanticsObject.recomputePositionAndSize();
final int semanticsId = semanticsObject.id;
if (doScrollForward) {
if (semanticsObject.isVerticalScrollContainer) {
window.invokeOnSemanticsAction(
semanticsId, ui.SemanticsAction.scrollUp, null);
} else {
assert(semanticsObject.isHorizontalScrollContainer);
window.invokeOnSemanticsAction(
semanticsId, ui.SemanticsAction.scrollLeft, null);
}
} else {
if (semanticsObject.isVerticalScrollContainer) {
window.invokeOnSemanticsAction(
semanticsId, ui.SemanticsAction.scrollDown, null);
} else {
assert(semanticsObject.isHorizontalScrollContainer);
window.invokeOnSemanticsAction(
semanticsId, ui.SemanticsAction.scrollRight, null);
}
}
}
}
@override
void update() {
if (_scrollListener == null) {
// We need to set touch-action:none explicitly here, despite the fact
// that we already have it on the <body> tag because overflow:scroll
// still causes the browser to take over pointer events in order to
// process scrolling. We don't want that when scrolling is handled by
// the framework.
//
// This is effective only in Chrome. Safari does not implement this
// CSS property. In Safari the `PointerBinding` uses `preventDefault`
// to prevent browser scrolling.
semanticsObject.element.style.touchAction = 'none';
_gestureModeDidChange();
// We neutralize the scroll position after all children have been
// updated. Otherwise the browser does not yet have the sizes of the
// child nodes and resets the scrollTop value back to zero.
semanticsObject.owner.addOneTimePostUpdateCallback(() {
_neutralizeDomScrollPosition();
});
// Memoize the tear-off because Dart does not guarantee that two
// tear-offs of a method on the same instance will produce the same
// object.
_gestureModeListener = (_) {
_gestureModeDidChange();
};
semanticsObject.owner.addGestureModeListener(_gestureModeListener);
_scrollListener = (_) {
_recomputeScrollPosition();
};
semanticsObject.element.addEventListener('scroll', _scrollListener);
}
}
/// The value of "scrollTop" or "scrollLeft", depending on the scroll axis.
int get _domScrollPosition {
if (semanticsObject.isVerticalScrollContainer) {
return semanticsObject.element.scrollTop;
} else {
assert(semanticsObject.isHorizontalScrollContainer);
return semanticsObject.element.scrollLeft;
}
}
/// Resets the scroll position (top or left) to the neutral value.
///
/// The scroll position of the scrollable HTML node that's considered to
/// have zero offset relative to Flutter's notion of scroll position is
/// referred to as "neutral scroll position".
///
/// We always set the scroll position to a non-zero value in order to
/// be able to scroll in the negative direction. When scrollTop/scrollLeft is
/// zero the browser will refuse to scroll back even when there is more
/// content available.
void _neutralizeDomScrollPosition() {
// This value is arbitrary.
const int _canonicalNeutralScrollPosition = 10;
final html.Element element = semanticsObject.element;
if (semanticsObject.isVerticalScrollContainer) {
element.scrollTop = _canonicalNeutralScrollPosition;
// Read back because the effective value depends on the amount of content.
_effectiveNeutralScrollPosition = element.scrollTop;
semanticsObject
..verticalContainerAdjustment =
_effectiveNeutralScrollPosition.toDouble()
..horizontalContainerAdjustment = 0.0;
} else {
element.scrollLeft = _canonicalNeutralScrollPosition;
// Read back because the effective value depends on the amount of content.
_effectiveNeutralScrollPosition = element.scrollLeft;
semanticsObject
..verticalContainerAdjustment = 0.0
..horizontalContainerAdjustment =
_effectiveNeutralScrollPosition.toDouble();
}
}
void _gestureModeDidChange() {
final html.Element element = semanticsObject.element;
switch (semanticsObject.owner.gestureMode) {
case GestureMode.browserGestures:
// overflow:scroll will cause the browser report "scroll" events when
// the accessibility focus shifts outside the visible bounds.
//
// Note that on Android overflow:hidden also works. However, we prefer
// "scroll" because it works both on Android and iOS.
if (semanticsObject.isVerticalScrollContainer) {
element.style.overflowY = 'scroll';
} else {
assert(semanticsObject.isHorizontalScrollContainer);
element.style.overflowX = 'scroll';
}
break;
case GestureMode.pointerEvents:
// We use "hidden" instead of "scroll" so that the browser does
// not "steal" pointer events. Flutter gesture recognizers need
// all pointer events in order to recognize gestures correctly.
if (semanticsObject.isVerticalScrollContainer) {
element.style.overflowY = 'hidden';
} else {
assert(semanticsObject.isHorizontalScrollContainer);
element.style.overflowX = 'hidden';
}
break;
}
}
@override
void dispose() {
final html.CssStyleDeclaration style = semanticsObject.element.style;
assert(_gestureModeListener != null);
style.removeProperty('overflowY');
style.removeProperty('overflowX');
style.removeProperty('touch-action');
if (_scrollListener != null) {
semanticsObject.element.removeEventListener('scroll', _scrollListener);
}
semanticsObject.owner.removeGestureModeListener(_gestureModeListener);
_gestureModeListener = null;
}
}