blob: a9d9c13da7e57b3137d01d171ac718cf10980812 [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 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<Future> 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<Event> _onScrollerStart;
Stream<Event> _onScrollerStartStream;
StreamController<Event> _onScrollerEnd;
Stream<Event> _onScrollerEndStream;
StreamController<Event> _onScrollerDragEnd;
Stream<Event> _onScrollerDragEndStream;
StreamController<Event> _onContentMoved;
Stream<Event> _onContentMovedStream;
StreamController<Event> _onDecelStart;
Stream<Event> _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<Event> get onScrollerStart {
if (_onScrollerStart == null) {
_onScrollerStart = new StreamController<Event>.broadcast(sync: true);
_onScrollerStartStream = _onScrollerStart.stream;
}
return _onScrollerStartStream;
}
Stream<Event> get onScrollerEnd {
if (_onScrollerEnd == null) {
_onScrollerEnd = new StreamController<Event>.broadcast(sync: true);
_onScrollerEndStream = _onScrollerEnd.stream;
}
return _onScrollerEndStream;
}
Stream<Event> get onScrollerDragEnd {
if (_onScrollerDragEnd == null) {
_onScrollerDragEnd = new StreamController<Event>.broadcast(sync: true);
_onScrollerDragEndStream = _onScrollerDragEnd.stream;
}
return _onScrollerDragEndStream;
}
Stream<Event> get onContentMoved {
if (_onContentMoved == null) {
_onContentMoved = new StreamController<Event>.broadcast(sync: true);
_onContentMovedStream = _onContentMoved.stream;
}
return _onContentMovedStream;
}
Stream<Event> get onDecelStart {
if (_onDecelStart == null) {
_onDecelStart = new StreamController<Event>.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;
}