blob: bfad07088331f98a232a0a57fe7ee032f85dc7a8 [file] [log] [blame]
/*
* Copyright 2010 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
* use this file except in compliance with the License. You may obtain a copy of
* the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations under
* the License.
*/
package com.google.gwt.mobile.client;
import com.google.gwt.core.client.Duration;
import com.google.gwt.user.client.Timer;
/**
* This class can be used to simulate the deceleration of an element within a
* certain region. To use this behavior you need to provide a distance and time
* that is meant to represent a 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. This behavior will step
* through all of the intermediate points necessary to decelerate the element
* back to a velocity of zero. In doing so, the element may 'bounce' in and out
* of the boundaries of the region, but will always come to rest within the
* region.
*
* This is primarily designed to solve the problem of slow scrolling in mobile
* safari. You can use this along with the Scroller behavior
* (wireless.fx.Scroller) to make a scrollable area scroll as well as it would
* in a native application.
*
* This class does not maintain any references to HTML elements, and therefore
* cannot do any redrawing of elements. It 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.
*/
class Momentum {
/**
* You are required to implement this interface in order to use the
* {@link Momentum} behavior.
*/
public interface Delegate {
/**
* Callback for a deceleration step. The delegate is responsible for redrawing
* the element in its new position.
*
* @param floorX The new x offset
* @param floorY The new y offset
* @param velocity The current velocitiy
*/
void onDecelerate(double floorX, double floorY, Point velocity);
/**
* Callback for end of deceleration.
*/
void onDecelerationEnd();
}
/**
* The constant factor applied to velocity at each frame to simulate
* deceleration.
*/
private static final double DECELERATION_FACTOR = 0.98;
/**
* The velocity threshold at which declereration will end.
*/
private static final double DECELERATION_STOP_VELOCITY = 0.01;
/**
* The number of frames per second the animation should run at.
*/
private static final double FRAMES_PER_SECOND = 60;
/**
* Boost the initial velocity by a certain factor before applying momentum.
* This just gives the momentum a better feel.
*/
private static final double INITIAL_VELOCITY_BOOST_FACTOR = 1.5;
/**
* Minimum velocity required to start deceleration.
*/
private static final double MIN_START_VELOCITY = 0.3;
/**
* The number of milliseconds per animation frame.
*/
private static final double MS_PER_FRAME = 1000 / 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.
*/
private static final double POST_BOUNCE_COEFFICIENT = 9 / FRAMES_PER_SECOND;
/**
* 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.
*/
private static final double PRE_BOUNCE_COEFFICIENT = 1.8 / FRAMES_PER_SECOND;
/**
* True when the momentum of has carried the position outside the allowable
* range but before the velocity has changed directions.
*/
private boolean bouncingX;
/**
* True when the momentum of has carried the position outside the allowable
* range but before the velocity has changed directions.
*/
private boolean bouncingY;
/**
* The current offset of the element. These x, y values can be decimal values.
* It is necessary to store these values for the sake of precision.
*/
private Point currentOffset;
/**
* Whether or not deceleration is currently in progress.
*/
private boolean decelerating;
private Delegate delegate;
/**
* The maximum boundary for the element.
*/
private Point maxCoord;
/**
* The minimum boundary for the element.
*/
private Point minCoord;
/**
* The previous offset of the element. These x, y values are whole numbers.
* Their values are derived from rounding of the currentOffset_ member.
*/
private Point previousOffset;
/**
* The start time of the deceleration.
*/
private double startTime;
private Timer stepFunction = new Timer() {
@Override
public void run() {
step();
}
};
/**
* The step number of the deceleration.
*/
private double stepNumber;
/**
* Current velocity of the element. In this class velocity is measured as
* pixels per frame. That is, the number of pixels to move the element in the
* next frame.
*/
private Point velocity;
/**
* Creates a new Momentum object.
*
* @param delegate The momentum delegate.
*/
public Momentum(Delegate delegate) {
this.delegate = delegate;
}
/**
* Whether or not the element is currently bouncing. Bouncing is the behavior
* of an element moving past an allowable boundary and decelerating to change
* direction and snap back into place. Not to be confused with bouncing back.
*
* @return True if the element is currently bouncing in either the x or y
* direction.
*/
public boolean isBouncing() {
return bouncingY || bouncingX;
}
/**
* Start decelerating. Checks if the current velocity is above the minumum
* threshold to start decelerating. If so then deceleration will begin, if not
* then nothing happens.
*
* @param velocity The initial velocity. The velocity passed here should be in
* terms of number of pixels / millisecond. initiating deceleration.
* @param minCoord The content's scrollable boundary.
* @param maxCoord The content's scrollable boundary.
* @param initialOffset The current offset of the element within its
* boundaries.
* @return True if deceleration has been initiated.
*/
public boolean start(Point velocity, Point minCoord,
Point maxCoord, Point initialOffset) {
this.minCoord = minCoord;
this.maxCoord = maxCoord;
currentOffset = new Point(initialOffset);
previousOffset = new Point(initialOffset);
this.velocity = adjustInitialVelocity(velocity);
if (isVelocityAboveThreshold(MIN_START_VELOCITY)) {
decelerating = true;
startTime = Duration.currentTimeMillis();
stepNumber = 0;
stepFunction.schedule((int) MS_PER_FRAME);
return true;
}
return false;
}
/**
* Stop decelerating.
*/
public void stop() {
decelerating = false;
bouncingX = false;
bouncingY = false;
delegate.onDecelerationEnd();
}
/**
* Helper method to calculate initial velocity.
*
* @param velocity The initial velocity. The velocity passed here should be in
* terms of number of pixels / millisecond.
* @return The adjusted x and y velocities.
*/
private Point adjustInitialVelocity(Point velocity) {
return new Point(adjustInitialVelocityForDirection(velocity.x,
currentOffset.x, minCoord.x, maxCoord.x),
adjustInitialVelocityForDirection(velocity.y, currentOffset.y,
minCoord.y, maxCoord.y));
}
/**
* Helper method to calculate the initial velocity for a specific direction.
*
* @param originalVelocity The velocity we are adjusting.
* @param offset The offset for this direction.
* @param min The min coordinate for this direction.
* @param max The max coordinate for this direction.
* @return The calculated velocity.
*/
private double adjustInitialVelocityForDirection(double originalVelocity,
double offset, double min, double max) {
// Convert from pixels/ms to pixels/frame
double vel = originalVelocity * MS_PER_FRAME
* INITIAL_VELOCITY_BOOST_FACTOR;
// If the initial velocity is below the minimum threshold, it is possible
// that we need to bounce back depending on where the element is.
if (Math.abs(vel) < MIN_START_VELOCITY) {
// If either of these cases are true, then the element is outside of its
// allowable region and we need to apply a bounce back acceleration to
// bring it back to rest in its defined area.
if (offset < min) {
vel = (min - offset) * POST_BOUNCE_COEFFICIENT;
vel = Math.max(vel, MIN_START_VELOCITY);
} else if (offset > max) {
vel = (offset - max) * POST_BOUNCE_COEFFICIENT;
vel = -Math.max(vel, MIN_START_VELOCITY);
}
}
return vel;
}
/**
* Decelerate the current velocity.
*/
private void adjustVelocity() {
adjustVelocityComponent(currentOffset.x, minCoord.x, maxCoord.x,
velocity.x, bouncingX, false /* horizontal */
);
adjustVelocityComponent(currentOffset.y, minCoord.y, maxCoord.y,
velocity.y, bouncingY, true /* vertical */
);
}
/**
* Apply deceleration to a specifc direction.
*
* @param offset The offset for this direction.
* @param min The min coordinate for this direction.
* @param max The max coordinate for this direction.
* @param velocity The velocity for this direction.
* @param bouncing Whether this direction is bouncing.
* @param vertical Whether or not the direction is vertical.
*/
private void adjustVelocityComponent(double offset, double min, double max,
double velocity, boolean bouncing, boolean vertical) {
double speed = Math.abs(velocity);
// Apply the deceleration factor several times as we get closer to stopping.
int numTimes = speed < 15 ? (speed < 3 ? 3 : 2) : 1;
velocity *= Math.pow(DECELERATION_FACTOR, numTimes);
double stretchDistance = 0;
// We make special adjustments to the velocity if the element is outside of
// its region.
if (offset < min) {
stretchDistance = min - offset;
} else if (offset > max) {
stretchDistance = max - offset;
}
// If stretchDistance has a value then we are either bouncing or bouncing
// back.
if (stretchDistance != 0) {
// If our adjustment is in the opposite direction of our velocity then we
// are still trying to turn around. Else we are bouncing back.
if (stretchDistance * velocity < 0) {
bouncing = true;
velocity += stretchDistance * PRE_BOUNCE_COEFFICIENT;
} else {
bouncing = false;
velocity = stretchDistance * POST_BOUNCE_COEFFICIENT;
}
}
if (vertical) {
this.velocity.y = velocity;
bouncingY = bouncing;
} else {
this.velocity.x = velocity;
bouncingX = bouncing;
}
}
/**
* 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.
*
* @return True if there is movement to be done in the next frame.
*/
private boolean isStepNecessary() {
return Math.abs(currentOffset.y + velocity.y - previousOffset.y) > 1
|| Math.abs(currentOffset.x + velocity.x - previousOffset.x) > 1;
}
/**
* Whether or not the current velocity is above the threshold required to
* continue decelerating. Once both the x and y velocity fall below the
* threshold, the element should stop moving entirely.
*
* @param threshold The threshold to measure against.
* @return True if the x or y velocity is still above the threshold.
*/
private boolean isVelocityAboveThreshold(double threshold) {
return Math.abs(velocity.x) >= threshold
|| Math.abs(velocity.y) >= threshold;
}
/**
* Calculate the next offset of the element and animate it to that position.
*/
private void step() {
// If deceleration is stopped between frames this is possible. Need to abort
// the step if this happens.
if (!decelerating) {
return;
}
double now = Duration.currentTimeMillis();
double framesExpected = Math.floor((now - startTime) / MS_PER_FRAME);
// Do at least one step, and more if subsequent steps are not necessary or
// if we are falling behind.
do {
stepWithoutAnimation();
} while (isVelocityAboveThreshold(DECELERATION_STOP_VELOCITY)
&& (!isStepNecessary() || framesExpected > stepNumber));
double floorY = currentOffset.y;
double floorX = currentOffset.x;
// If we have moved a whole integer then notify the delegate and update the
// previous position.
if (decelerating) {
delegate.onDecelerate(floorX, floorY, velocity);
previousOffset.y = floorY;
previousOffset.x = floorX;
}
// This condition checks of deceleration is over.
if (!isBouncing()
&& !isVelocityAboveThreshold(DECELERATION_STOP_VELOCITY)) {
stop();
return;
}
stepFunction.schedule((int) (MS_PER_FRAME * (1 + stepNumber - framesExpected)));
}
/**
* 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.
*/
private void stepWithoutAnimation() {
stepNumber++;
currentOffset.y += velocity.y;
currentOffset.x += velocity.x;
adjustVelocity();
}
}