// 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 custom scrolling behavior. * This behavior overrides native scrolling for an area. This area can be a * single defined part of a page, the entire page, or several different parts * of a page. * * To use this scrolling behavior you need to define a frame and the content. * The frame defines the area that the content will scroll within. The frame and * content must both be HTML Elements, with the content being a direct child of * the frame. Usually the frame is smaller in size than the content. This is * not necessary though, if the content is smaller then bouncing will occur to * provide feedback that you are past the scrollable area. * * The scrolling behavior works using the webkit translate3d transformation, * which means browsers that do not have hardware accelerated transformations * will not perform as well using this. Simple scrolling should be fine even * without hardware acceleration, but animating momentum and deceleration is * unacceptably slow without it. There is also the option to use relative * positioning (setting the left and top styles). * * For this to work properly you need to set -webkit-text-size-adjust to 'none' * on an ancestor element of the frame, or on the frame itself. If you forget * this you may see the text content of the scrollable area changing size as it * moves. * * The behavior is intended to support vertical and horizontal scrolling, and * scrolling with momentum when a touch gesture flicks with enough velocity. */ typedef void Callback(); // Helper method to await the completion of 2 futures. void joinFutures(List futures, Callback callback) { int count = 0; int len = futures.length; void helper(value) { count++; if (count == len) { callback(); } } for (Future p in futures) { p.then(helper); } } class Scroller implements Draggable, MomentumDelegate { /** Pixels to move each time an arrow key is pressed. */ static const ARROW_KEY_DELTA = 30; static const SCROLL_WHEEL_VELOCITY = 0.01; static const FAST_SNAP_DECELERATION_FACTOR = 0.84; static const PAGE_KEY_SCROLL_FRACTION = .85; // TODO(jacobr): remove this static variable. static bool _dragInProgress = false; /** The node that will actually scroll. */ Element _element; /** * Frame is the node that will serve as the container for the scrolling * content. */ Element _frame; /** Touch manager to track the events on the scrollable area. */ TouchHandler _touchHandler; Momentum _momentum; StreamController _onScrollerStart; Stream _onScrollerStartStream; StreamController _onScrollerEnd; Stream _onScrollerEndStream; StreamController _onScrollerDragEnd; Stream _onScrollerDragEndStream; StreamController _onContentMoved; Stream _onContentMovedStream; StreamController _onDecelStart; Stream _onDecelStartStream; /** Set if vertical scrolling should be enabled. */ bool verticalEnabled; /** Set if horizontal scrolling should be enabled. */ bool horizontalEnabled; /** * Set if momentum should be enabled. */ bool _momentumEnabled; /** Set which type of scrolling translation technique should be used. */ int _scrollTechnique; /** * The maximum coordinate that the left upper corner of the content can scroll * to. */ Coordinate _maxPoint; /** * An offset to subtract from the maximum coordinate that the left upper * corner of the content can scroll to. */ Coordinate _maxOffset; /** * An offset to add to the minimum coordinate that the left upper corner of * the content can scroll to. */ Coordinate _minOffset; /** Initialize the current content offset. */ Coordinate _contentOffset; // TODO(jacobr): the function type is // [:Function(Element, num, num)->void:]. /** * The function to use that will actually translate the scrollable node. */ Function _setOffsetFunction; /** * Function that returns the content size that can be specified instead of * querying the DOM. */ Function _lookupContentSizeDelegate; Size _scrollSize; Size _contentSize; Coordinate _minPoint; bool _isStopping = false; Coordinate _contentStartOffset; bool _started = false; bool _activeGesture = false; ScrollWatcher _scrollWatcher; Scroller(Element scrollableElem, [this.verticalEnabled = false, this.horizontalEnabled = false, momentumEnabled = true, lookupContentSizeDelegate = null, num defaultDecelerationFactor = 1, int scrollTechnique = null, bool capture = false]) : _momentumEnabled = momentumEnabled, _lookupContentSizeDelegate = lookupContentSizeDelegate, _element = scrollableElem, _frame = scrollableElem.parent, _scrollTechnique = scrollTechnique != null ? scrollTechnique : ScrollerScrollTechnique.TRANSFORM_3D, _minPoint = new Coordinate(0, 0), _maxPoint = new Coordinate(0, 0), _maxOffset = new Coordinate(0, 0), _minOffset = new Coordinate(0, 0), _contentOffset = new Coordinate(0, 0) { _touchHandler = new TouchHandler(this, scrollableElem.parent); _momentum = new Momentum(this, defaultDecelerationFactor); Element parentElem = scrollableElem.parent; assert(parentElem != null); _setOffsetFunction = _getOffsetFunction(_scrollTechnique); _touchHandler.setDraggable(this); _touchHandler.enable(capture); _frame.onMouseWheel.listen((e) { if (e.deltaY != 0 && verticalEnabled || e.deltaX != 0 && horizontalEnabled) { num x = horizontalEnabled ? e.deltaX : 0; num y = verticalEnabled ? e.deltaY : 0; throwDelta(x, y, FAST_SNAP_DECELERATION_FACTOR); e.preventDefault(); } }); _frame.onKeyDown.listen((KeyboardEvent e) { bool handled = false; // We ignore key events where further scrolling in that direction // would have no impact which matches default browser behavior with // nested scrollable areas. switch (e.keyCode) { case 33: // page-up throwDelta(0, _scrollSize.height * PAGE_KEY_SCROLL_FRACTION); handled = true; break; case 34: // page-down throwDelta(0, -_scrollSize.height * PAGE_KEY_SCROLL_FRACTION); handled = true; break; case 35: // End throwTo(_maxPoint.x, _minPoint.y, FAST_SNAP_DECELERATION_FACTOR); handled = true; break; case 36: // Home throwTo(_maxPoint.x, _maxPoint.y, FAST_SNAP_DECELERATION_FACTOR); handled = true; break; /* TODO(jacobr): enable arrow keys when the don't conflict with other application keyboard shortcuts. case 38: // up handled = throwDelta( 0, ARROW_KEY_DELTA, FAST_SNAP_DECELERATION_FACTOR); break; case 40: // down handled = throwDelta( 0, -ARROW_KEY_DELTA, FAST_SNAP_DECELERATION_FACTOR); break; case 37: // left handled = throwDelta( ARROW_KEY_DELTA, 0, FAST_SNAP_DECELERATION_FACTOR); break; case 39: // right handled = throwDelta( -ARROW_KEY_DELTA, 0, FAST_SNAP_DECELERATION_FACTOR); break; */ } if (handled) { e.preventDefault(); } }); // The scrollable element must be relatively positioned. // TODO(jacobr): this assert fires asynchronously which could be confusing. if (_scrollTechnique == ScrollerScrollTechnique.RELATIVE_POSITIONING) { assert(_element.getComputedStyle().position != "static"); } _initLayer(); } Stream get onScrollerStart { if (_onScrollerStart == null) { _onScrollerStart = new StreamController.broadcast(sync: true); _onScrollerStartStream = _onScrollerStart.stream; } return _onScrollerStartStream; } Stream get onScrollerEnd { if (_onScrollerEnd == null) { _onScrollerEnd = new StreamController.broadcast(sync: true); _onScrollerEndStream = _onScrollerEnd.stream; } return _onScrollerEndStream; } Stream get onScrollerDragEnd { if (_onScrollerDragEnd == null) { _onScrollerDragEnd = new StreamController.broadcast(sync: true); _onScrollerDragEndStream = _onScrollerDragEnd.stream; } return _onScrollerDragEndStream; } Stream get onContentMoved { if (_onContentMoved == null) { _onContentMoved = new StreamController.broadcast(sync: true); _onContentMovedStream = _onContentMoved.stream; } return _onContentMovedStream; } Stream get onDecelStart { if (_onDecelStart == null) { _onDecelStart = new StreamController.broadcast(sync: true); _onDecelStartStream = _onDecelStart.stream; } return _onDecelStartStream; } /** * Add a scroll listener. This allows other classes to subscribe to scroll * notifications from this scroller. */ void addScrollListener(ScrollListener listener) { if (_scrollWatcher == null) { _scrollWatcher = new ScrollWatcher(this); _scrollWatcher.initialize(); } _scrollWatcher.addListener(listener); } /** * Adjust the new calculated scroll position based on the minimum allowed * position and returns the adjusted scroll value. */ num _adjustValue(num newPosition, num minPosition, num maxPosition) { assert(minPosition <= maxPosition); if (newPosition < minPosition) { newPosition -= (newPosition - minPosition) / 2; } else { if (newPosition > maxPosition) { newPosition -= (newPosition - maxPosition) / 2; } } return newPosition; } /** * Coordinate we would end up at if we did nothing. */ Coordinate get currentTarget { Coordinate end = _momentum.destination; if (end == null) { end = _contentOffset; } return end; } Coordinate get contentOffset => _contentOffset; /** * Animate the position of the scroller to the specified [x], [y] coordinates * by applying the throw gesture with the correct velocity to end at that * location. */ void throwTo(num x, num y, [num decelerationFactor = null]) { reconfigure(() { final snappedTarget = _snapToBounds(x, y); // If a deceleration factor is not specified, use the existing // deceleration factor specified by the momentum simulator. if (decelerationFactor == null) { decelerationFactor = _momentum.decelerationFactor; } if (snappedTarget != currentTarget) { _momentum.abort(); _startDeceleration( _momentum.calculateVelocity( _contentOffset, snappedTarget, decelerationFactor), decelerationFactor); if (_onDecelStart != null) { _onDecelStart.add(new Event(ScrollerEventType.DECEL_START)); } } }); } void throwDelta(num deltaX, num deltaY, [num decelerationFactor = null]) { Coordinate start = _contentOffset; Coordinate end = currentTarget; int x = end.x.toInt(); int y = end.y.toInt(); // If we are throwing in the opposite direction of the existing momentum, // cancel the current momentum. if (deltaX != 0 && deltaX.isNegative != (end.x - start.x).isNegative) { x = start.x; } if (deltaY != 0 && deltaY.isNegative != (end.y - start.y).isNegative) { y = start.y; } x += deltaX.toInt(); y += deltaY.toInt(); throwTo(x, y, decelerationFactor); } void setPosition(num x, num y) { _momentum.abort(); _contentOffset.x = x; _contentOffset.y = y; _snapContentOffsetToBounds(); _setContentOffset(_contentOffset.x, _contentOffset.y); } /** * Adjusted content size is a size with the combined largest height and width * of both the content and the frame. */ Size _getAdjustedContentSize() { return new Size(Math.max(_scrollSize.width, _contentSize.width), Math.max(_scrollSize.height, _contentSize.height)); } // TODO(jmesserly): these should be properties instead of get* methods num getDefaultVerticalOffset() => _maxPoint.y; Element getElement() => _element; Element getFrame() => _frame; num getHorizontalOffset() => _contentOffset.x; /** * [x] Value to use as reference for percent measurement. If * none is provided then the content's current x offset will be used. * Returns the percent of the page scrolled horizontally. */ num getHorizontalScrollPercent([num x = null]) { x = x != null ? x : _contentOffset.x; return (x - _minPoint.x) / (_maxPoint.x - _minPoint.x); } num getMaxPointY() => _maxPoint.y; num getMinPointY() => _minPoint.y; Momentum get momentum => _momentum; /** * Provide access to the touch handler that the scroller created to manage * touch events. */ TouchHandler getTouchHandler() => _touchHandler; num getVerticalOffset() => _contentOffset.y; /** * [y] value is used as reference for percent measurement. If * none is provided then the content's current y offset will be used. */ num getVerticalScrollPercent([num y = null]) { y = y != null ? y : _contentOffset.y; return (y - _minPoint.y) / Math.max(1, _maxPoint.y - _minPoint.y); } /** * Initialize the dom elements necessary for the scrolling to work. */ void _initLayer() { // The scrollable node provided to Scroller must be a direct child // of the scrollable frame. // TODO(jacobr): Figure out why this is failing on dartium. // assert(_element.parent == _frame); _setContentOffset(_maxPoint.x, _maxPoint.y); } void onDecelerate(num x, num y) { _setContentOffset(x, y); } void onDecelerationEnd() { if (_onScrollerEnd != null) { _onScrollerEnd.add(new Event(ScrollerEventType.SCROLLER_END)); } _started = false; } void onDragEnd() { _dragInProgress = false; bool decelerating = false; if (_activeGesture) { if (_momentumEnabled) { decelerating = _startDeceleration(_touchHandler.getEndVelocity()); } } if (_onScrollerDragEnd != null) { _onScrollerDragEnd.add(new Event(ScrollerEventType.DRAG_END)); } if (!decelerating) { _snapContentOffsetToBounds(); if (_onScrollerEnd != null) { _onScrollerEnd.add(new Event(ScrollerEventType.SCROLLER_END)); } _started = false; } else { if (_onDecelStart != null) { _onDecelStart.add(new Event(ScrollerEventType.DECEL_START)); } } _activeGesture = false; } void onDragMove() { if (_isStopping || (!_activeGesture && _dragInProgress)) { return; } assert(_contentStartOffset != null); // Content start not set Coordinate contentStart = _contentStartOffset; num newX = contentStart.x + _touchHandler.getDragDeltaX(); num newY = contentStart.y + _touchHandler.getDragDeltaY(); newY = _shouldScrollVertically() ? _adjustValue(newY, _minPoint.y, _maxPoint.y) : 0; newX = _shouldScrollHorizontally() ? _adjustValue(newX, _minPoint.x, _maxPoint.x) : 0; if (!_activeGesture) { _activeGesture = true; _dragInProgress = true; } if (!_started) { _started = true; if (_onScrollerStart != null) { _onScrollerStart.add(new Event(ScrollerEventType.SCROLLER_START)); } } _setContentOffset(newX, newY); } bool onDragStart(TouchEvent e) { if (e.touches.length > 1) { return false; } bool shouldHorizontal = _shouldScrollHorizontally(); bool shouldVertical = _shouldScrollVertically(); bool verticalish = _touchHandler.getDragDeltaY().abs() > _touchHandler.getDragDeltaX().abs(); return !!(shouldVertical || shouldHorizontal && !verticalish); } void onTouchEnd() {} /** * Prepare the scrollable area for possible movement. */ bool onTouchStart(TouchEvent e) { reconfigure(() { final touch = e.touches[0]; if (_momentum.decelerating) { e.preventDefault(); e.stopPropagation(); stop(); } _contentStartOffset = _contentOffset.clone(); _snapContentOffsetToBounds(); }); return true; } /** * Recalculate dimensions of the frame and the content. Adjust the minPoint * and maxPoint allowed for scrolling and scroll to a valid position. Call * this method if you know the frame or content has been updated. Called * internally on every touchstart event the frame receives. */ void reconfigure(Callback callback) { _resize(() { _snapContentOffsetToBounds(); callback(); }); } void reset() { stop(); _touchHandler.reset(); _maxOffset.x = 0; _maxOffset.y = 0; _minOffset.x = 0; _minOffset.y = 0; reconfigure(() => _setContentOffset(_maxPoint.x, _maxPoint.y)); } /** * Recalculate dimensions of the frame and the content. Adjust the minPoint * and maxPoint allowed for scrolling. */ void _resize(Callback callback) { scheduleMicrotask(() { if (_lookupContentSizeDelegate != null) { _contentSize = _lookupContentSizeDelegate(); } else { _contentSize = new Size(_element.scrollWidth, _element.scrollHeight); } _scrollSize = new Size(_frame.offset.width, _frame.offset.height); Size adjusted = _getAdjustedContentSize(); _maxPoint = new Coordinate(-_maxOffset.x, -_maxOffset.y); _minPoint = new Coordinate( Math.min( _scrollSize.width - adjusted.width + _minOffset.x, _maxPoint.x), Math.min(_scrollSize.height - adjusted.height + _minOffset.y, _maxPoint.y)); callback(); }); } Coordinate _snapToBounds(num x, num y) { num clampX = GoogleMath.clamp(_minPoint.x, x, _maxPoint.x); num clampY = GoogleMath.clamp(_minPoint.y, y, _maxPoint.y); return new Coordinate(clampX, clampY); } /** * Translate the content to a new position specified in px. */ void _setContentOffset(num x, num y) { _contentOffset.x = x; _contentOffset.y = y; _setOffsetFunction(_element, x, y); if (_onContentMoved != null) { _onContentMoved.add(new Event(ScrollerEventType.CONTENT_MOVED)); } } /** * Enable or disable momentum. */ void setMomentum(bool enable) { _momentumEnabled = enable; } /** * Sets the vertical scrolled offset of the element where [y] is the amount * of vertical space to be scrolled, in pixels. */ void setVerticalOffset(num y) { _setContentOffset(_contentOffset.x, y); } /** * Whether the scrollable area should scroll horizontally. Only * returns true if the client has enabled horizontal scrolling, and the * content is wider than the frame. */ bool _shouldScrollHorizontally() { return horizontalEnabled && _scrollSize.width < _contentSize.width; } /** * Whether the scrollable area should scroll vertically. Only * returns true if the client has enabled vertical scrolling. * Vertical bouncing will occur even if frame is taller than content, because * this is what iPhone web apps tend to do. If this is not the desired * behavior, either disable vertical scrolling for this scroller or add a * 'bouncing' parameter to this interface. */ bool _shouldScrollVertically() { return verticalEnabled; } /** * In the event that the content is currently beyond the bounds of * the frame, snap it back in to place. */ void _snapContentOffsetToBounds() { num clampX = GoogleMath.clamp(_minPoint.x, _contentOffset.x, _maxPoint.x); num clampY = GoogleMath.clamp(_minPoint.y, _contentOffset.y, _maxPoint.y); if (_contentOffset.x != clampX || _contentOffset.y != clampY) { _setContentOffset(clampX, clampY); } } /** * Initiate the deceleration behavior given a flick [velocity]. * Returns true if deceleration has been initiated. */ bool _startDeceleration(Coordinate velocity, [num decelerationFactor = null]) { if (!_shouldScrollHorizontally()) { velocity.x = 0; } if (!_shouldScrollVertically()) { velocity.y = 0; } assert(_minPoint != null); // Min point is not set assert(_maxPoint != null); // Max point is not set return _momentum.start( velocity, _minPoint, _maxPoint, _contentOffset, decelerationFactor); } Coordinate stop() { return _momentum.stop(); } /** * Stop the deceleration of the scrollable content given a new position in px. */ void _stopDecelerating(num x, num y) { _momentum.stop(); _setContentOffset(x, y); } static Function _getOffsetFunction(int scrollTechnique) { return scrollTechnique == ScrollerScrollTechnique.TRANSFORM_3D ? (el, x, y) { FxUtil.setTranslate(el, x, y, 0); } : (el, x, y) { FxUtil.setLeftAndTop(el, x, y); }; } } // TODO(jacobr): cleanup this class of enum constants. class ScrollerEventType { static const SCROLLER_START = "scroller:scroll_start"; static const SCROLLER_END = "scroller:scroll_end"; static const DRAG_END = "scroller:drag_end"; static const CONTENT_MOVED = "scroller:content_moved"; static const DECEL_START = "scroller:decel_start"; } class ScrollerScrollTechnique { static const TRANSFORM_3D = 1; static const RELATIVE_POSITIONING = 2; }