diff options
author | Henrik Paul <henrik@vaadin.com> | 2013-11-19 13:24:56 +0200 |
---|---|---|
committer | Henrik Paul <henrik@vaadin.com> | 2013-11-19 13:24:56 +0200 |
commit | 96de019ea4ee5536dab87d1f04d4cb94ae71371d (patch) | |
tree | 2ba6934d041c18994280f0c0140f8d405644d766 /client | |
parent | 1c1506ef0447b1d979a6adb4d812ae9858f00b67 (diff) | |
download | vaadin-framework-96de019ea4ee5536dab87d1f04d4cb94ae71371d.tar.gz vaadin-framework-96de019ea4ee5536dab87d1f04d4cb94ae71371d.zip |
Implement touch-based scrolling for Escalator (#12645)
Change-Id: I74559572412a26823903cdc0e0f171ec55c21ee7
Diffstat (limited to 'client')
-rw-r--r-- | client/src/com/vaadin/client/ui/grid/Escalator.java | 495 |
1 files changed, 471 insertions, 24 deletions
diff --git a/client/src/com/vaadin/client/ui/grid/Escalator.java b/client/src/com/vaadin/client/ui/grid/Escalator.java index c90d993022..51b45dba59 100644 --- a/client/src/com/vaadin/client/ui/grid/Escalator.java +++ b/client/src/com/vaadin/client/ui/grid/Escalator.java @@ -24,8 +24,12 @@ import java.util.ListIterator; import java.util.Map; import java.util.logging.Logger; +import com.google.gwt.animation.client.AnimationScheduler; +import com.google.gwt.animation.client.AnimationScheduler.AnimationCallback; +import com.google.gwt.core.client.Duration; import com.google.gwt.core.client.JavaScriptObject; import com.google.gwt.dom.client.Document; +import com.google.gwt.dom.client.NativeEvent; import com.google.gwt.dom.client.Node; import com.google.gwt.dom.client.NodeList; import com.google.gwt.dom.client.Style; @@ -36,6 +40,7 @@ import com.google.gwt.user.client.Element; import com.google.gwt.user.client.Window; import com.google.gwt.user.client.ui.Widget; import com.vaadin.client.Util; +import com.vaadin.client.ui.grid.Escalator.JsniUtil.TouchHandlerBundle; import com.vaadin.client.ui.grid.PositionFunction.AbsolutePosition; import com.vaadin.client.ui.grid.PositionFunction.Translate3DPosition; import com.vaadin.client.ui.grid.PositionFunction.TranslatePosition; @@ -148,9 +153,38 @@ abstract class JsniWorkaround { */ protected final JavaScriptObject mousewheelListenerFunction; + /** + * A JavaScript function that handles the touch start DOM event, and passes + * it on to Java code. + * + * @see TouchHandlerBundle#touchStart(Escalator.JsniUtil.TouchHandlerBundle.CustomTouchEvent) + */ + protected JavaScriptObject touchStartFunction; + + /** + * A JavaScript function that handles the touch move DOM event, and passes + * it on to Java code. + * + * @see TouchHandlerBundle#touchMove(Escalator.JsniUtil.TouchHandlerBundle.CustomTouchEvent) + */ + protected JavaScriptObject touchMoveFunction; + + /** + * A JavaScript function that handles the touch end and cancel DOM events, + * and passes them on to Java code. + * + * @see TouchHandlerBundle#touchEnd(Escalator.JsniUtil.TouchHandlerBundle.CustomTouchEvent) + */ + protected JavaScriptObject touchEndFunction; + protected JsniWorkaround(final Escalator escalator) { scrollListenerFunction = createScrollListenerFunction(escalator); mousewheelListenerFunction = createMousewheelListenerFunction(escalator); + + final TouchHandlerBundle bundle = new TouchHandlerBundle(escalator); + touchStartFunction = bundle.getTouchStartHandler(); + touchMoveFunction = bundle.getTouchMoveHandler(); + touchEndFunction = bundle.getTouchEndHandler(); } /** @@ -217,6 +251,279 @@ public class Escalator extends Widget { * being supported. */ + /** + * A utility class that contains utility methods that are usually called + * from JSNI. + * <p> + * The methods are moved in this class to minimize the amount of JSNI code + * as much as feasible. + */ + static class JsniUtil { + public static class TouchHandlerBundle { + + /** + * A <a href= + * "http://www.gwtproject.org/doc/latest/DevGuideCodingBasicsOverlay.html" + * >JavaScriptObject overlay</a> for the <a + * href="http://www.w3.org/TR/touch-events/">JavaScript + * TouchEvent</a> object. + * <p> + * This needs to be used in the touch event handlers, since GWT's + * {@link com.google.gwt.event.dom.client.TouchEvent TouchEvent} + * can't be cast from the JSNI call, and the + * {@link com.google.gwt.dom.client.NativeEvent NativeEvent} isn't + * properly populated with the correct values. + */ + private final static class CustomTouchEvent extends + JavaScriptObject { + protected CustomTouchEvent() { + } + + public native NativeEvent getNativeEvent() + /*-{ + return this; + }-*/; + + public native int getPageX() + /*-{ + return this.targetTouches[0].pageX; + }-*/; + + public native int getPageY() + /*-{ + return this.targetTouches[0].pageY; + }-*/; + } + + private double touches = 0; + private int lastX = 0; + private int lastY = 0; + private double lastTime = 0; + private boolean snappedScrollEnabled = true; + private double deltaX = 0; + private double deltaY = 0; + + private final Escalator escalator; + + public TouchHandlerBundle(final Escalator escalator) { + this.escalator = escalator; + } + + public native JavaScriptObject getTouchStartHandler() + /*-{ + // we need to store "this", since it won't be preserved on call. + var self = this; + return $entry(function (e) { + self.@com.vaadin.client.ui.grid.Escalator.JsniUtil.TouchHandlerBundle::touchStart(*)(e); + }); + }-*/; + + public native JavaScriptObject getTouchMoveHandler() + /*-{ + // we need to store "this", since it won't be preserved on call. + var self = this; + return $entry(function (e) { + self.@com.vaadin.client.ui.grid.Escalator.JsniUtil.TouchHandlerBundle::touchMove(*)(e); + }); + }-*/; + + public native JavaScriptObject getTouchEndHandler() + /*-{ + // we need to store "this", since it won't be preserved on call. + var self = this; + return $entry(function (e) { + self.@com.vaadin.client.ui.grid.Escalator.JsniUtil.TouchHandlerBundle::touchEnd(*)(e); + }); + }-*/; + + public void touchStart(final CustomTouchEvent event) { + touches++; + if (touches != 1) { + return; + } + + escalator.scroller.cancelFlickScroll(); + + lastX = event.getPageX(); + lastY = event.getPageY(); + + snappedScrollEnabled = true; + } + + public void touchMove(final CustomTouchEvent event) { + if (touches != 1) { + return; + } + + final int x = event.getPageX(); + final int y = event.getPageY(); + deltaX = x - lastX; + deltaY = y - lastY; + lastX = x; + lastY = y; + lastTime = Duration.currentTimeMillis(); + + // 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.scrollerElem, -deltaX, -deltaY, + event.getNativeEvent()); + } + + public void touchEnd(@SuppressWarnings("unused") + final CustomTouchEvent event) { + touches--; + + if (touches == 0) { + escalator.scroller.handleFlickScroll(deltaX, deltaY, + lastTime); + } + } + } + + public static void moveScrollFromEvent(final Element scrollerElem, + final double deltaX, final double deltaY, + final NativeEvent event) { + /* + * TODO [[optimize]]: instead of calling getScollLeft/Top that + * potentially causes a reflow, update a new scroll absolute + * position. + */ + if (!Double.isNaN(deltaX) && deltaX != 0) { + scrollerElem + .setScrollLeft((int) (scrollerElem.getScrollLeft() + deltaX)); + } + if (!Double.isNaN(deltaY) && deltaY != 0) { + scrollerElem + .setScrollTop((int) (scrollerElem.getScrollTop() + deltaY)); + } + + /* + * TODO: only prevent if not scrolled to end/bottom. Or no? UX team + * needs to decide. + */ + event.preventDefault(); + } + } + + /** + * 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; + + /** + * 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 lastTime) { + final double currentTimeMillis = Duration.currentTimeMillis(); + velX = Math.max(Math.min(deltaX / (currentTimeMillis - lastTime), + MAX_SPEED), -MAX_SPEED); + velY = Math.max(Math.min(deltaY / (currentTimeMillis - lastTime), + MAX_SPEED), -MAX_SPEED); + prevTime = lastTime; + + /* + * 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 timestamp) { + if (millisLeft <= 0 || cancelled) { + scroller.currentFlickScroller = null; + return; + } + + final int lastLeft = tBodyScrollLeft; + final int lastTop = tBodyScrollTop; + + final double timeDiff = timestamp - prevTime; + setScrollLeft((int) (tBodyScrollLeft - velX * timeDiff)); + velX -= xFric * timeDiff; + + setScrollTop(tBodyScrollTop - velY * timeDiff); + velY -= yFric * timeDiff; + + cancelBecauseOfEdgeOrCornerMaybe(lastLeft, lastTop); + + prevTime = timestamp; + millisLeft -= timeDiff; + AnimationScheduler.get().requestAnimationFrame(this); + } + + private void cancelBecauseOfEdgeOrCornerMaybe(final int lastLeft, + final int lastTop) { + if (lastLeft == scrollerElem.getScrollLeft() + && lastTop == scrollerElem.getScrollTop()) { + cancel(); + } + } + + public void cancel() { + cancelled = true; + } + } + private static final int ROW_HEIGHT_PX = 20; private static final int COLUMN_WIDTH_PX = 100; @@ -224,6 +531,11 @@ public class Escalator extends Widget { 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); @@ -251,24 +563,8 @@ public class Escalator extends Widget { deltaY = -0.5*e.wheelDelta; } - // TODO [[optimize]]: instead of using "+=" that potentially - // causes a reflow, update a new scroll absolute position. - if (!isNaN(deltaY)) { - // the scroll event handler will make sure the content is moved around appropriately - esc.@com.vaadin.client.ui.grid.Escalator::scrollerElem.scrollTop += deltaY; - } - - if (!isNaN(deltaX)) { - // the scroll event handler will make sure the content is moved around appropriately - esc.@com.vaadin.client.ui.grid.Escalator::scrollerElem.scrollLeft += deltaX; - } - - // TODO: only prevent if not scrolled to end/bottom. Or no? UX team needs to decide. - if (e.preventDefault) { - e.preventDefault(); - } else { - e.returnValue = false; - } + var scroller = esc.@com.vaadin.client.ui.grid.Escalator::scrollerElem; + @com.vaadin.client.ui.grid.Escalator.JsniUtil::moveScrollFromEvent(*)(scroller, deltaX, deltaY, e); }); }-*/; @@ -401,6 +697,14 @@ public class Escalator extends Widget { } public native void attachScrollListener(Element element) + /* + * Attaching events with JSNI instead of the GWT event mechanism because + * GWT didn't provide enough details in events, or triggering the event + * handlers with GWT bindings was unsuccessful. Maybe, with more time + * and skill, it could be done with better success. JavaScript overlay + * types might work. This might also get rid of the JsniWorkaround + * class. + */ /*-{ if (element.addEventListener) { element.addEventListener("scroll", this.@com.vaadin.client.ui.grid.JsniWorkaround::scrollListenerFunction); @@ -410,6 +714,14 @@ public class Escalator extends Widget { }-*/; public native void detachScrollListener(Element element) + /* + * Attaching events with JSNI instead of the GWT event mechanism because + * GWT didn't provide enough details in events, or triggering the event + * handlers with GWT bindings was unsuccessful. Maybe, with more time + * and skill, it could be done with better success. JavaScript overlay + * types might work. This might also get rid of the JsniWorkaround + * class. + */ /*-{ if (element.addEventListener) { element.removeEventListener("scroll", this.@com.vaadin.client.ui.grid.JsniWorkaround::scrollListenerFunction); @@ -419,8 +731,15 @@ public class Escalator extends Widget { }-*/; public native void attachMousewheelListener(Element element) + /* + * Attaching events with JSNI instead of the GWT event mechanism because + * GWT didn't provide enough details in events, or triggering the event + * handlers with GWT bindings was unsuccessful. Maybe, with more time + * and skill, it could be done with better success. JavaScript overlay + * types might work. This might also get rid of the JsniWorkaround + * class. + */ /*-{ - if (element.addEventListener) { // firefox likes "wheel", while others use "mousewheel" var eventName = element.onwheel===undefined?"mousewheel":"wheel"; @@ -432,6 +751,14 @@ public class Escalator extends Widget { }-*/; public native void detachMousewheelListener(Element element) + /* + * Detaching events with JSNI instead of the GWT event mechanism because + * GWT didn't provide enough details in events, or triggering the event + * handlers with GWT bindings was unsuccessful. Maybe, with more time + * and skill, it could be done with better success. JavaScript overlay + * types might work. This might also get rid of the JsniWorkaround + * class. + */ /*-{ if (element.addEventListener) { // firefox likes "wheel", while others use "mousewheel" @@ -443,6 +770,70 @@ public class Escalator extends Widget { } }-*/; + public native void attachTouchListeners(Element element) + /* + * Detaching events with JSNI instead of the GWT event mechanism because + * GWT didn't provide enough details in events, or triggering the event + * handlers with GWT bindings was unsuccessful. Maybe, with more time + * and skill, it could be done with better success. JavaScript overlay + * types might work. This might also get rid of the JsniWorkaround + * class. + */ + /*-{ + if (element.addEventListener) { + element.addEventListener("touchstart", this.@com.vaadin.client.ui.grid.JsniWorkaround::touchStartFunction); + element.addEventListener("touchmove", this.@com.vaadin.client.ui.grid.JsniWorkaround::touchMoveFunction); + element.addEventListener("touchend", this.@com.vaadin.client.ui.grid.JsniWorkaround::touchEndFunction); + element.addEventListener("touchcancel", this.@com.vaadin.client.ui.grid.JsniWorkaround::touchEndFunction); + } else { + // this would be IE8, but we don't support it with touch + } + }-*/; + + public native void detachTouchListeners(Element element) + /* + * Detaching events with JSNI instead of the GWT event mechanism because + * GWT didn't provide enough details in events, or triggering the event + * handlers with GWT bindings was unsuccessful. Maybe, with more time + * and skill, it could be done with better success. JavaScript overlay + * types might work. This might also get rid of the JsniWorkaround + * class. + */ + /*-{ + if (element.removeEventListener) { + element.removeEventListener("touchstart", this.@com.vaadin.client.ui.grid.JsniWorkaround::touchStartFunction); + element.removeEventListener("touchmove", this.@com.vaadin.client.ui.grid.JsniWorkaround::touchMoveFunction); + element.removeEventListener("touchend", this.@com.vaadin.client.ui.grid.JsniWorkaround::touchEndFunction); + element.removeEventListener("touchcancel", this.@com.vaadin.client.ui.grid.JsniWorkaround::touchEndFunction); + } else { + // this would be IE8, but we don't support it with touch + } + }-*/; + + 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 lastTime) { + currentFlickScroller = new FlickScrollAnimator(deltaX, deltaY, + lastTime); + 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"; @@ -1026,9 +1417,6 @@ public class Escalator extends Widget { @Deprecated private final Map<Element, Integer> rowTopPosMap = new HashMap<Element, Integer>(); - private int tBodyScrollTop = 0; - private int tBodyScrollLeft = 0; - public BodyRowContainer(final Element bodyElement) { super(bodyElement); } @@ -1489,7 +1877,7 @@ public class Escalator extends Widget { * anything to scroll by. Let's make sure the viewport is * scrolled to top, to render any rows possibly left above. */ - body.setBodyScrollPosition(body.tBodyScrollLeft, 0); + body.setBodyScrollPosition(tBodyScrollLeft, 0); /* * We might have removed some rows from the middle, so let's @@ -2006,6 +2394,28 @@ public class Escalator extends Widget { } } + // abs(atan(y/x))*(180/PI) = n deg, x = 1, solve y + /** + * The solution to + * <code>|tan<sup>-1</sup>(<i>x</i>)|×(180/π) = 30</code> + * . + * <p> + * This constant is placed in the Escalator class, instead of an inner + * class, since even mathematical expressions aren't allowed in non-static + * inner classes for constants. + */ + private static final double RATIO_OF_30_DEGREES = 1 / Math.sqrt(3); + /** + * The solution to + * <code>|tan<sup>-1</sup>(<i>x</i>)|×(180/π) = 40</code> + * . + * <p> + * This constant is placed in the Escalator class, instead of an inner + * class, since even mathematical expressions aren't allowed in non-static + * inner classes for constants. + */ + private static final double RATIO_OF_40_DEGREES = Math.tan(2 * Math.PI / 9); + private FlyweightRow flyweightRow = new FlyweightRow(this); /** The {@code <thead/>} tag. */ @@ -2015,6 +2425,9 @@ public class Escalator extends Widget { /** The {@code <tfoot/>} tag. */ private final Element footElem = DOM.createTFoot(); + private int tBodyScrollTop = 0; + private int tBodyScrollLeft = 0; + private final Element scrollerElem = DOM.createDiv(); private final Element innerScrollerElem = DOM.createDiv(); @@ -2117,9 +2530,11 @@ public class Escalator extends Widget { scroller.attachScrollListener(scrollerElem); scroller.attachMousewheelListener(getElement()); + scroller.attachTouchListeners(getElement()); } else { scroller.detachScrollListener(scrollerElem); scroller.detachMousewheelListener(getElement()); + scroller.detachTouchListeners(getElement()); } } }); @@ -2439,7 +2854,7 @@ public class Escalator extends Widget { } /** - * A routing method for {@link Scroller#onScroll(double, double)} + * A routing method for {@link Scroller#onScroll(double, double)}. * <p> * This is a workaround for GWT and JSNI unable to properly handle inner * classes, so instead we call the outer class' method, which calls the @@ -2451,4 +2866,36 @@ public class Escalator extends Widget { private void onScroll() { scroller.onScroll(); } + + /** + * Snap deltas of x and y to the major four axes (up, down, left, right) + * with a threshold of a number of degrees from those axes. + * + * @param deltaX + * the delta in the x axis + * @param deltaY + * the delta in the y axis + * @param thresholdRatio + * the threshold in ratio (0..1) between x and y for when to snap + * @return a two-element array: <code>[snappedX, snappedY]</code> + */ + private static double[] snapDeltas(final double deltaX, + final double deltaY, final double thresholdRatio) { + + final double[] array = new double[2]; + if (deltaX != 0 && deltaY != 0) { + final double aDeltaX = Math.abs(deltaX); + final double aDeltaY = Math.abs(deltaY); + final double yRatio = aDeltaY / aDeltaX; + final double xRatio = aDeltaX / aDeltaY; + + array[0] = (xRatio < thresholdRatio) ? 0 : deltaX; + array[1] = (yRatio < thresholdRatio) ? 0 : deltaY; + } else { + array[0] = deltaX; + array[1] = deltaY; + } + + return array; + } } |