blob: 5c05a36151e79d9fad3c6ec677cb379ce57efdd7 [file] [log] [blame]
/*
* Copyright 2011 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.touch.client;
import com.google.gwt.core.client.Duration;
import com.google.gwt.core.client.JsArray;
import com.google.gwt.core.client.Scheduler;
import com.google.gwt.core.client.Scheduler.RepeatingCommand;
import com.google.gwt.dom.client.PartialSupport;
import com.google.gwt.dom.client.Touch;
import com.google.gwt.event.dom.client.TouchCancelEvent;
import com.google.gwt.event.dom.client.TouchCancelHandler;
import com.google.gwt.event.dom.client.TouchEndEvent;
import com.google.gwt.event.dom.client.TouchEndHandler;
import com.google.gwt.event.dom.client.TouchEvent;
import com.google.gwt.event.dom.client.TouchMoveEvent;
import com.google.gwt.event.dom.client.TouchMoveHandler;
import com.google.gwt.event.dom.client.TouchStartEvent;
import com.google.gwt.event.dom.client.TouchStartHandler;
import com.google.gwt.event.shared.HandlerRegistration;
import com.google.gwt.touch.client.Momentum.State;
import com.google.gwt.user.client.Event;
import com.google.gwt.user.client.Event.NativePreviewEvent;
import com.google.gwt.user.client.Event.NativePreviewHandler;
import com.google.gwt.user.client.ui.HasScrolling;
import java.util.ArrayList;
import java.util.List;
/**
* Adds touch based scrolling to a scroll panel.
*
* <p>
* Touch based scrolling is only supported on devices that support touch events
* and do not implement native touch based scrolling.
* </p>
*/
@PartialSupport
public class TouchScroller {
/**
* A point associated with a time.
*
* Visible for testing.
*/
static class TemporalPoint {
private Point point;
private double time;
public TemporalPoint() {
}
/**
* Construct a new {@link TemporalPoint} for the specified point and time.
*/
public TemporalPoint(Point point, double time) {
setTemporalPoint(point, time);
}
public Point getPoint() {
return point;
}
public double getTime() {
return time;
}
/**
* Update the point and time.
*
* @param point the new point
* @param time the new time
*/
public void setTemporalPoint(Point point, double time) {
this.point = point;
this.time = time;
}
}
/**
* The command used to apply momentum.
*/
private class MomentumCommand implements RepeatingCommand {
private final Duration duration = new Duration();
private final Point initialPosition = getWidgetScrollPosition();
private int lastElapsedMillis = 0;
private State state;
/**
* Construct a {@link MomentumCommand}.
*
* @param endVelocity the final velocity of the user drag
*/
public MomentumCommand(Point endVelocity) {
state = momentum.createState(initialPosition, endVelocity);
}
public boolean execute() {
/*
* Stop the command if another touch event starts or if momentum is
* disabled.
*/
if (this != momentumCommand) {
return false;
}
// Get the current position from the momentum.
int cumulativeElapsedMillis = duration.elapsedMillis();
state.setElapsedMillis(cumulativeElapsedMillis - lastElapsedMillis);
lastElapsedMillis = cumulativeElapsedMillis;
state.setCumulativeElapsedMillis(cumulativeElapsedMillis);
// Calculate the new state.
boolean notDone = momentum.updateState(state);
// Momementum is finished, so the user is free to click.
if (!notDone) {
setBustNextClick(false);
}
/*
* Apply the new position. Even if there is no additional momentum, we
* want to respect the end position that the momentum returns.
*/
setWidgetScrollPosition(state.getPosition());
return notDone;
}
}
/**
* The number of frames per second the animation should run at.
*/
private static final double FRAMES_PER_SECOND = 60;
/**
* The number of ms to wait during a drag before updating the reported start
* position of the drag.
*/
private static final double MAX_TRACKING_TIME = 200;
/**
* The number of ms to wait before putting a position on deck.
*/
private static final double MAX_TRACKING_TIME_ON_DECK = MAX_TRACKING_TIME / 2;
/**
* Minimum movement of touch required to be considered a drag.
*/
private static final double MIN_TRACKING_FOR_DRAG = 5;
/**
* The number of milliseconds per animation frame.
*/
private static final int MS_PER_FRAME = (int) (1000 / FRAMES_PER_SECOND);
/**
* A cached boolean indicating whether or not touch scrolling is supported.
* Set to a non-null value the first time {@link #isSupported()} is called.
*/
private static Boolean isSupported;
/**
* Return a new {@link TouchScroller}.
*
* @return a new {@link TouchScroller} if supported, and null otherwise
*/
public static TouchScroller createIfSupported() {
return isSupported() ? new TouchScroller() : null;
}
/**
* Return a new {@link TouchScroller} that augments the specified scrollable
* widget if supported, and null otherwise.
*
* @param widget the scrollable widget
* @return a new {@link TouchScroller} if supported, and null otherwise
*/
public static TouchScroller createIfSupported(HasScrolling widget) {
TouchScroller scroller = createIfSupported();
if (scroller != null) {
scroller.setTargetWidget(widget);
}
return scroller;
}
/**
* Runtime check for whether touch scrolling is supported in this browser.
* Returns true if touch events are supported but touch based scrolling is not
* natively supported.
*
* @return true if touch scrolling is supported, false if not
*/
public static boolean isSupported() {
if (isSupported == null) {
/*
* Android 3.0 devices support touch scrolling natively.
*
* TODO(jlabanca): Find a more reliable way to detect if native touch
* scrolling is supported.
*/
isSupported = TouchEvent.isSupported() && !isAndroid3();
}
return isSupported;
}
/**
* Check if the user agent is android 3.0 or greater.
*
* @return true if android 3.0+
*
*/
private static native boolean isAndroid3() /*-{
var ua = navigator.userAgent.toLowerCase();
return /android ([3-9]+)\.([0-9]+)/.exec(ua) != null;
}-*/;
/**
* The registration for the preview handler used to bust click events.
*/
private HandlerRegistration bustClickHandler;
/**
* A boolean indicating that we are in a drag sequence. Dragging occurs after
* the user moves beyond a threshold distance.
*/
private boolean dragging;
/**
* Registrations for the handlers added to the widget.
*/
private final List<HandlerRegistration> handlerRegs = new ArrayList<HandlerRegistration>();
/**
* The last (most recent) touch position. We need to keep track of this when
* we handle touch move events because the Touch is already destroyed before
* the touch end event fires.
*/
private final TemporalPoint lastTouchPosition = new TemporalPoint();
/**
* The momentum that determines how the widget scrolls after the user
* completes a gesture. Can be null if momentum is not supported.
*/
private Momentum momentum;
/**
* The repeating command used to continue momentum after the gesture ends. The
* command is instantiated after the user finishes a drag sequence. A non null
* value indicates that momentum is occurring.
*/
private RepeatingCommand momentumCommand;
/**
* The coordinate of the most recent relevant touch event. For most drag
* sequences this will be the same as the startCoordinate. If the touch
* gesture changes direction significantly or pauses for a while this
* coordinate will be updated to the coordinate of the on deck touchmove
* event.
*/
private final TemporalPoint recentTouchPosition = new TemporalPoint();
/**
* If the gesture takes too long, we update the recentTouchPosition to the
* position on deck, which occurred halfway through the max tracking time. We
* do this so that we don't base the velocity on two touch events that
* occurred very close to each other at the end of a long gesture.
*/
private TemporalPoint recentTouchPositionOnDeck;
/**
* The position of the scrollable when the first touch occured.
*/
private Point startScrollPosition;
/**
* The position of the first touch.
*/
private Point startTouchPosition;
/**
* A boolean indicating that we are in a touch sequence.
*/
private boolean touching;
/**
* The widget being augmented.
*/
private HasScrolling widget;
/**
* Construct a new {@link TouchScroller}. This constructor should be called
* using the static method {@link #createIfSupported()}.
*
* @param widget the widget to augment
* @see #createIfSupported()
*/
protected TouchScroller() {
setMomentum(new DefaultMomentum());
}
/**
* Get the {@link Momentum} that controls scrolling after the user completes a
* gesture.
*
* @return the scrolling {@link Momentum}, or null if disabled
*/
public Momentum getMomentum() {
return momentum;
}
/**
* Get the target {@link HasScrolling} widget that this scroller affects.
*
* @return the target widget
*/
public HasScrolling getTargetWidget() {
return widget;
}
/**
* Set the {@link Momentum} that controls scrolling after the user completes a
* gesture.
*
* @param momentum the scrolling {@link Momentum}, or null to disable
*/
public void setMomentum(Momentum momentum) {
this.momentum = momentum;
if (momentum == null) {
// Cancel the current momentum.
momentumCommand = null;
}
}
/**
* Set the target {@link HasScrolling} widget that this scroller affects.
*
* @param widget the target widget, or null to disbale
*/
public void setTargetWidget(HasScrolling widget) {
if (this.widget == widget) {
return;
}
// Cancel drag and momentum.
cancelAll();
setBustNextClick(false);
// Release the old widget.
if (this.widget != null) {
for (HandlerRegistration reg : handlerRegs) {
reg.removeHandler();
}
handlerRegs.clear();
}
// Attach to the new widget.
this.widget = widget;
if (widget != null) {
// Add touch start handler.
handlerRegs.add(widget.asWidget().addDomHandler(new TouchStartHandler() {
public void onTouchStart(TouchStartEvent event) {
TouchScroller.this.onTouchStart(event);
}
}, TouchStartEvent.getType()));
// Add touch move handler.
handlerRegs.add(widget.asWidget().addDomHandler(new TouchMoveHandler() {
public void onTouchMove(TouchMoveEvent event) {
TouchScroller.this.onTouchMove(event);
}
}, TouchMoveEvent.getType()));
// Add touch end handler.
handlerRegs.add(widget.asWidget().addDomHandler(new TouchEndHandler() {
public void onTouchEnd(TouchEndEvent event) {
TouchScroller.this.onTouchEnd(event);
}
}, TouchEndEvent.getType()));
// Add touch cancel handler.
handlerRegs.add(widget.asWidget().addDomHandler(new TouchCancelHandler() {
public void onTouchCancel(TouchCancelEvent event) {
TouchScroller.this.onTouchCancel(event);
}
}, TouchCancelEvent.getType()));
}
}
/**
* Get touch from event.
*
* @param event the event
* @return the touch object
*/
protected Touch getTouchFromEvent(TouchEvent<?> event) {
JsArray<Touch> touches = event.getTouches();
return (touches.length() > 0) ? touches.get(0) : null;
}
/**
* Called when the object's drag sequence is complete.
*
* @param event the touch event
*/
protected void onDragEnd(TouchEvent<?> event) {
// There is no momentum or it isn't supported.
if (momentum == null) {
return;
}
// Schedule the momentum.
Point endVelocity = calculateEndVelocity(recentTouchPosition, lastTouchPosition);
if (endVelocity != null) {
momentumCommand = new MomentumCommand(endVelocity);
Scheduler.get().scheduleFixedDelay(momentumCommand, MS_PER_FRAME);
}
}
/**
* Called when the object has been dragged to a new position.
*
* @param event the touch event
*/
protected void onDragMove(TouchEvent<?> event) {
/*
* Scroll to the new position. Touch scrolling moves in the same direction
* as the finger dragging, whereas scrolling is inverted with traditional
* scrollbars.
*/
Point diff = startTouchPosition.minus(lastTouchPosition.getPoint());
Point curScrollPosition = startScrollPosition.plus(diff);
setWidgetScrollPosition(curScrollPosition);
}
/**
* Called when the object has started dragging.
*
* @param event the touch event
*/
protected void onDragStart(TouchEvent<?> event) {
}
/**
* Called when the user cancels a touch. This can happen if the user touches
* the screen with too many fingers.
*
* @param event the touch event
*/
protected void onTouchCancel(TouchEvent<?> event) {
onTouchEnd(event);
}
/**
* Called when the user releases a touch.
*
* @param event the touch event
*/
protected void onTouchEnd(TouchEvent<?> event) {
// Ignore the touch if we didn't catch a touch start event.
if (!touching) {
return;
}
touching = false;
// Stop dragging.
if (dragging) {
dragging = false;
onDragEnd(event);
}
}
/**
* Called when the user moves a touch.
*
* @param event the touch event
*/
protected void onTouchMove(TouchEvent<?> event) {
// Ignore the touch if we never caught a touch start event.
if (!touching) {
return;
}
// Prevent native scrolling.
event.preventDefault();
// Check if we should start dragging.
Touch touch = getTouchFromEvent(event);
Point touchPoint = new Point(touch.getPageX(), touch.getPageY());
double touchTime = Duration.currentTimeMillis();
lastTouchPosition.setTemporalPoint(touchPoint, touchTime);
if (!dragging) {
Point diff = touchPoint.minus(startTouchPosition);
double absDiffX = Math.abs(diff.getX());
double absDiffY = Math.abs(diff.getY());
if (absDiffX > MIN_TRACKING_FOR_DRAG || absDiffY > MIN_TRACKING_FOR_DRAG) {
// Start dragging.
dragging = true;
onDragStart(event);
}
}
if (dragging) {
// Continue dragging.
onDragMove(event);
/*
* Update the recent position. This happens when they are dragging slowly.
* If they are dragging slowly then we should reset the start time and
* position to where they are now. This will be important during the drag
* end when we report to the draggable delegate what kind of drag just
* happened.
*/
double trackingTime = touchTime - recentTouchPosition.getTime();
if (trackingTime > MAX_TRACKING_TIME && recentTouchPositionOnDeck != null) {
// See comment below.
recentTouchPosition.setTemporalPoint(recentTouchPositionOnDeck.getPoint(),
recentTouchPositionOnDeck.getTime());
recentTouchPositionOnDeck = null;
} else if (trackingTime > MAX_TRACKING_TIME_ON_DECK && recentTouchPositionOnDeck == null) {
/*
* When we are halfway to the max tracking time, put the current touch
* on deck. When we switch the recent touch position, we use the on deck
* position. That prevents us from calculating the velocity from two
* points that are too close in time (or the same time).
*/
recentTouchPositionOnDeck = new TemporalPoint(touchPoint, touchTime);
}
}
}
/**
* Called when the user starts a touch.
*
* @param event the touch event
*/
protected void onTouchStart(TouchEvent<?> event) {
// Ignore the touch if there is already a touch happening.
if (touching) {
return;
}
/*
* If the user touches the screen while momentum is scrolling, bust the next
* click event. They probably want to pause the momentum, not click an item.
*/
setBustNextClick(isMomentumActive());
cancelAll();
touching = true;
// Record the starting touch position.
Touch touch = getTouchFromEvent(event);
startTouchPosition = new Point(touch.getPageX(), touch.getPageY());
double startTouchTime = Duration.currentTimeMillis();
recentTouchPosition.setTemporalPoint(startTouchPosition, startTouchTime);
lastTouchPosition.setTemporalPoint(startTouchPosition, startTouchTime);
recentTouchPositionOnDeck = null;
// Record the starting scroll position.
startScrollPosition = getWidgetScrollPosition();
}
/**
* Calculate the end velocity. Visible for testing.
*
* @param from the starting point
* @param to the ending point
* @return the end velocity, or null if it cannot be calculated
*/
Point calculateEndVelocity(TemporalPoint from, TemporalPoint to) {
/*
* Calculate the time since the recent touch. The time can be zero if the
* user pauses for too long, which updates the recentTouchPosition, then
* lets go without moving again.
*/
double time = to.getTime() - from.getTime();
if (time <= 0) {
return null;
}
/*
* Calculate the end velocities. The velocity is inverted from the direction
* of the gesture.
*/
Point dist = from.getPoint().minus(to.getPoint());
return new Point(dist.getX() / time, dist.getY() / time);
}
/**
* Visible for testing.
*/
TemporalPoint getLastTouchPosition() {
return lastTouchPosition;
}
/**
* Visible for testing.
*/
TemporalPoint getRecentTouchPosition() {
return recentTouchPosition;
}
/**
* Visible for testing.
*/
boolean isDragging() {
return dragging;
}
/**
* Check if momentum is currently active. Visible for testing.
*
* @return true if active, false if not
*/
boolean isMomentumActive() {
return (momentumCommand != null);
}
/**
* Visible for testing.
*/
boolean isTouching() {
return touching;
}
/**
* Cancel all existing touch, drag, and momentum.
*/
private void cancelAll() {
touching = false;
dragging = false;
momentumCommand = null;
}
/**
* Get the scroll position of the widget.
*
* @param position the current scroll position
*/
private Point getWidgetScrollPosition() {
return new Point(widget.getHorizontalScrollPosition(), widget.getVerticalScrollPosition());
}
/**
* Set whether or not we should bust the next click.
*
* @param doBust true to bust the next click, false not to
*/
private void setBustNextClick(boolean doBust) {
if (doBust && bustClickHandler == null) {
bustClickHandler = Event.addNativePreviewHandler(new NativePreviewHandler() {
public void onPreviewNativeEvent(NativePreviewEvent event) {
if (Event.ONCLICK == event.getTypeInt()) {
event.getNativeEvent().stopPropagation();
event.getNativeEvent().preventDefault();
setBustNextClick(false);
}
};
});
} else if (!doBust && bustClickHandler != null) {
bustClickHandler.removeHandler();
bustClickHandler = null;
}
}
/**
* Set the scroll position of the widget.
*
* @param position the new position
*/
private void setWidgetScrollPosition(Point position) {
widget.setHorizontalScrollPosition((int) position.getX());
widget.setVerticalScrollPosition((int) position.getY());
}
}