diff options
author | Manolo Carrasco <manolo@vaadin.com> | 2015-06-03 11:41:59 +0200 |
---|---|---|
committer | Vaadin Code Review <review@vaadin.com> | 2015-09-02 07:33:24 +0000 |
commit | 0922cc0ee60fd1cddc1c189eac500a305aa9db92 (patch) | |
tree | a49bb6785a4a41d65ef5164f83aff73f4c3bd8d5 /client | |
parent | 93cb10eb9793e1998eafab1c09a184477b3294c8 (diff) | |
download | vaadin-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.java | 459 |
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(); } } |