summaryrefslogtreecommitdiffstats
path: root/client
diff options
context:
space:
mode:
authorManolo Carrasco <manolo@vaadin.com>2015-06-03 11:41:59 +0200
committerVaadin Code Review <review@vaadin.com>2015-09-02 07:33:24 +0000
commit0922cc0ee60fd1cddc1c189eac500a305aa9db92 (patch)
treea49bb6785a4a41d65ef5164f83aff73f4c3bd8d5 /client
parent93cb10eb9793e1998eafab1c09a184477b3294c8 (diff)
downloadvaadin-framework-0922cc0ee60fd1cddc1c189eac500a305aa9db92.tar.gz
vaadin-framework-0922cc0ee60fd1cddc1c189eac500a305aa9db92.zip
Grid: touch kinetic scrolling. #18133 #16885
This is a new implementation of scrolling for grid we have in v-grid. It uses GWT animations instead of having to deal with the HTML5 animation framework directly and implements the concepts of velocity and acceleration. Change-Id: I11cb0098e8a0e61b989edd7a383a069fa064ab32
Diffstat (limited to 'client')
-rw-r--r--client/src/com/vaadin/client/widgets/Escalator.java459
1 files changed, 147 insertions, 312 deletions
diff --git a/client/src/com/vaadin/client/widgets/Escalator.java b/client/src/com/vaadin/client/widgets/Escalator.java
index 7b0bb3bfe4..43eeb7a0ce 100644
--- a/client/src/com/vaadin/client/widgets/Escalator.java
+++ b/client/src/com/vaadin/client/widgets/Escalator.java
@@ -29,11 +29,13 @@ import java.util.TreeMap;
import java.util.logging.Level;
import java.util.logging.Logger;
+import com.google.gwt.animation.client.Animation;
import com.google.gwt.animation.client.AnimationScheduler;
import com.google.gwt.animation.client.AnimationScheduler.AnimationCallback;
import com.google.gwt.animation.client.AnimationScheduler.AnimationHandle;
import com.google.gwt.core.client.Duration;
import com.google.gwt.core.client.JavaScriptObject;
+import com.google.gwt.core.client.JsArray;
import com.google.gwt.core.client.Scheduler;
import com.google.gwt.core.client.Scheduler.ScheduledCommand;
import com.google.gwt.dom.client.DivElement;
@@ -48,6 +50,7 @@ import com.google.gwt.dom.client.Style.Unit;
import com.google.gwt.dom.client.TableCellElement;
import com.google.gwt.dom.client.TableRowElement;
import com.google.gwt.dom.client.TableSectionElement;
+import com.google.gwt.dom.client.Touch;
import com.google.gwt.event.shared.HandlerRegistration;
import com.google.gwt.logging.client.LogConfiguration;
import com.google.gwt.user.client.Command;
@@ -72,6 +75,7 @@ import com.vaadin.client.widget.escalator.PositionFunction.AbsolutePosition;
import com.vaadin.client.widget.escalator.PositionFunction.Translate3DPosition;
import com.vaadin.client.widget.escalator.PositionFunction.TranslatePosition;
import com.vaadin.client.widget.escalator.PositionFunction.WebkitTranslate3DPosition;
+import com.vaadin.client.widget.escalator.Row;
import com.vaadin.client.widget.escalator.RowContainer;
import com.vaadin.client.widget.escalator.RowContainer.BodyRowContainer;
import com.vaadin.client.widget.escalator.RowVisibilityChangeEvent;
@@ -302,8 +306,6 @@ public class Escalator extends Widget implements RequiresResize,
static class JsniUtil {
public static class TouchHandlerBundle {
- private static final double FLICK_POLL_FREQUENCY = 100d;
-
/**
* A <a href=
* "http://www.gwtproject.org/doc/latest/DevGuideCodingBasicsOverlay.html"
@@ -338,105 +340,8 @@ public class Escalator extends Widget implements RequiresResize,
}-*/;
}
- private double touches = 0;
- private int lastX = 0;
- private int lastY = 0;
- private boolean snappedScrollEnabled = true;
- private double deltaX = 0;
- private double deltaY = 0;
-
private final Escalator escalator;
- private CustomTouchEvent latestTouchMoveEvent;
-
- /** The timestamp of {@link #flickPageX1} and {@link #flickPageY1} */
- private double flickStartTime = 0;
-
- /** The timestamp of {@link #flickPageX2} and {@link #flickPageY2} */
- private double flickTimestamp = 0;
-
- /** The most recent flick touch reference Y */
- private double flickPageY1 = -1;
- /** The most recent flick touch reference X */
- private double flickPageX1 = -1;
-
- /** The previous flick touch reference Y, before {@link #flickPageY1} */
- private double flickPageY2 = -1;
- /** The previous flick touch reference X, before {@link #flickPageX1} */
- private double flickPageX2 = -1;
-
- /**
- * This animation callback guarantees the fact that we don't scroll
- * the grid more than once per visible frame.
- *
- * It seems that there will never be more touch events than there
- * are rendered frames, but there's no guarantee for that. If it was
- * guaranteed, we probably could do all of this immediately in
- * {@link #touchMove(CustomTouchEvent)}, instead of deferring it
- * over here.
- */
- private AnimationCallback mover = new AnimationCallback() {
-
- @Override
- public void execute(double timestamp) {
- if (touches != 1) {
- return;
- }
-
- final int x = latestTouchMoveEvent.getPageX();
- final int y = latestTouchMoveEvent.getPageY();
-
- /*
- * Check if we need a new flick coordinate sample ( more
- * than FLICK_POLL_FREQUENCY ms have passed since the last
- * sample )
- */
- if (System.currentTimeMillis() - flickTimestamp > FLICK_POLL_FREQUENCY) {
-
- flickTimestamp = System.currentTimeMillis();
- // Set target coordinates
- flickPageY2 = y;
- flickPageX2 = x;
- }
-
- deltaX = x - lastX;
- deltaY = y - lastY;
- lastX = x;
- lastY = y;
-
- // snap the scroll to the major axes, at first.
- if (snappedScrollEnabled) {
- final double oldDeltaX = deltaX;
- final double oldDeltaY = deltaY;
-
- /*
- * Scrolling snaps to 40 degrees vs. flick scroll's 30
- * degrees, since slow movements have poor resolution -
- * it's easy to interpret a slight angle as a steep
- * angle, since the sample rate is "unnecessarily" high.
- * 40 simply felt better than 30.
- */
- final double[] snapped = Escalator.snapDeltas(deltaX,
- deltaY, RATIO_OF_40_DEGREES);
- deltaX = snapped[0];
- deltaY = snapped[1];
-
- /*
- * if the snap failed once, let's follow the pointer
- * from now on.
- */
- if (oldDeltaX != 0 && deltaX == oldDeltaX
- && oldDeltaY != 0 && deltaY == oldDeltaY) {
- snappedScrollEnabled = false;
- }
- }
-
- moveScrollFromEvent(escalator, -deltaX, -deltaY,
- latestTouchMoveEvent.getNativeEvent());
- }
- };
- private AnimationHandle animationHandle;
-
public TouchHandlerBundle(final Escalator escalator) {
this.escalator = escalator;
}
@@ -468,79 +373,157 @@ public class Escalator extends Widget implements RequiresResize,
});
}-*/;
- public void touchStart(final CustomTouchEvent event) {
- touches = event.getNativeEvent().getTouches().length();
- if (touches != 1) {
- return;
+ // Duration of the inertial scrolling simulation. Devices with
+ // larger screens take longer durations.
+ private static final int DURATION = (int)Window.getClientHeight();
+ // multiply scroll velocity with repeated touching
+ private int acceleration = 1;
+ private boolean touching = false;
+ // Two movement objects for storing status and processing touches
+ private Movement yMov, xMov;
+ final double MIN_VEL = 0.6, MAX_VEL = 4, F_VEL = 1500, F_ACC = 0.7, F_AXIS = 1;
+
+ // The object to deal with one direction scrolling
+ private class Movement {
+ final List<Double> speeds = new ArrayList<Double>();
+ final ScrollbarBundle scroll;
+ double position, offset, velocity, prevPos, prevTime, delta;
+ boolean run, vertical;
+
+ public Movement(boolean vertical) {
+ this.vertical = vertical;
+ scroll = vertical ? escalator.verticalScrollbar : escalator.horizontalScrollbar;
}
- escalator.scroller.cancelFlickScroll();
+ public void startTouch(CustomTouchEvent event) {
+ speeds.clear();
+ prevPos = pagePosition(event);
+ prevTime = Duration.currentTimeMillis();
+ }
+ public void moveTouch(CustomTouchEvent event) {
+ double pagePosition = pagePosition(event);
+ if (pagePosition > -1) {
+ delta = prevPos - pagePosition;
+ double now = Duration.currentTimeMillis();
+ double ellapsed = now - prevTime;
+ velocity = delta / ellapsed;
+ // if last speed was so low, reset speeds and start storing again
+ if (speeds.size() > 0 && !validSpeed(speeds.get(0))) {
+ speeds.clear();
+ run = true;
+ }
+ speeds.add(0, velocity);
+ prevTime = now;
+ prevPos = pagePosition;
+ }
+ }
+ public void endTouch(CustomTouchEvent event) {
+ // Compute average speed
+ velocity = 0;
+ for (double s : speeds) {
+ velocity += s / speeds.size();
+ }
+ position = scroll.getScrollPos();
+ // Compute offset, and adjust it with an easing curve so as movement is smoother.
+ offset = F_VEL * velocity * acceleration * easingInOutCos(velocity, MAX_VEL);
+ // Check that offset does not over-scroll
+ double minOff = -scroll.getScrollPos();
+ double maxOff = scroll.getScrollSize() - scroll.getOffsetSize() + minOff;
+ offset = Math.min(Math.max(offset, minOff), maxOff);
+ // Enable or disable inertia movement in this axis
+ run = validSpeed(velocity) && minOff < 0 && maxOff > 0;
+ if (run) {
+ event.getNativeEvent().preventDefault();
+ }
+ }
+ void validate(Movement other) {
+ if (!run || other.velocity > 0 && Math.abs(velocity / other.velocity) < F_AXIS) {
+ delta = offset = 0;
+ run = false;
+ }
+ }
+ void stepAnimation(double progress) {
+ scroll.setScrollPos(position + offset * progress);
+ }
- lastX = event.getPageX();
- lastY = event.getPageY();
+ int pagePosition(CustomTouchEvent event) {
+ JsArray<Touch> a = event.getNativeEvent().getTouches();
+ return vertical ? a.get(0).getPageY() : a.get(0).getPageX();
+ }
+ boolean validSpeed(double speed) {
+ return Math.abs(speed) > MIN_VEL;
+ }
+ }
- // Reset flick parameters
- flickPageX1 = lastX;
- flickPageX2 = -1;
- flickPageY1 = lastY;
- flickPageY2 = -1;
- flickStartTime = System.currentTimeMillis();
- flickTimestamp = 0;
+ // Using GWT animations which take care of native animation frames.
+ private Animation animation = new Animation() {
+ public void onUpdate(double progress) {
+ xMov.stepAnimation(progress);
+ yMov.stepAnimation(progress);
+ }
+ public double interpolate(double progress) {
+ return easingOutCirc(progress);
+ };
+ public void onComplete() {
+ touching = false;
+ escalator.body.domSorter.reschedule();
+ };
+ public void run(int duration) {
+ if (xMov.run || yMov.run) {
+ super.run(duration);
+ } else {
+ onComplete();
+ }
+ };
+ };
- snappedScrollEnabled = true;
+ public void touchStart(final CustomTouchEvent event) {
+ if (event.getNativeEvent().getTouches().length() == 1) {
+ if (yMov == null) {
+ yMov = new Movement(true);
+ xMov = new Movement(false);
+ }
+ if (animation.isRunning()) {
+ acceleration += F_ACC;
+ event.getNativeEvent().preventDefault();
+ animation.cancel();
+ } else {
+ acceleration = 1;
+ }
+ xMov.startTouch(event);
+ yMov.startTouch(event);
+ touching = true;
+ }
}
public void touchMove(final CustomTouchEvent event) {
- /*
- * since we only use the getPageX/Y, and calculate the diff
- * within the handler, we don't need to calculate any
- * intermediate deltas.
- */
- latestTouchMoveEvent = event;
-
- if (animationHandle != null) {
- animationHandle.cancel();
- }
- animationHandle = AnimationScheduler.get()
- .requestAnimationFrame(mover, escalator.bodyElem);
- event.getNativeEvent().preventDefault();
+ xMov.moveTouch(event);
+ yMov.moveTouch(event);
+ xMov.validate(yMov);
+ yMov.validate(xMov);
+ moveScrollFromEvent(escalator, xMov.delta, yMov.delta, event.getNativeEvent());
}
public void touchEnd(final CustomTouchEvent event) {
- touches = event.getNativeEvent().getTouches().length();
-
- if (touches == 0) {
-
- /*
- * We want to smooth the flick calculations here. We have
- * taken a frame of reference every FLICK_POLL_FREQUENCY.
- * But if the sample is too fresh, we might introduce noise
- * in our sampling, so we use the older sample instead. it
- * might be less accurate, but it's smoother.
- *
- * flickPage?1 is the most recent one, while flickPage?2 is
- * the previous one.
- */
-
- final double finalPageY;
- final double finalPageX;
- double deltaT = flickTimestamp - flickStartTime;
- boolean onlyOneSample = flickPageX2 < 0 || flickPageY2 < 0;
- if (onlyOneSample) {
- finalPageX = latestTouchMoveEvent.getPageX();
- finalPageY = latestTouchMoveEvent.getPageY();
- } else {
- finalPageY = flickPageY2;
- finalPageX = flickPageX2;
- }
-
- double deltaX = finalPageX - flickPageX1;
- double deltaY = finalPageY - flickPageY1;
+ xMov.endTouch(event);
+ yMov.endTouch(event);
+ xMov.validate(yMov);
+ yMov.validate(xMov);
+ // Adjust duration so as longer movements take more duration
+ boolean vert = !xMov.run || yMov.run && Math.abs(yMov.offset) > Math.abs(xMov.offset);
+ double delta = Math.abs((vert ? yMov : xMov).offset);
+ animation.run((int)(3 * DURATION * easingOutExp(delta)));
+ }
- escalator.scroller
- .handleFlickScroll(deltaX, deltaY, deltaT);
- escalator.body.domSorter.reschedule();
- }
+ private double easingInOutCos(double val, double max) {
+ return 0.5 - 0.5 * Math.cos(Math.PI * Math.signum(val)
+ * Math.min(Math.abs(val), max) / max);
+ }
+ private double easingOutExp(double delta) {
+ return (1 - Math.pow(2, -delta / 1000));
+ }
+ private double easingOutCirc(double progress) {
+ return Math.sqrt(1 - (progress - 1) * (progress - 1));
}
}
@@ -570,117 +553,6 @@ public class Escalator extends Widget implements RequiresResize,
}
}
- /**
- * The animation callback that handles the animation of a touch-scrolling
- * flick with inertia.
- */
- private class FlickScrollAnimator implements AnimationCallback {
- private static final double MIN_MAGNITUDE = 0.005;
- private static final double MAX_SPEED = 7;
-
- private double velX;
- private double velY;
- private double prevTime = 0;
- private int millisLeft;
- private double xFric;
- private double yFric;
-
- private boolean cancelled = false;
- private double lastLeft;
- private double lastTop;
-
- /**
- * Creates a new animation callback to handle touch-scrolling flick with
- * inertia.
- *
- * @param deltaX
- * the last scrolling delta in the x-axis in a touchmove
- * @param deltaY
- * the last scrolling delta in the y-axis in a touchmove
- * @param lastTime
- * the timestamp of the last touchmove
- */
- public FlickScrollAnimator(final double deltaX, final double deltaY,
- final double deltaT) {
- velX = Math.max(Math.min(deltaX / deltaT, MAX_SPEED), -MAX_SPEED);
- velY = Math.max(Math.min(deltaY / deltaT, MAX_SPEED), -MAX_SPEED);
-
- lastLeft = horizontalScrollbar.getScrollPos();
- lastTop = verticalScrollbar.getScrollPos();
-
- /*
- * If we're scrolling mainly in one of the four major directions,
- * and only a teeny bit to any other side, snap the scroll to that
- * major direction instead.
- */
- final double[] snapDeltas = Escalator.snapDeltas(velX, velY,
- RATIO_OF_30_DEGREES);
- velX = snapDeltas[0];
- velY = snapDeltas[1];
-
- if (velX * velX + velY * velY > MIN_MAGNITUDE) {
- millisLeft = 1500;
- xFric = velX / millisLeft;
- yFric = velY / millisLeft;
- } else {
- millisLeft = 0;
- }
-
- }
-
- @Override
- public void execute(final double doNotUseThisTimestamp) {
- /*
- * We cannot use the timestamp provided to this method since it is
- * of a format that cannot be determined at will. Therefore, we need
- * a timestamp format that we can handle, so our calculations are
- * correct.
- */
-
- if (millisLeft <= 0 || cancelled) {
- scroller.currentFlickScroller = null;
- return;
- }
-
- final double timestamp = Duration.currentTimeMillis();
- if (prevTime == 0) {
- prevTime = timestamp;
- AnimationScheduler.get().requestAnimationFrame(this);
- return;
- }
-
- double currentLeft = horizontalScrollbar.getScrollPos();
- double currentTop = verticalScrollbar.getScrollPos();
-
- final double timeDiff = timestamp - prevTime;
- double left = currentLeft - velX * timeDiff;
- setScrollLeft(left);
- velX -= xFric * timeDiff;
-
- double top = currentTop - velY * timeDiff;
- setScrollTop(top);
- velY -= yFric * timeDiff;
-
- cancelBecauseOfEdgeOrCornerMaybe();
-
- prevTime = timestamp;
- millisLeft -= timeDiff;
- lastLeft = currentLeft;
- lastTop = currentTop;
- AnimationScheduler.get().requestAnimationFrame(this);
- }
-
- private void cancelBecauseOfEdgeOrCornerMaybe() {
- if (lastLeft == horizontalScrollbar.getScrollPos()
- && lastTop == verticalScrollbar.getScrollPos()) {
- cancel();
- }
- }
-
- public void cancel() {
- cancelled = true;
- }
- }
/**
* ScrollDestination case-specific handling logic.
@@ -760,11 +632,6 @@ public class Escalator extends Widget implements RequiresResize,
private class Scroller extends JsniWorkaround {
private double lastScrollTop = 0;
private double lastScrollLeft = 0;
- /**
- * The current flick scroll animator. This is <code>null</code> if the
- * view isn't animating a flick scroll at the moment.
- */
- private FlickScrollAnimator currentFlickScroller;
public Scroller() {
super(Escalator.this);
@@ -782,7 +649,7 @@ public class Escalator extends Widget implements RequiresResize,
return $entry(function(e) {
var target = e.target || e.srcElement; // IE8 uses e.scrElement
-
+
// in case the scroll event was native (i.e. scrollbars were dragged, or
// the scrollTop/Left was manually modified), the bundles have old cache
// values. We need to make sure that the caches are kept up to date.
@@ -1100,30 +967,6 @@ public class Escalator extends Widget implements RequiresResize,
}
}-*/;
- private void cancelFlickScroll() {
- if (currentFlickScroller != null) {
- currentFlickScroller.cancel();
- }
- }
-
- /**
- * Handles a touch-based flick scroll.
- *
- * @param deltaX
- * the last scrolling delta in the x-axis in a touchmove
- * @param deltaY
- * the last scrolling delta in the y-axis in a touchmove
- * @param lastTime
- * the timestamp of the last touchmove
- */
- public void handleFlickScroll(double deltaX, double deltaY,
- double deltaT) {
- currentFlickScroller = new FlickScrollAnimator(deltaX, deltaY,
- deltaT);
- AnimationScheduler.get()
- .requestAnimationFrame(currentFlickScroller);
- }
-
public void scrollToColumn(final int columnIndex,
final ScrollDestination destination, final int padding) {
assert columnIndex >= columnConfiguration.frozenColumns : "Can't scroll to a frozen column";
@@ -2481,9 +2324,9 @@ public class Escalator extends Widget implements RequiresResize,
private boolean sortIfConditionsMet() {
boolean enoughFramesHavePassed = framesPassed >= REQUIRED_FRAMES_PASSED;
boolean enoughTimeHasPassed = (Duration.currentTimeMillis() - startTime) >= SORT_DELAY_MILLIS;
- boolean notAnimatingFlick = (scroller.currentFlickScroller == null);
+ boolean notTouchActivity = !scroller.touchHandlerBundle.touching;
boolean conditionsMet = enoughFramesHavePassed
- && enoughTimeHasPassed && notAnimatingFlick;
+ && enoughTimeHasPassed && notTouchActivity;
if (conditionsMet) {
resetConditions();
@@ -2663,15 +2506,7 @@ public class Escalator extends Widget implements RequiresResize,
if (rowsWereMoved) {
fireRowVisibilityChangeEvent();
-
- if (scroller.touchHandlerBundle.touches == 0) {
- /*
- * this will never be called on touch scrolling. That is
- * handled separately and explicitly by
- * TouchHandlerBundle.touchEnd();
- */
- domSorter.reschedule();
- }
+ domSorter.reschedule();
}
}