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