| // 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); |
| } |
| } |
| } |