blob: eea39a03d8d8bbc46291a49bd656215cccac0c79 [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.
part of touch;
/**
* Implementations can be used to simulate the deceleration of an element within
* a certain region. To use this behavior you need to provide an initial
* velocity that is meant to represent the gesture that is initiating this
* deceleration. You also provide the bounds of the region that the element
* exists in, and the current offset of the element within that region. The
* transitions will have the element decelerate to rest, or stretch past the
* offset boundaries and then come to rest.
*
* This is primarily designed to solve the problem of slow scrolling in mobile
* safari. You can use this along with the [Scroller] behavior to make a
* scrollable area scroll the same way it would in a native application.
*
* Implementations of this interface do not maintain any references to HTML
* elements, and therefore cannot do any redrawing of elements. They only
* calculates where the element should be on an interval. It is the delegate's
* responsibility to redraw the element when the onDecelerate callback is
* invoked. It is recommended that you move the element with a hardware
* accelerated method such as using 'translate3d' on the element's
* -webkit-transform style property.
*/
abstract class Momentum {
factory Momentum(MomentumDelegate delegate,
[num defaultDecelerationFactor = 1]) =>
new TimeoutMomentum(delegate, defaultDecelerationFactor);
bool get decelerating;
num get decelerationFactor;
/**
* Transition end handler. This function must be invoked after any transition
* that occurred as a result of a call to the delegate's onDecelerate callback.
*/
void onTransitionEnd();
/**
* Start decelerating.
* The [velocity] passed should be in terms of number of pixels / millisecond.
* [minCoord] and [maxCoord] specify the content's scrollable boundary.
* The current offset of the element within its boundaries is specified by
* [initialOffset].
* Returns true if deceleration has been initiated.
*/
bool start(Coordinate velocity, Coordinate minCoord, Coordinate maxCoord,
Coordinate initialOffset,
[num decelerationFactor]);
/**
* Calculate the velocity required to transition between coordinates [start]
* and [target] optionally specifying a custom [decelerationFactor].
*/
Coordinate calculateVelocity(Coordinate start, Coordinate target,
[num decelerationFactor]);
/** Stop decelerating and return the current velocity. */
Coordinate stop();
/** Aborts decelerating without dispatching any notification events. */
void abort();
/** null if no transition is in progress. */
Coordinate get destination;
}
/**
* Momentum Delegate interface.
* You are required to implement this interface in order to use the
* Momentum behavior.
*/
abstract class MomentumDelegate {
/**
* Callback for a deceleration step. The delegate is responsible for redrawing
* the element in its new position specified in px.
*/
void onDecelerate(num x, num y);
/**
* Callback for end of deceleration.
*/
void onDecelerationEnd();
}
class BouncingState {
static const NOT_BOUNCING = 0;
static const BOUNCING_AWAY = 1;
static const BOUNCING_BACK = 2;
}
class _Move {
final num x;
final num y;
final num vx;
final num vy;
final num time;
_Move(this.x, this.y, this.vx, this.vy, this.time);
}
/**
* Secant method root solver helper class.
* We use http://en.wikipedia.org/wiki/Secant_method
* falling back to the http://en.wikipedia.org/wiki/Bisection_method
* if it doesn't appear we are converging properlty.
* TODO(jacobr): simplify the code so we don't have to use this solver
* class at all.
*/
class Solver {
static num solve(num Function(num) fn, num targetY, num startX,
[int maxIterations = 50]) {
num lastX = 0;
num lastY = fn(lastX);
num deltaX;
num deltaY;
num minX = null;
num maxX = null;
num x = startX;
num delta = startX;
for (int i = 0; i < maxIterations; i++) {
num y = fn(x);
if (y.round() == targetY.round()) {
return x;
}
if (y > targetY) {
maxX = x;
} else {
minX = x;
}
num errorY = targetY - y;
deltaX = x - lastX;
deltaY = y - lastY;
lastX = x;
lastY = y;
// Avoid divide by zero and as a hack just repeat the previous delta.
// Obviously this is a little dangerous and we might not converge.
if (deltaY != 0) {
delta = errorY * deltaX / deltaY;
}
x += delta;
if (minX != null && maxX != null && (x > minX || x < maxX)) {
// Fall back to binary search.
x = (minX + maxX) / 2;
}
}
window.console.warn('''Could not find an exact solution. LastY=${lastY},
targetY=${targetY} lastX=$lastX delta=$delta deltaX=$deltaX
deltaY=$deltaY''');
return x;
}
}
/**
* Helper class modeling the physics of a throwable scrollable area along a
* single dimension.
*/
class SingleDimensionPhysics {
/** The number of frames per second the animation should run at. */
static const _FRAMES_PER_SECOND = 60;
/**
* The spring coefficient for when the element has passed a boundary and is
* decelerating to change direction and bounce back. Each frame, the velocity
* will be changed by x times this coefficient, where x is the current stretch
* value of the element from its boundary. This will end when velocity reaches
* zero.
*/
static const _PRE_BOUNCE_COEFFICIENT = 7.0 / _FRAMES_PER_SECOND;
/**
* The spring coefficient for when the element is bouncing back from a
* stretched offset to a min or max position. Each frame, the velocity will
* be changed to x times this coefficient, where x is the current stretch
* value of the element from its boundary. This will end when the stretch
* value reaches 0.
*/
static const _POST_BOUNCE_COEFFICIENT = 7.0 / _FRAMES_PER_SECOND;
/**
* The number of milliseconds per animation frame.
*/
static const _MS_PER_FRAME = 1000.0 / _FRAMES_PER_SECOND;
/**
* The constant factor applied to velocity at each frame to simulate
* deceleration.
*/
static const _DECELERATION_FACTOR = 0.97;
static const _MAX_VELOCITY_STATIC_FRICTION = 0.08 * _MS_PER_FRAME;
static const _DECELERATION_FACTOR_STATIC_FRICTION = 0.92;
/**
* Minimum velocity required to start or continue deceleration, in
* pixels/frame. This is equivalent to 0.25 px/ms.
*/
static const _MIN_VELOCITY = 0.25 * _MS_PER_FRAME;
/**
* Minimum velocity during a step, in pixels/frame. This is equivalent to 0.01
* px/ms.
*/
static const _MIN_STEP_VELOCITY = 0.01 * _MS_PER_FRAME;
/**
* Boost the initial velocity by a certain factor before applying momentum.
* This just gives the momentum a better feel.
*/
static const _INITIAL_VELOCITY_BOOST_FACTOR = 1.25;
/**
* Additional deceleration factor to apply for the current move only. This
* is helpful for cases such as scroll wheel scrolling where the default
* amount of deceleration is inadequate.
*/
num customDecelerationFactor = 1;
num _minCoord;
num _maxCoord;
/** The bouncing state. */
int _bouncingState;
num velocity;
num _currentOffset;
/**
* constant used when guessing at the velocity required to throw to a specific
* location. Chosen arbitrarily. All that really matters is that the velocity
* is large enough that a throw gesture will occur.
*/
static const _VELOCITY_GUESS = 20;
SingleDimensionPhysics() : _bouncingState = BouncingState.NOT_BOUNCING {}
void configure(num minCoord, num maxCoord, num initialOffset,
num customDecelerationFactor_, num velocity_) {
_bouncingState = BouncingState.NOT_BOUNCING;
_minCoord = minCoord;
_maxCoord = maxCoord;
_currentOffset = initialOffset;
this.customDecelerationFactor = customDecelerationFactor_;
_adjustInitialVelocityAndBouncingState(velocity_);
}
num solve(
num initialOffset, num targetOffset, num customDecelerationFactor_) {
initialOffset = initialOffset.round();
targetOffset = targetOffset.round();
if (initialOffset == targetOffset) {
return 0;
}
return Solver.solve((num velocity_) {
// Don't specify min and max coordinates as we don't need to bother
// with the simulating bouncing off the edges.
configure(null, null, initialOffset.round(), customDecelerationFactor_,
velocity_);
stepAll();
return _currentOffset;
}, targetOffset,
targetOffset > initialOffset ? _VELOCITY_GUESS : -_VELOCITY_GUESS);
}
/**
* Helper method to calculate initial velocity.
* The [velocity] passed here should be in terms of number of
* pixels / millisecond. Returns the adjusted x and y velocities.
*/
void _adjustInitialVelocityAndBouncingState(num v) {
velocity = v * _MS_PER_FRAME * _INITIAL_VELOCITY_BOOST_FACTOR;
if (velocity.abs() < _MIN_VELOCITY) {
if (_minCoord != null && _currentOffset < _minCoord) {
velocity = (_minCoord - _currentOffset) * _POST_BOUNCE_COEFFICIENT;
velocity = Math.max(velocity, _MIN_STEP_VELOCITY);
_bouncingState = BouncingState.BOUNCING_BACK;
} else if (_maxCoord != null && _currentOffset > _maxCoord) {
velocity = (_currentOffset - _maxCoord) * _POST_BOUNCE_COEFFICIENT;
velocity = -Math.max(velocity, _MIN_STEP_VELOCITY);
_bouncingState = BouncingState.BOUNCING_BACK;
}
}
}
/**
* Apply deceleration.
*/
void _adjustVelocity() {
num speed = velocity.abs();
velocity *= _DECELERATION_FACTOR;
if (customDecelerationFactor != null) {
velocity *= customDecelerationFactor;
}
// This isn't really how static friction works but it is a plausible
// approximation.
if (speed < _MAX_VELOCITY_STATIC_FRICTION) {
velocity *= _DECELERATION_FACTOR_STATIC_FRICTION;
}
num stretchDistance;
if (_minCoord != null && _currentOffset < _minCoord) {
stretchDistance = _minCoord - _currentOffset;
} else {
if (_maxCoord != null && _currentOffset > _maxCoord) {
stretchDistance = _maxCoord - _currentOffset;
}
}
if (stretchDistance != null) {
if (stretchDistance * velocity < 0) {
_bouncingState = _bouncingState == BouncingState.BOUNCING_BACK
? BouncingState.NOT_BOUNCING
: BouncingState.BOUNCING_AWAY;
velocity += stretchDistance * _PRE_BOUNCE_COEFFICIENT;
} else {
_bouncingState = BouncingState.BOUNCING_BACK;
velocity = stretchDistance > 0
? Math.max(
stretchDistance * _POST_BOUNCE_COEFFICIENT, _MIN_STEP_VELOCITY)
: Math.min(stretchDistance * _POST_BOUNCE_COEFFICIENT,
-_MIN_STEP_VELOCITY);
}
} else {
_bouncingState = BouncingState.NOT_BOUNCING;
}
}
void step() {
// It is common for scrolling to be disabled so in these cases we want to
// avoid needless calculations.
if (velocity != null) {
_currentOffset += velocity;
_adjustVelocity();
}
}
void stepAll() {
while (!isDone()) {
step();
}
}
/**
* Whether or not the current velocity is above the threshold required to
* continue decelerating.
*/
bool isVelocityAboveThreshold(num threshold) {
return velocity.abs() >= threshold;
}
bool isDone() {
return _bouncingState == BouncingState.NOT_BOUNCING &&
!isVelocityAboveThreshold(_MIN_STEP_VELOCITY);
}
}
/**
* Implementation of a momentum strategy using webkit-transforms
* and timeouts.
*/
class TimeoutMomentum implements Momentum {
SingleDimensionPhysics physicsX;
SingleDimensionPhysics physicsY;
Coordinate _previousOffset;
Queue<_Move> _moves;
num _stepTimeout;
bool _decelerating;
MomentumDelegate _delegate;
int _nextY;
int _nextX;
Coordinate _minCoord;
Coordinate _maxCoord;
num _customDecelerationFactor;
num _defaultDecelerationFactor;
TimeoutMomentum(this._delegate, [num defaultDecelerationFactor = 1])
: _defaultDecelerationFactor = defaultDecelerationFactor,
_decelerating = false,
_moves = new Queue<_Move>(),
physicsX = new SingleDimensionPhysics(),
physicsY = new SingleDimensionPhysics();
/**
* Calculate and return the moves for the deceleration motion.
*/
void _calculateMoves() {
_moves.clear();
num time = TimeUtil.now();
while (!physicsX.isDone() || !physicsY.isDone()) {
_stepWithoutAnimation();
time += SingleDimensionPhysics._MS_PER_FRAME;
if (_isStepNecessary()) {
_moves.add(new _Move(
_nextX, _nextY, physicsX.velocity, physicsY.velocity, time));
_previousOffset.y = _nextY;
_previousOffset.x = _nextX;
}
}
}
bool get decelerating => _decelerating;
num get decelerationFactor => _customDecelerationFactor;
/**
* Checks whether or not an animation step is necessary or not. Animations
* steps are not necessary when the velocity gets so low that in several
* frames the offset is the same.
* Returns true if there is movement to be done in the next frame.
*/
bool _isStepNecessary() {
return _nextY != _previousOffset.y || _nextX != _previousOffset.x;
}
/**
* The [TouchHandler] requires this function but we don't need to do
* anything here.
*/
void onTransitionEnd() {}
Coordinate calculateVelocity(Coordinate start_, Coordinate target,
[num decelerationFactor = null]) {
return new Coordinate(
physicsX.solve(start_.x, target.x, decelerationFactor),
physicsY.solve(start_.y, target.y, decelerationFactor));
}
bool start(Coordinate velocity, Coordinate minCoord, Coordinate maxCoord,
Coordinate initialOffset,
[num decelerationFactor = null]) {
_customDecelerationFactor = _defaultDecelerationFactor;
if (decelerationFactor != null) {
_customDecelerationFactor = decelerationFactor;
}
if (_stepTimeout != null) {
Env.cancelRequestAnimationFrame(_stepTimeout);
_stepTimeout = null;
}
assert(_stepTimeout == null);
assert(minCoord.x <= maxCoord.x);
assert(minCoord.y <= maxCoord.y);
_previousOffset = initialOffset.clone();
physicsX.configure(minCoord.x, maxCoord.x, initialOffset.x,
_customDecelerationFactor, velocity.x);
physicsY.configure(minCoord.y, maxCoord.y, initialOffset.y,
_customDecelerationFactor, velocity.y);
if (!physicsX.isDone() || !physicsY.isDone()) {
_calculateMoves();
if (!_moves.isEmpty) {
num firstTime = _moves.first.time;
_stepTimeout = Env.requestAnimationFrame(_step, null, firstTime);
_decelerating = true;
return true;
}
}
_decelerating = false;
return false;
}
/**
* Update the x, y values of the element offset without actually moving the
* element. This is done because we store decimal values for x, y for
* precision, but moving is only required when the offset is changed by at
* least a whole integer.
*/
void _stepWithoutAnimation() {
physicsX.step();
physicsY.step();
_nextX = physicsX._currentOffset.round();
_nextY = physicsY._currentOffset.round();
}
/**
* Calculate the next offset of the element and animate it to that position.
*/
void _step(num timestamp) {
_stepTimeout = null;
// Prune moves that are more than 1 frame behind when we have more
// available moves.
num lastEpoch = timestamp - SingleDimensionPhysics._MS_PER_FRAME;
while (!_moves.isEmpty &&
!identical(_moves.first, _moves.last) &&
_moves.first.time < lastEpoch) {
_moves.removeFirst();
}
if (!_moves.isEmpty) {
final move = _moves.removeFirst();
_delegate.onDecelerate(move.x, move.y);
if (!_moves.isEmpty) {
num nextTime = _moves.first.time;
assert(_stepTimeout == null);
_stepTimeout = Env.requestAnimationFrame(_step, null, nextTime);
} else {
stop();
}
}
}
void abort() {
_decelerating = false;
_moves.clear();
if (_stepTimeout != null) {
Env.cancelRequestAnimationFrame(_stepTimeout);
_stepTimeout = null;
}
}
Coordinate stop() {
final wasDecelerating = _decelerating;
_decelerating = false;
Coordinate velocity;
if (!_moves.isEmpty) {
final move = _moves.first;
// This is a workaround for the ugly hacks that get applied when a user
// passed a velocity in to this Momentum implementation.
num velocityScale = SingleDimensionPhysics._MS_PER_FRAME *
SingleDimensionPhysics._INITIAL_VELOCITY_BOOST_FACTOR;
velocity =
new Coordinate(move.vx / velocityScale, move.vy / velocityScale);
} else {
velocity = new Coordinate(0, 0);
}
_moves.clear();
if (_stepTimeout != null) {
Env.cancelRequestAnimationFrame(_stepTimeout);
_stepTimeout = null;
}
if (wasDecelerating) {
_delegate.onDecelerationEnd();
}
return velocity;
}
Coordinate get destination {
if (!_moves.isEmpty) {
final lastMove = _moves.last;
return new Coordinate(lastMove.x, lastMove.y);
} else {
return null;
}
}
}