blob: aebd48d7d7193860cb2e778f0c23ad864b9ad6c9 [file] [log] [blame]
// Copyright (c) 2011, the Dart project authors. Please see the AUTHORS file
// for details. 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.9
part of touch;
/**
* Implementation of a scrollbar for the custom scrolling behavior
* defined in [:Scroller:].
*/
class Scrollbar implements ScrollListener {
/**
* The minimum size of scrollbars when not compressed.
*/
static const _MIN_SIZE = 30;
/**
* The minimum compressed size of scrollbars. Scrollbars are compressed when
* the content is stretching past its boundaries.
*/
static const _MIN_COMPRESSED_SIZE = 8;
/** Padding in pixels to add above and bellow the scrollbar. */
static const _PADDING_LENGTH = 10;
/**
* The amount of time to wait before hiding scrollbars after showing them.
* Measured in ms.
*/
static const _DISPLAY_TIME = 300;
static const DRAG_CLASS_NAME = 'drag';
Scroller _scroller;
Element _frame;
bool _scrollInProgress = false;
bool _scrollBarDragInProgressValue = false;
/**
* Cached values of height and width. Keys will be 'height' and 'width'
* depending on if they are applied to vertical or horizontal scrollbar.
*/
Map<String, num> _cachedSize;
/**
* This bound function will be used as the input to window.setTimeout when
* scheduling the hiding of the scrollbars.
*/
Function _boundHideFn;
Element _verticalElement;
Element _horizontalElement;
int _currentScrollStartMouse;
num _currentScrollStartOffset;
bool _currentScrollVertical;
num _currentScrollRatio;
Timer _timer;
bool _displayOnHover;
bool _hovering = false;
Scrollbar(Scroller scroller, [displayOnHover = true])
: _displayOnHover = displayOnHover,
_scroller = scroller,
_frame = scroller.getFrame(),
_cachedSize = new Map<String, num>() {
_boundHideFn = () {
_showScrollbars(false);
};
}
bool get _scrollBarDragInProgress => _scrollBarDragInProgressValue;
void set _scrollBarDragInProgress(bool value) {
_scrollBarDragInProgressValue = value;
_toggleClass(
_verticalElement, DRAG_CLASS_NAME, value && _currentScrollVertical);
_toggleClass(
_horizontalElement, DRAG_CLASS_NAME, value && !_currentScrollVertical);
}
// TODO(jacobr): move this helper method into the DOM.
void _toggleClass(Element e, String className, bool enabled) {
if (enabled) {
if (!e.classes.contains(className)) {
e.classes.add(className);
}
} else {
e.classes.remove(className);
}
}
/**
* Initializes elements and event handlers. Must be called after
* construction and before usage.
*/
void initialize() {
// Don't initialize if we have already been initialized.
// TODO(jacobr): remove this once bugs are fixed and enterDocument is only
// called once by each view.
if (_verticalElement != null) {
return;
}
_verticalElement = new Element.html(
'<div class="touch-scrollbar touch-scrollbar-vertical"></div>');
_horizontalElement = new Element.html(
'<div class="touch-scrollbar touch-scrollbar-horizontal"></div>');
_scroller.addScrollListener(this);
Element scrollerEl = _scroller.getElement();
if (!Device.supportsTouch) {
_addEventListeners(
_verticalElement, _onStart, _onMove, _onEnd, _onEnd, true);
_addEventListeners(
_horizontalElement, _onStart, _onMove, _onEnd, _onEnd, true);
}
_scroller.addScrollListener(this);
_showScrollbars(false);
_scroller.onScrollerStart.listen(_onScrollerStart);
_scroller.onScrollerEnd.listen(_onScrollerEnd);
if (_displayOnHover) {
// TODO(jacobr): rather than adding all these event listeners we could
// instead attach a single global event listener and let data in the
// DOM drive.
_frame.onClick.listen((Event e) {
// Always focus on click as one of our children isn't all focused.
if (!_frame.contains(document.activeElement)) {
scrollerEl.focus();
}
});
_frame.onMouseOver.listen((Event e) {
final activeElement = document.activeElement;
// TODO(jacobr): don't steal focus from a child element or a truly
// focusable element. Only support stealing focus from another
// element that was given fake focus.
if (activeElement is BodyElement ||
(!_frame.contains(activeElement) && activeElement is DivElement)) {
scrollerEl.focus();
}
if (_hovering == false) {
_hovering = true;
_cancelTimeout();
_showScrollbars(true);
refresh();
}
});
_frame.onMouseOut.listen((e) {
_hovering = false;
// Start hiding immediately if we aren't
// scrolling or already in the process of
// hiding the scrollbar
if (!_scrollInProgress && _timer == null) {
_boundHideFn();
}
});
}
}
void _onStart(/*MouseEvent | Touch*/ e) {
Element elementOver = e.target;
if (elementOver == _verticalElement || elementOver == _horizontalElement) {
_currentScrollVertical = elementOver == _verticalElement;
if (_currentScrollVertical) {
_currentScrollStartMouse = e.page.y;
_currentScrollStartOffset = _scroller.getVerticalOffset();
} else {
_currentScrollStartMouse = e.page.x;
_currentScrollStartOffset = _scroller.getHorizontalOffset();
}
_refreshScrollRatio();
_scrollBarDragInProgress = true;
_scroller._momentum.abort();
e.stopPropagation();
}
}
void _refreshScrollRatio() {
Size contentSize = _scroller._getAdjustedContentSize();
if (_currentScrollVertical) {
_refreshScrollRatioHelper(
_scroller._scrollSize.height, contentSize.height);
} else {
_refreshScrollRatioHelper(_scroller._scrollSize.width, contentSize.width);
}
}
void _refreshScrollRatioHelper(num frameSize, num contentSize) {
num frameTravelDistance = frameSize -
_defaultScrollSize(frameSize, contentSize) -
_PADDING_LENGTH * 2;
if (frameTravelDistance < 0.001) {
_currentScrollRatio = 0;
} else {
_currentScrollRatio = (contentSize - frameSize) / frameTravelDistance;
}
}
void _onMove(/*MouseEvent | Touch*/ e) {
if (!_scrollBarDragInProgress) {
return;
}
_refreshScrollRatio();
int coordinate = _currentScrollVertical ? e.page.y : e.page.x;
num delta = (coordinate - _currentScrollStartMouse) * _currentScrollRatio;
if (delta != 0) {
num x;
num y;
_currentScrollStartOffset -= delta;
if (_currentScrollVertical) {
x = _scroller.getHorizontalOffset();
y = _currentScrollStartOffset.toInt();
} else {
x = _currentScrollStartOffset.toInt();
y = _scroller.getVerticalOffset();
}
_scroller.setPosition(x, y);
}
_currentScrollStartMouse = coordinate;
}
void _onEnd(Event e) {
_scrollBarDragInProgress = false;
// TODO(jacobr): make scrollbar less tightly coupled to the scroller.
_scroller._onScrollerDragEnd.add(new Event(ScrollerEventType.DRAG_END));
}
/**
* When scrolling ends, schedule a timeout to hide the scrollbars.
*/
void _onScrollerEnd(Event e) {
_cancelTimeout();
_timer =
new Timer(const Duration(milliseconds: _DISPLAY_TIME), _boundHideFn);
_scrollInProgress = false;
}
void onScrollerMoved(num scrollX, num scrollY, bool decelerating) {
if (_scrollInProgress == false) {
// Display the scrollbar and then immediately prepare to hide it...
_onScrollerStart(null);
_onScrollerEnd(null);
}
updateScrollbars(scrollX, scrollY);
}
void refresh() {
if (_scrollInProgress == false && _hovering == false) {
// No need to refresh if not visible.
return;
}
_scroller._resize(() {
updateScrollbars(
_scroller.getHorizontalOffset(), _scroller.getVerticalOffset());
});
}
void updateScrollbars(num scrollX, num scrollY) {
Size contentSize = _scroller._getAdjustedContentSize();
if (_scroller._shouldScrollHorizontally()) {
num scrollPercentX = _scroller.getHorizontalScrollPercent(scrollX);
_updateScrollbar(_horizontalElement, scrollX, scrollPercentX,
_scroller._scrollSize.width, contentSize.width, 'right', 'width');
}
if (_scroller._shouldScrollVertically()) {
num scrollPercentY = _scroller.getVerticalScrollPercent(scrollY);
_updateScrollbar(_verticalElement, scrollY, scrollPercentY,
_scroller._scrollSize.height, contentSize.height, 'bottom', 'height');
}
}
/**
* When scrolling starts, show scrollbars and clear hide intervals.
*/
void _onScrollerStart(Event e) {
_scrollInProgress = true;
_cancelTimeout();
_showScrollbars(true);
}
void _cancelTimeout() {
if (_timer != null) {
_timer.cancel();
_timer = null;
}
}
/**
* Show or hide the scrollbars by changing the opacity.
*/
void _showScrollbars(bool show) {
if (_hovering == true && _displayOnHover) {
show = true;
}
_toggleOpacity(_verticalElement, show);
_toggleOpacity(_horizontalElement, show);
}
_toggleOpacity(Element element, bool show) {
if (show) {
element.style.removeProperty("opacity");
} else {
element.style.opacity = '0';
}
}
num _defaultScrollSize(num frameSize, num contentSize) {
return GoogleMath.clamp(
(frameSize - _PADDING_LENGTH * 2) * frameSize / contentSize,
_MIN_SIZE,
frameSize - _PADDING_LENGTH * 2);
}
/**
* Update the vertical or horizontal scrollbar based on the new scroll
* properties. The CSS property to adjust for position (bottom|right) is
* specified by [cssPos]. The CSS property to adjust for size (height|width)
* is specified by [cssSize].
*/
void _updateScrollbar(Element element, num offset, num scrollPercent,
num frameSize, num contentSize, String cssPos, String cssSize) {
if (!_cachedSize.containsKey(cssSize)) {
if (offset == null || contentSize < frameSize) {
return;
}
_frame.nodes.add(element);
}
num stretchPercent;
if (scrollPercent > 1) {
stretchPercent = scrollPercent - 1;
} else {
stretchPercent = scrollPercent < 0 ? -scrollPercent : 0;
}
num scrollPx = stretchPercent * (contentSize - frameSize);
num maxSize = _defaultScrollSize(frameSize, contentSize);
num size = Math.max(_MIN_COMPRESSED_SIZE, maxSize - scrollPx);
num maxOffset = frameSize - size - _PADDING_LENGTH * 2;
num pos = GoogleMath.clamp(scrollPercent * maxOffset, 0, maxOffset) +
_PADDING_LENGTH;
pos = pos.round();
size = size.round();
final style = element.style;
style.setProperty(cssPos, '${pos}px', '');
if (_cachedSize[cssSize] != size) {
_cachedSize[cssSize] = size;
style.setProperty(cssSize, '${size}px', '');
}
if (element.parent == null) {
_frame.nodes.add(element);
}
}
}