Adding touch scrolling support to ScrollPanel for mobile support. TouchScroller augments an existing widget implementor of HasScrolling. By default, it is enabled in ScrollPanel so users don't have to worry about adding special mobile support (most users are surprised that ScrollPanel isn't natively supported on mobile browsers).  Power users can disable the default TouchScroller and implement their own, with specialized Momentum logic.  However, most users will just use the default implementation.

Review at http://gwt-code-reviews.appspot.com/1370801

Review by: pdr@google.com

git-svn-id: https://google-web-toolkit.googlecode.com/svn/trunk@9814 8db76d5a-ed1c-0410-87a9-c151d255dfc7
diff --git a/user/src/com/google/gwt/dom/client/DOMImpl.java b/user/src/com/google/gwt/dom/client/DOMImpl.java
index 0c55278..fd1a45d 100644
--- a/user/src/com/google/gwt/dom/client/DOMImpl.java
+++ b/user/src/com/google/gwt/dom/client/DOMImpl.java
@@ -408,11 +408,11 @@
   }-*/;
 
   public native int touchGetScreenX(Touch touch)/*-{
-    return touch.ScreenX;
+    return touch.screenX;
   }-*/;
 
   public native int touchGetScreenY(Touch touch)/*-{
-    return touch.ScreenY;
+    return touch.screenY;
   }-*/;
 
   public native EventTarget touchGetTarget(Touch touch)/*-{
diff --git a/user/src/com/google/gwt/touch/Touch.gwt.xml b/user/src/com/google/gwt/touch/Touch.gwt.xml
new file mode 100644
index 0000000..959061d
--- /dev/null
+++ b/user/src/com/google/gwt/touch/Touch.gwt.xml
@@ -0,0 +1,37 @@
+<!--                                                                        -->
+<!-- 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   -->
+<!-- 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. License for the specific language governing permissions and   -->
+<!-- limitations under the License.                                         -->
+<module>
+  <inherits name="com.google.gwt.user.User" />
+
+  <!-- Define the support property -->
+  <define-property name="touchEventSupport" values="maybe,no" />
+
+  <!--
+    Modern browsers either support touch events or will probably add touch
+    support in the future.
+  -->
+  <set-property name="touchEventSupport" value="maybe" />
+
+  <!-- Older browsers do not support touch events. -->
+  <set-property name="touchEventSupport" value="no">
+    <any>
+      <when-property-is name="user.agent" value="ie6" />
+    </any>
+  </set-property>
+
+  <replace-with class="com.google.gwt.touch.client.TouchScroller.TouchSupportDetectorNo">
+    <when-type-is class="com.google.gwt.touch.client.TouchScroller.TouchSupportDetector" />
+    <when-property-is name="touchEventSupport" value="no" />
+  </replace-with>
+</module>
diff --git a/user/src/com/google/gwt/touch/client/DefaultMomentum.java b/user/src/com/google/gwt/touch/client/DefaultMomentum.java
new file mode 100644
index 0000000..7681cdc
--- /dev/null
+++ b/user/src/com/google/gwt/touch/client/DefaultMomentum.java
@@ -0,0 +1,103 @@
+/*
+ * 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;
+
+/**
+ * Default implementation of momentum.
+ */
+public class DefaultMomentum implements Momentum {
+
+  /**
+   * The constant factor applied to velocity every millisecond to simulate
+   * deceleration.
+   */
+  private static final double DECELERATION_FACTOR = 0.9993;
+
+  /**
+   * The velocity threshold at which declereration will end.
+   */
+  private static final double DECELERATION_STOP_VELOCITY = 0.02;
+
+  /**
+   * The minimum deceleration rate in pixels per millisecond^2.
+   */
+  private static final double MIN_DECELERATION = 0.0005;
+
+  public State createState(Point initialPosition, Point initialVelocity) {
+    return new State(initialPosition, initialVelocity);
+  }
+
+  public boolean updateState(State state) {
+    // Calculate the new velocity.
+    int ellapsedMillis = state.getElapsedMillis();
+    int totalEllapsedMillis = state.getCumulativeElapsedMillis();
+    Point initialVelocity = state.getInitialVelocity();
+    Point oldVelocity = state.getVelocity();
+    double decelFactor = Math.pow(DECELERATION_FACTOR, totalEllapsedMillis);
+    double minDecel = ellapsedMillis * MIN_DECELERATION;
+    double newVelocityX =
+        calcNewVelocity(initialVelocity.getX(), decelFactor, oldVelocity.getX(), minDecel);
+    double newVelocityY =
+        calcNewVelocity(initialVelocity.getY(), decelFactor, oldVelocity.getY(), minDecel);
+
+    // Save the new velocity.
+    Point newVelocity = new Point(newVelocityX, newVelocityY);
+    state.setVelocity(newVelocity);
+
+    // Calculate the distance traveled.
+    int elapsedMillis = state.getElapsedMillis();
+    Point dist = newVelocity.mult(new Point(elapsedMillis, elapsedMillis));
+
+    // Update the state with the new point.
+    Point position = state.getPosition();
+    state.setPosition(position.plus(dist));
+
+    // End momentum when we reach the threshold.
+    if (Math.abs(newVelocity.getX()) < DECELERATION_STOP_VELOCITY
+        && Math.abs(newVelocity.getY()) < DECELERATION_STOP_VELOCITY) {
+      return false;
+    }
+    return true;
+  }
+
+  /**
+   * Calculate the new velocity.
+   * 
+   * @param initialVelocity the initial velocity
+   * @param decelFactor the deceleration factor based on the cumulative elapsed
+   *          time
+   * @param oldVelocity the previous velocity
+   * @param minDecel the absolute value of the minimum deceleration over the
+   *          elapsed time since the last update
+   * @return the new velocity
+   */
+  private double calcNewVelocity(double initialVelocity, double decelFactor, double oldVelocity,
+      double minDecel) {
+    // Calculate the new velocity based on the deceleration factor.
+    double newVelocity = initialVelocity * decelFactor;
+
+    // Ensure that we are decelerating faster than the minimum rate.
+    if (oldVelocity >= 0) {
+      double maxVelocityX = Math.max(0.0, oldVelocity - minDecel);
+      newVelocity = Math.min(newVelocity, maxVelocityX);
+    } else {
+      double minVelocityX = Math.min(0.0, oldVelocity + minDecel);
+      newVelocity = Math.max(newVelocity, minVelocityX);
+    }
+
+    return newVelocity;
+  }
+}
diff --git a/user/src/com/google/gwt/touch/client/Momentum.java b/user/src/com/google/gwt/touch/client/Momentum.java
new file mode 100644
index 0000000..21d7ba8
--- /dev/null
+++ b/user/src/com/google/gwt/touch/client/Momentum.java
@@ -0,0 +1,173 @@
+/*
+ * 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;
+
+/**
+ * Describes the momentum of a gesture after the gesture has been completed. You
+ * can use it to control the speed of scrolling for scrollable widgets affected
+ * by {@link TouchScroller}.
+ */
+public interface Momentum {
+
+  /**
+   * A snapshot of the current state.
+   */
+  public static class State {
+
+    private int cumulativeElapsedMillis = 0;
+    private int elapsedMillis = 0;
+    private final Point initialPosition;
+    private final Point initialVelocity;
+    private Point position;
+    private Point velocity;
+
+    /**
+     * Construct a new {@link State}.
+     * 
+     * @param initialPosition the initial position, which is also set to the
+     *          current position
+     * @param initialVelocity the initial velocity in pixels per millisecond,
+     *          which is also set to the current velocity
+     * @param elapsedMillis the elapsed time since the momentum took over
+     */
+    public State(Point initialPosition, Point initialVelocity) {
+      this.initialPosition = initialPosition;
+      this.initialVelocity = initialVelocity;
+      this.position = new Point(initialPosition);
+      this.velocity = new Point(initialVelocity);
+    }
+
+    /**
+     * Get the cumulative elapsed time in milliseconds since momentum took over.
+     * 
+     * @return the elapsed time in milliseconds
+     */
+    public int getCumulativeElapsedMillis() {
+      return cumulativeElapsedMillis;
+    }
+
+    /**
+     * Get the elapsed time in milliseconds since the last time the state was
+     * updated.
+     * 
+     * @return the elapsed time in milliseconds
+     */
+    public int getElapsedMillis() {
+      return elapsedMillis;
+    }
+
+    /**
+     * Get the initial position when the momentum took over.
+     * 
+     * @return the initial position
+     */
+    public Point getInitialPosition() {
+      return initialPosition;
+    }
+
+    /**
+     * Get the initial velocity in pixels per millisecond when the momentum took
+     * over.
+     * 
+     * @return the initial velocity
+     */
+    public Point getInitialVelocity() {
+      return initialVelocity;
+    }
+
+    /**
+     * Get the current position.
+     * 
+     * @return the current position
+     */
+    public Point getPosition() {
+      return position;
+    }
+
+    /**
+     * Get the current velocity in pixels per millisecond.
+     * 
+     * @return the current velocity
+     */
+    public Point getVelocity() {
+      return velocity;
+    }
+
+    /**
+     * Set the current position.
+     * 
+     * @param position set the current position
+     */
+    public void setPosition(Point position) {
+      this.position = position;
+    }
+
+    /**
+     * Get the current velocity in pixels per millisecond.
+     * 
+     * @param velocity set the current velocity
+     */
+    public void setVelocity(Point velocity) {
+      this.velocity = velocity;
+    }
+
+    /**
+     * Set the cumulative elapsed time in milliseconds since momentum took over.
+     * 
+     * @return the elapsed time in milliseconds
+     */
+    void setCumulativeElapsedMillis(int cumulativeElapsedMillis) {
+      this.cumulativeElapsedMillis = cumulativeElapsedMillis;
+    }
+
+    /**
+     * Set the elapsed time in milliseconds since the last time the state was
+     * updated.
+     * 
+     * @return the elapsed time
+     */
+    void setElapsedMillis(int elapsedMillis) {
+      this.elapsedMillis = elapsedMillis;
+    }
+  }
+
+  /**
+   * Create a {@link State} instance. The {@link State} instance will be passed
+   * to {@link Momentum#updateState(State)} until the momentum is depleted.
+   * 
+   * @param initialPosition the initial position
+   * @param initialVelocity the initial velocity in pixels per millisecond
+   */
+  State createState(Point initialPosition, Point initialVelocity);
+
+  /**
+   * <p>
+   * Update the state based on the specified {@link State}. When no more
+   * momentum remains, this method should return false to stop future calls.
+   * </p>
+   * <p>
+   * The {@link State} instance is created by a call to
+   * {@link #createState(Point, Point)}, and the same instance if used for the
+   * duration of the momentum. This method should modify the existing state by
+   * calling {@link State#setPosition(Point)} and/or
+   * {@link State#setVelocity(Point)}.
+   * </p>
+   * 
+   * @param state the current state
+   * @return true to continue momentum, false if no momentum remains
+   */
+  boolean updateState(State state);
+}
diff --git a/user/src/com/google/gwt/touch/client/Point.java b/user/src/com/google/gwt/touch/client/Point.java
new file mode 100644
index 0000000..f07021b
--- /dev/null
+++ b/user/src/com/google/gwt/touch/client/Point.java
@@ -0,0 +1,119 @@
+/*
+ * 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;
+
+/**
+ * A simple point class.
+ */
+public class Point {
+
+  private final double x;
+  private final double y;
+
+  public Point() {
+    this(0.0, 0.0);
+  }
+
+  public Point(double x, double y) {
+    this.x = x;
+    this.y = y;
+  }
+
+  public Point(Point c) {
+    this(c.x, c.y);
+  }
+
+  /**
+   * Divide this point {@link Point} by specified point and return the result.
+   * Does not modified this {@link Point}.
+   * 
+   * @param c the value by which to divide
+   * @return the resulting point
+   */
+  public Point div(Point c) {
+    return new Point(x / c.x, y / c.y);
+  }
+
+  @Override
+  public boolean equals(Object obj) {
+    if (!(obj instanceof Point)) {
+      return false;
+    }
+    Point c = (Point) obj;
+    return (x == c.x) && (y == c.y);
+  }
+
+  /**
+   * Get the x value of the point.
+   * 
+   * @return the x value
+   */
+  public double getX() {
+    return x;
+  }
+
+  /**
+   * Get the y value of the point.
+   * 
+   * @return the y value
+   */
+  public double getY() {
+    return y;
+  }
+
+  @Override
+  public int hashCode() {
+    return (int) x ^ (int) y;
+  }
+
+  /**
+   * Subtract the specified {@link Point} from this point and return the result.
+   * Does not modified this {@link Point}.
+   * 
+   * @param c the value to subtract
+   * @return the resulting point
+   */
+  public Point minus(Point c) {
+    return new Point(x - c.x, y - c.y);
+  }
+
+  /**
+   * Multiple this point {@link Point} by specified point and return the result.
+   * Does not modified this {@link Point}.
+   * 
+   * @param c the value by which to multiply
+   * @return the resulting point
+   */
+  public Point mult(Point c) {
+    return new Point(x * c.x, y * c.y);
+  }
+
+  /**
+   * Add the specified {@link Point} to this point and return the result. Does
+   * not modified this {@link Point}.
+   * 
+   * @param c the value to add
+   * @return the resulting point
+   */
+  public Point plus(Point c) {
+    return new Point(x + c.x, y + c.y);
+  }
+
+  @Override
+  public String toString() {
+    return "Point(" + x + "," + y + ")";
+  }
+}
diff --git a/user/src/com/google/gwt/touch/client/TouchScroller.java b/user/src/com/google/gwt/touch/client/TouchScroller.java
new file mode 100644
index 0000000..fd9967a
--- /dev/null
+++ b/user/src/com/google/gwt/touch/client/TouchScroller.java
@@ -0,0 +1,703 @@
+/*
+ * 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.GWT;
+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.
+ */
+@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;
+    }
+  }
+
+  /**
+   * Dectector for browser support for touch events.
+   */
+  private static class TouchSupportDetector {
+
+    private final boolean isSupported = detectTouchSupport();
+
+    public boolean isSupported() {
+      return isSupported;
+    }
+
+    private native boolean detectTouchSupport() /*-{
+      var elem = document.createElement('div');
+      elem.setAttribute('ontouchstart', 'return;');
+      return (typeof elem.ontouchstart) == "function";
+    }-*/;
+  }
+
+  /**
+   * Detector for browsers that do not support touch events.
+   */
+  @SuppressWarnings("unused")
+  private static class TouchSupportDetectorNo extends TouchSupportDetector {
+    @Override
+    public boolean isSupported() {
+      return false;
+    }
+  }
+
+  /**
+   * 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);
+
+  /**
+   * The implementation singleton.
+   */
+  private static TouchSupportDetector impl;
+
+  /**
+   * 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 events are supported in this browser.
+   * 
+   * @return true if touch events are is supported, false it not
+   */
+  public static boolean isSupported() {
+    return impl().isSupported();
+  }
+
+  /**
+   * Get the implementation of this widget.
+   * 
+   * @return the implementation
+   */
+  private static TouchSupportDetector impl() {
+    if (impl == null) {
+      impl = GWT.create(TouchSupportDetector.class);
+    }
+    return impl;
+  }
+
+  /**
+   * 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());
+  }
+}
diff --git a/user/src/com/google/gwt/user/ScrollPanel.gwt.xml b/user/src/com/google/gwt/user/ScrollPanel.gwt.xml
new file mode 100644
index 0000000..0fc3c65
--- /dev/null
+++ b/user/src/com/google/gwt/user/ScrollPanel.gwt.xml
@@ -0,0 +1,25 @@
+<!--
+  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.
+-->
+<module>
+  <inherits name="com.google.gwt.user.User"/>
+
+  <replace-with class="com.google.gwt.user.client.ui.ScrollPanel.ImplRtlReversed">
+    <when-type-is class="com.google.gwt.user.client.ui.ScrollPanel.Impl"/>
+    <any>
+      <when-property-is name="user.agent" value="gecko1_8"/>
+    </any>
+  </replace-with>
+</module>
diff --git a/user/src/com/google/gwt/user/User.gwt.xml b/user/src/com/google/gwt/user/User.gwt.xml
index adf164d..5611bc9 100644
--- a/user/src/com/google/gwt/user/User.gwt.xml
+++ b/user/src/com/google/gwt/user/User.gwt.xml
@@ -27,6 +27,7 @@
    <inherits name="com.google.gwt.resources.Resources"/>
    <inherits name="com.google.gwt.layout.Layout"/>
    <inherits name="com.google.gwt.media.Media"/>
+   <inherits name="com.google.gwt.touch.Touch"/>
    <inherits name="com.google.gwt.uibinder.UiBinder"/>
    <inherits name="com.google.gwt.user.AsyncProxy"/>
    <inherits name="com.google.gwt.user.RemoteService"/>
@@ -50,6 +51,7 @@
    <inherits name="com.google.gwt.user.Hyperlink"/>
    <inherits name="com.google.gwt.user.FileUpload"/>
    <inherits name="com.google.gwt.user.ResizeLayoutPanel"/>
+   <inherits name="com.google.gwt.user.ScrollPanel"/>
    <inherits name="com.google.gwt.user.datepicker.DatePicker"/>
    <inherits name="com.google.gwt.user.cellview.CellView"/>
    <inherits name="com.google.gwt.safehtml.SafeHtml" />
diff --git a/user/src/com/google/gwt/user/client/ui/HasHorizontalScrolling.java b/user/src/com/google/gwt/user/client/ui/HasHorizontalScrolling.java
new file mode 100644
index 0000000..b6a0397
--- /dev/null
+++ b/user/src/com/google/gwt/user/client/ui/HasHorizontalScrolling.java
@@ -0,0 +1,51 @@
+/*
+ * 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.user.client.ui;
+
+/**
+ * Implemented by widgets that support horizontal scrolling.
+ */
+public interface HasHorizontalScrolling {
+
+  /**
+   * Gets the horizontal scroll position.
+   * 
+   * @return the horizontal scroll position, in pixels
+   */
+  int getHorizontalScrollPosition();
+
+  /**
+   * Get the maximum position of horizontal scrolling. This is usually the
+   * <code>scrollWidth - clientWidth</code>.
+   * 
+   * @return the maximum horizontal scroll position
+   */
+  int getMaximumHorizontalScrollPosition();
+
+  /**
+   * Get the minimum position of horizontal scrolling.
+   * 
+   * @return the minimum horizontal scroll position
+   */
+  int getMinimumHorizontalScrollPosition();
+
+  /**
+   * Sets the horizontal scroll position.
+   * 
+   * @param position the new horizontal scroll position, in pixels
+   */
+  void setHorizontalScrollPosition(int position);
+}
diff --git a/user/src/com/google/gwt/user/client/ui/HasScrolling.java b/user/src/com/google/gwt/user/client/ui/HasScrolling.java
new file mode 100644
index 0000000..1691b6f
--- /dev/null
+++ b/user/src/com/google/gwt/user/client/ui/HasScrolling.java
@@ -0,0 +1,26 @@
+/*
+ * 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.user.client.ui;
+
+import com.google.gwt.event.dom.client.HasScrollHandlers;
+
+/**
+ * Implemented by widgets that support scrolling.
+ */
+public interface HasScrolling extends HasHorizontalScrolling,
+    HasVerticalScrolling, HasScrollHandlers, IsWidget {
+
+}
diff --git a/user/src/com/google/gwt/user/client/ui/HasVerticalScrolling.java b/user/src/com/google/gwt/user/client/ui/HasVerticalScrolling.java
new file mode 100644
index 0000000..2e260b5
--- /dev/null
+++ b/user/src/com/google/gwt/user/client/ui/HasVerticalScrolling.java
@@ -0,0 +1,51 @@
+/*
+ * 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.user.client.ui;
+
+/**
+ * Implemented by widgets that support vertical scrolling.
+ */
+public interface HasVerticalScrolling {
+
+  /**
+   * Get the maximum position of vertical scrolling. This is usually the
+   * <code>scrollHeight - clientHeight</code>.
+   * 
+   * @return the maximum vertical scroll position
+   */
+  int getMaximumVerticalScrollPosition();
+
+  /**
+   * Get the minimum position of vertical scrolling.
+   * 
+   * @return the minimum vertical scroll position
+   */
+  int getMinimumVerticalScrollPosition();
+
+  /**
+   * Gets the vertical scroll position.
+   * 
+   * @return the vertical scroll position, in pixels
+   */
+  int getVerticalScrollPosition();
+
+  /**
+   * Sets the vertical scroll position.
+   * 
+   * @param position the new vertical scroll position, in pixels
+   */
+  void setVerticalScrollPosition(int position);
+}
diff --git a/user/src/com/google/gwt/user/client/ui/ScrollPanel.java b/user/src/com/google/gwt/user/client/ui/ScrollPanel.java
index 229c973..c9bb1c0 100644
--- a/user/src/com/google/gwt/user/client/ui/ScrollPanel.java
+++ b/user/src/com/google/gwt/user/client/ui/ScrollPanel.java
@@ -15,10 +15,11 @@
  */
 package com.google.gwt.user.client.ui;
 
-import com.google.gwt.event.dom.client.HasScrollHandlers;
+import com.google.gwt.core.client.GWT;
 import com.google.gwt.event.dom.client.ScrollEvent;
 import com.google.gwt.event.dom.client.ScrollHandler;
 import com.google.gwt.event.shared.HandlerRegistration;
+import com.google.gwt.touch.client.TouchScroller;
 import com.google.gwt.user.client.DOM;
 import com.google.gwt.user.client.Element;
 
@@ -27,14 +28,80 @@
  */
 @SuppressWarnings("deprecation")
 public class ScrollPanel extends SimplePanel implements SourcesScrollEvents,
-    HasScrollHandlers, RequiresResize, ProvidesResize {
+    RequiresResize, ProvidesResize, HasScrolling {
+
+  /**
+   * Implementation of this widget.
+   */
+  static class Impl {
+    /**
+     * Get the maximum horizontal scroll position.
+     * 
+     * @param scrollable the scrollable element
+     * @return the maximum scroll position
+     */
+    public int getMaximumHorizontalScrollPosition(Element scrollable) {
+      return scrollable.getScrollWidth() - scrollable.getClientWidth();
+    }
+
+    /**
+     * Get the minimum horizontal scroll position.
+     * 
+     * @param scrollable the scrollable element
+     * @return the minimum scroll position
+     */
+    public int getMinimumHorizontalScrollPosition(Element scrollable) {
+      return 0;
+    }
+  }
+
+  /**
+   * Firefox scrolls in the negative direction in RTL mode.
+   */
+  static class ImplRtlReversed extends Impl {
+    @Override
+    public int getMaximumHorizontalScrollPosition(Element scrollable) {
+      return isRtl(scrollable) ? 0 : super
+          .getMaximumHorizontalScrollPosition(scrollable);
+    }
+
+    @Override
+    public int getMinimumHorizontalScrollPosition(Element scrollable) {
+      return isRtl(scrollable) ? scrollable.getClientWidth()
+          - scrollable.getScrollWidth() : 0;
+    }
+
+    /**
+     * Check if the specified element has an RTL direction. We can't base this
+     * on the current locale because the user can modify the direction at the
+     * DOM level.
+     * 
+     * @param scrollable the scrollable element
+     * @return true if the direction is RTL, false if LTR
+     */
+    private native boolean isRtl(Element scrollable) /*-{
+      var computedStyle = $doc.defaultView.getComputedStyle(scrollable, null);
+      return computedStyle.getPropertyValue('direction') == 'rtl';
+    }-*/;
+  }
+
+  private static Impl impl;
 
   private Element containerElem;
 
   /**
+   * The scroller used to support touch events.
+   */
+  private TouchScroller touchScroller;
+  
+  /**
    * Creates an empty scroll panel.
    */
   public ScrollPanel() {
+    if (impl == null) {
+      impl = GWT.create(Impl.class);
+    }
+    
     setAlwaysShowScrollBars(false);
 
     containerElem = DOM.createDiv();
@@ -48,6 +115,9 @@
     //   http://stackoverflow.com/questions/139000/div-with-overflowauto-and-a-100-wide-table-problem
     DOM.setStyleAttribute(getElement(), "zoom", "1");
     DOM.setStyleAttribute(containerElem, "zoom", "1");
+
+    // Enable touch scrolling.
+    setTouchScrollingDisabled(false);
   }
 
   /**
@@ -93,15 +163,47 @@
     return DOM.getElementPropertyInt(getElement(), "scrollLeft");
   }
 
+  public int getMaximumHorizontalScrollPosition() {
+    return impl.getMaximumHorizontalScrollPosition(getElement());
+  }
+
+  public int getMaximumVerticalScrollPosition() {
+    return getElement().getScrollHeight() - getElement().getClientHeight();
+  }
+
+  public int getMinimumHorizontalScrollPosition() {
+    return impl.getMinimumHorizontalScrollPosition(getElement());
+  }
+
+  public int getMinimumVerticalScrollPosition() {
+    return 0;
+  }
+
   /**
    * Gets the vertical scroll position.
    * 
    * @return the vertical scroll position, in pixels
+   * @deprecated as of GWT 2.3, replaced by {@link #getVerticalScrollPosition()}
    */
+  @Deprecated
   public int getScrollPosition() {
     return DOM.getElementPropertyInt(getElement(), "scrollTop");
   }
 
+  public int getVerticalScrollPosition() {
+    return getScrollPosition();
+  }
+
+  /**
+   * Check whether or not touch based scrolling is disabled. This method always
+   * returns false on devices that do not support touch scrolling.
+   * 
+   * @return true if disabled, false if enabled
+   */
+  public boolean isTouchScrollingDisabled() {
+    return touchScroller == null;
+  }
+
   public void onResize() {
     Widget child = getWidget();
     if ((child != null) && (child instanceof RequiresResize)) {
@@ -183,7 +285,10 @@
    * Sets the vertical scroll position.
    * 
    * @param position the new vertical scroll position, in pixels
+   * @deprecated as of GWT 2.3, replaced by
+   *             {@link #setVerticalScrollPosition(int)}
    */
+  @Deprecated
   public void setScrollPosition(int position) {
     DOM.setElementPropertyInt(getElement(), "scrollTop", position);
   }
@@ -203,6 +308,34 @@
   }
 
   /**
+   * Set whether or not touch scrolling is disabled. By default, touch scrolling
+   * is enabled on devices that support touch events.
+   * 
+   * @param isDisabled true to disable, false to enable
+   * @return true if touch scrolling is enabled and supported, false if disabled
+   *         or not supported
+   */
+  public boolean setTouchScrollingDisabled(boolean isDisabled) {
+    if (isDisabled == isTouchScrollingDisabled()) {
+      return isDisabled;
+    }
+
+    if (isDisabled) {
+      // Detach the touch scroller.
+      touchScroller.setTargetWidget(null);
+      touchScroller = null;
+    } else {
+      // Attach a new touch scroller.
+      touchScroller = TouchScroller.createIfSupported(this);
+    }
+    return isTouchScrollingDisabled();
+  }
+
+  public void setVerticalScrollPosition(int position) {
+    setScrollPosition(position);
+  }
+
+  /**
    * Sets the object's width. This width does not include decorations such as
    * border, margin, and padding.
    * 
diff --git a/user/test/com/google/gwt/touch/TouchSuite.java b/user/test/com/google/gwt/touch/TouchSuite.java
new file mode 100644
index 0000000..cf64b23
--- /dev/null
+++ b/user/test/com/google/gwt/touch/TouchSuite.java
@@ -0,0 +1,42 @@
+/*
+ * 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;
+
+import com.google.gwt.junit.tools.GWTTestSuite;
+import com.google.gwt.touch.client.DefaultMomentumTest;
+import com.google.gwt.touch.client.PointTest;
+import com.google.gwt.touch.client.TouchScrollTest;
+
+import junit.framework.Test;
+
+/**
+ * Tests of Touch support.
+ */
+public class TouchSuite {
+  public static Test suite() {
+    GWTTestSuite suite = new GWTTestSuite("Test suite for Touch support");
+
+    suite.addTestSuite(DefaultMomentumTest.class);
+    suite.addTestSuite(PointTest.class);
+    suite.addTestSuite(TouchScrollTest.class);
+
+    return suite;
+  }
+
+  private TouchSuite() {
+  }
+}
diff --git a/user/test/com/google/gwt/touch/client/DefaultMomentumTest.java b/user/test/com/google/gwt/touch/client/DefaultMomentumTest.java
new file mode 100644
index 0000000..6897537
--- /dev/null
+++ b/user/test/com/google/gwt/touch/client/DefaultMomentumTest.java
@@ -0,0 +1,111 @@
+/*
+ * 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.touch.client.Momentum.State;
+
+import junit.framework.TestCase;
+
+/**
+ * Tests for {@link DefaultMomentum}.
+ */
+public class DefaultMomentumTest extends TestCase {
+
+  /**
+   * Test updating the state before the acceleration falls below the minimum
+   * acceleration.
+   */
+  public void testUpdateStateFast() {
+    DefaultMomentum momentum = new DefaultMomentum();
+    State state = momentum.createState(new Point(0.0, 0.0), new Point(1.0, 2.0));
+    state.setPosition(new Point(0.1, 0.2));
+    state.setCumulativeElapsedMillis(10);
+    state.setElapsedMillis(5);
+
+    assertTrue(momentum.updateState(state));
+
+    // Check the new velocity.
+    Point velocity = state.getVelocity();
+    assertEquals(0.99302, velocity.getX(), 0.000009); // 1.0 * .9993 ^ 10
+    assertEquals(1.98604, velocity.getY(), 0.000009); // 2.0 * .9993 ^ 10
+
+    // Check the new position.
+    Point position = state.getPosition();
+    assertEquals(5.0651, position.getX(), 0.0001); // .1 + v * 5ms
+    assertEquals(10.1302, position.getY(), 0.0001); // .2 + v * 5ms5
+  }
+
+  /**
+   * Test updating the state after the X acceleration falls below the minimum
+   * acceleration.
+   */
+  public void testUpdateStateSlowX() {
+    DefaultMomentum momentum = new DefaultMomentum();
+    State state = momentum.createState(new Point(0.0, 0.0), new Point(0.005, 1.0));
+    state.setPosition(new Point(0.2, 0.1));
+    state.setCumulativeElapsedMillis(10);
+    state.setElapsedMillis(5);
+
+    assertTrue(momentum.updateState(state));
+
+    // Check the new velocity.
+    Point velocity = state.getVelocity();
+    assertEquals(0.0025, velocity.getX(), 0.0001); // 0.005 - 0.0005 * 5
+    assertEquals(0.99302, velocity.getY(), 0.000009); // 1.0 * .9993 ^ 10
+
+    // Check the new position.
+    Point position = state.getPosition();
+    assertEquals(0.2125, position.getX(), 0.0001); // .2 + v * 5ms
+    assertEquals(5.0651, position.getY(), 0.0001); // .1 + v * 5ms
+  }
+
+  /**
+   * Test updating the state after the Y acceleration falls below the minimum
+   * acceleration.
+   */
+  public void testUpdateStateSlowY() {
+    DefaultMomentum momentum = new DefaultMomentum();
+    State state = momentum.createState(new Point(0.0, 0.0), new Point(1.0, 0.005));
+    state.setPosition(new Point(0.1, 0.2));
+    state.setCumulativeElapsedMillis(10);
+    state.setElapsedMillis(5);
+
+    assertTrue(momentum.updateState(state));
+
+    // Check the new velocity.
+    Point velocity = state.getVelocity();
+    assertEquals(0.99302, velocity.getX(), 0.000009); // 1.0 * .9993 ^ 10
+    assertEquals(0.0025, velocity.getY(), 0.0001); // 0.005 - 0.0005 * 5
+
+    // Check the new position.
+    Point position = state.getPosition();
+    assertEquals(5.0651, position.getX(), 0.0001); // .1 + v * 5ms
+    assertEquals(0.2125, position.getY(), 0.0001); // .2 + v * 5ms
+  }
+
+  /**
+   * Test updating the state returns null when we reach the minimum velocity.
+   */
+  public void testUpdateStateMinimumVelociy() {
+    DefaultMomentum momentum = new DefaultMomentum();
+    State state = momentum.createState(new Point(0.0, 0.0), new Point(0.02, 0.02));
+    state.setPosition(new Point(0.1, 0.2));
+    state.setCumulativeElapsedMillis(10);
+    state.setElapsedMillis(5);
+
+    assertFalse(momentum.updateState(state));
+  }
+}
diff --git a/user/test/com/google/gwt/touch/client/PointTest.java b/user/test/com/google/gwt/touch/client/PointTest.java
new file mode 100644
index 0000000..19fb5ba
--- /dev/null
+++ b/user/test/com/google/gwt/touch/client/PointTest.java
@@ -0,0 +1,91 @@
+/*
+ * 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 junit.framework.TestCase;
+
+/**
+ * Tests for {@link Point}.
+ */
+public class PointTest extends TestCase {
+
+  public void testDiv() {
+    Point p0 = new Point(6.0, 10.0);
+    Point p1 = new Point(2.0, 5.0);
+    Point result = p0.div(p1);
+    assertEquals(6.0, p0.getX());
+    assertEquals(10.0, p0.getY());
+    assertEquals(2.0, p1.getX());
+    assertEquals(5.0, p1.getY());
+    assertEquals(3.0, result.getX());
+    assertEquals(2.0, result.getY());
+  }
+
+  public void testEquals() {
+    Point base = new Point(1.0, 2.0);
+
+    // Equals.
+    assertTrue(base.equals(new Point(1.0, 2.0)));
+
+    // Different x.
+    assertFalse(base.equals(new Point(3.0, 2.0)));
+
+    // Different y.
+    assertFalse(base.equals(new Point(1.0, 3.0)));
+
+    // Different x and y.
+    assertFalse(base.equals(new Point(3.0, 4.0)));
+
+    // Null.
+    assertFalse(base.equals(null));
+  }
+
+  public void testMinus() {
+    Point p0 = new Point(5.0, 7.0);
+    Point p1 = new Point(1.0, 2.0);
+    Point result = p0.minus(p1);
+    assertEquals(5.0, p0.getX());
+    assertEquals(7.0, p0.getY());
+    assertEquals(1.0, p1.getX());
+    assertEquals(2.0, p1.getY());
+    assertEquals(4.0, result.getX());
+    assertEquals(5.0, result.getY());
+  }
+
+  public void testMult() {
+    Point p0 = new Point(5.0, 7.0);
+    Point p1 = new Point(2.0, 3.0);
+    Point result = p0.mult(p1);
+    assertEquals(5.0, p0.getX());
+    assertEquals(7.0, p0.getY());
+    assertEquals(2.0, p1.getX());
+    assertEquals(3.0, p1.getY());
+    assertEquals(10.0, result.getX());
+    assertEquals(21.0, result.getY());
+  }
+
+  public void testPlus() {
+    Point p0 = new Point(1.0, 2.0);
+    Point p1 = new Point(4.0, 5.0);
+    Point result = p0.plus(p1);
+    assertEquals(1.0, p0.getX());
+    assertEquals(2.0, p0.getY());
+    assertEquals(4.0, p1.getX());
+    assertEquals(5.0, p1.getY());
+    assertEquals(5.0, result.getX());
+    assertEquals(7.0, result.getY());
+  }
+}
diff --git a/user/test/com/google/gwt/touch/client/TouchScrollTest.java b/user/test/com/google/gwt/touch/client/TouchScrollTest.java
new file mode 100644
index 0000000..6b07f39
--- /dev/null
+++ b/user/test/com/google/gwt/touch/client/TouchScrollTest.java
@@ -0,0 +1,520 @@
+/*
+ * 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.dom.client.NativeEvent;
+import com.google.gwt.dom.client.Touch;
+import com.google.gwt.event.dom.client.TouchEvent;
+import com.google.gwt.event.dom.client.TouchStartEvent;
+import com.google.gwt.junit.client.GWTTestCase;
+import com.google.gwt.touch.client.TouchScroller.TemporalPoint;
+import com.google.gwt.user.client.ui.HasScrolling;
+import com.google.gwt.user.client.ui.Label;
+import com.google.gwt.user.client.ui.RootPanel;
+import com.google.gwt.user.client.ui.ScrollPanel;
+
+/**
+ * Tests for {@link TouchScroller}.
+ * 
+ * <p>
+ * Many of the tests in this class can run even in HtmlUnit and browsers that do
+ * not support touch events because we create mock touch events.
+ * </p>
+ */
+public class TouchScrollTest extends GWTTestCase {
+
+  /**
+   * A custom {@link ScrollPanel} that doesn't rely on the DOM to calculate its
+   * vertical and horizontal position. Allows testing in HtmlUnit.
+   */
+  private static class CustomScrollPanel extends ScrollPanel {
+    private final int maxHorizontalScrollPosition;
+    private final int maxVerticalScrollPosition;
+    private final int minHorizontalScrollPosition;
+    private final int minVerticalScrollPosition;
+    private int horizontalScrollPosition;
+    private int verticalScrollPosition;
+
+    /**
+     * Construct a new {@link CustomScrollPanel} using 0 as the minimum vertical
+     * and horizontal scroll position and INTEGER.MAX_VALUE as the maximum
+     * positions.
+     */
+    public CustomScrollPanel() {
+      this.minVerticalScrollPosition = 0;
+      this.maxVerticalScrollPosition = Integer.MAX_VALUE;
+      this.minHorizontalScrollPosition = 0;
+      this.maxHorizontalScrollPosition = Integer.MAX_VALUE;
+    }
+
+    @Override
+    public int getHorizontalScrollPosition() {
+      return horizontalScrollPosition;
+    }
+
+    @Override
+    public int getMaximumHorizontalScrollPosition() {
+      return maxHorizontalScrollPosition;
+    }
+
+    @Override
+    public int getMaximumVerticalScrollPosition() {
+      return maxVerticalScrollPosition;
+    }
+
+    @Override
+    public int getMinimumHorizontalScrollPosition() {
+      return minHorizontalScrollPosition;
+    }
+
+    @Override
+    public int getMinimumVerticalScrollPosition() {
+      return minVerticalScrollPosition;
+    }
+
+    @Override
+    public int getVerticalScrollPosition() {
+      return verticalScrollPosition;
+    }
+
+    @Override
+    public void setHorizontalScrollPosition(int position) {
+      this.horizontalScrollPosition = position;
+      super.setHorizontalScrollPosition(position);
+    }
+
+    @Override
+    public void setVerticalScrollPosition(int position) {
+      this.verticalScrollPosition = position;
+      super.setVerticalScrollPosition(position);
+    }
+  }
+
+  /**
+   * A custom touch event.
+   */
+  private static class CustomTouchEvent extends TouchStartEvent {
+  }
+
+  /**
+   * A {@link TouchScroller} that overrides drag events.
+   */
+  private static class CustomTouchScroller extends TouchScroller {
+
+    private boolean onDragEndCalled;
+    private boolean onDragMoveCalled;
+    private boolean onDragStartCalled;
+
+    public CustomTouchScroller(HasScrolling widget) {
+      super();
+      setTargetWidget(widget);
+    }
+
+    public void assertOnDragEndCalled(boolean expected) {
+      assertEquals(expected, onDragEndCalled);
+      onDragEndCalled = false;
+    }
+
+    public void assertOnDragMoveCalled(boolean expected) {
+      assertEquals(expected, onDragMoveCalled);
+      onDragMoveCalled = false;
+    }
+
+    public void assertOnDragStartCalled(boolean expected) {
+      assertEquals(expected, onDragStartCalled);
+      onDragStartCalled = false;
+    }
+
+    @Override
+    protected void onDragEnd(TouchEvent<?> event) {
+      assertFalse("onDragEnd called twice", onDragEndCalled);
+      super.onDragEnd(event);
+      onDragEndCalled = true;
+    }
+
+    @Override
+    protected void onDragMove(TouchEvent<?> event) {
+      assertFalse("onDragMove called twice", onDragMoveCalled);
+      super.onDragMove(event);
+      onDragMoveCalled = true;
+    }
+
+    @Override
+    protected void onDragStart(TouchEvent<?> event) {
+      assertFalse("onDragStart called twice", onDragStartCalled);
+      super.onDragStart(event);
+      onDragStartCalled = true;
+    }
+  }
+
+  /**
+   * Create a mock native touch event that contains no touches.
+   * 
+   * @return an empty mock touch event
+   */
+  private static native NativeEvent createNativeTouchEvent() /*-{
+    // Create a real event so standard event methods are available.
+    var touches = [];
+    return {
+      "changedTouches" : touches,
+      "targetTouches" : touches,
+      "touches" : touches,
+      "preventDefault" : function() {} // Called by TouchScroller.
+    };
+  }-*/;
+
+  /**
+   * Create a mock {@link Touch} for the specified x and y coordinate.
+   * 
+   * @param x the x coordinate
+   * @param y the y coordinate
+   * @return a mock touch
+   */
+  private static native Touch createTouch(int x, int y) /*-{
+    return {
+      "clientX" : x,
+      "clientY" : y,
+      "identifier" : 0,
+      "pageX" : x,
+      "pageY" : y,
+      "screenX" : x,
+      "screenY" : y,
+      "target" : null
+    };
+  }-*/;
+
+  /**
+   * Create a mock TouchEndEvent. Touch end events do not have any touches.
+   * 
+   * @return a mock TouchEndEvent
+   */
+  private static TouchEvent<?> createTouchEndEvent() {
+    CustomTouchEvent event = new CustomTouchEvent();
+    event.setNativeEvent(createNativeTouchEvent());
+    return event;
+  };
+
+  /**
+   * Create a mock TouchMoveEvent for the specified x and y coordinate.
+   * 
+   * @param x the x coordinate
+   * @param y the y coordinate
+   * @return a mock TouchMoveEvent
+   */
+  private static TouchEvent<?> createTouchMoveEvent(int x, int y) {
+    // TouchScroller doesn't care about the actual event subclass.
+    return createTouchStartEvent(x, y);
+  };
+
+  /**
+   * Create a mock {@link TouchStartEvent} for the specified x and y coordinate.
+   * 
+   * @param x the x coordinate
+   * @param y the y coordinate
+   * @return a mock {@link TouchStartEvent}
+   */
+  private static TouchEvent<?> createTouchStartEvent(int x, int y) {
+    CustomTouchEvent event = new CustomTouchEvent();
+    NativeEvent nativeEvent = createNativeTouchEvent();
+    nativeEvent.getTouches().push(createTouch(x, y));
+    event.setNativeEvent(nativeEvent);
+    return event;
+  };
+
+  private CustomTouchScroller scroller;
+  private CustomScrollPanel scrollPanel;
+
+  @Override
+  public String getModuleName() {
+    return "com.google.gwt.touch.Touch";
+  }
+
+  public void testCalculateEndVlocity() {
+    // Two points at the same time should return null.
+    TemporalPoint from = new TemporalPoint(new Point(100.0, 200.0), 0);
+    TemporalPoint sameTime = new TemporalPoint(new Point(100.0, 100.0), 0);
+    assertNull(scroller.calculateEndVelocity(from, sameTime));
+
+    // Two different points should return a velocity.
+    TemporalPoint to = new TemporalPoint(new Point(250.0, 150.0), 25);
+    assertEquals(new Point(-6.0, 2.0), scroller.calculateEndVelocity(from, to));
+  }
+
+  public void testCreateIfSupported() {
+    // createIfSupported()
+    TouchScroller scroller = TouchScroller.createIfSupported();
+    if (isTouchSupported()) {
+      assertNotNull("TouchScroll not created, but touch is supported", scroller);
+      assertNull(scroller.getTargetWidget());
+
+    } else {
+      assertNull("TouchScroll created, but touch is not supported", scroller);
+    }
+
+    // createIfSupported(HasScrolling)
+    HasScrolling target = new ScrollPanel();
+    scroller = TouchScroller.createIfSupported(target);
+    if (isTouchSupported()) {
+      assertNotNull("TouchScroll not created, but touch is supported", scroller);
+      assertEquals(target, scroller.getTargetWidget());
+
+    } else {
+      assertNull("TouchScroll created, but touch is not supported", scroller);
+    }
+  }
+
+  /**
+   * Test that touch events correctly initiate drag events.
+   */
+  public void testDragSequence() {
+    // Disable momentum for this test.
+    scroller.setMomentum(null);
+
+    // Initial state.
+    assertFalse(scroller.isTouching());
+    assertFalse(scroller.isDragging());
+
+    // Start touching.
+    scroller.onTouchStart(createTouchStartEvent(0, 0));
+    scroller.assertOnDragStartCalled(false);
+    assertTrue(scroller.isTouching());
+    assertFalse(scroller.isDragging());
+
+    // Move, but not enough to drag.
+    scroller.onTouchMove(createTouchMoveEvent(1, 0));
+    scroller.assertOnDragStartCalled(false);
+    scroller.assertOnDragMoveCalled(false);
+    assertTrue(scroller.isTouching());
+    assertFalse(scroller.isDragging());
+
+    // Move.
+    scroller.onTouchMove(createTouchMoveEvent(100, 0));
+    scroller.assertOnDragStartCalled(true);
+    scroller.assertOnDragMoveCalled(true);
+    assertTrue(scroller.isTouching());
+    assertTrue(scroller.isDragging());
+
+    // Move again.
+    scroller.onTouchMove(createTouchMoveEvent(200, 0));
+    scroller.assertOnDragStartCalled(false); // drag already started.
+    scroller.assertOnDragMoveCalled(true);
+    assertTrue(scroller.isTouching());
+    assertTrue(scroller.isDragging());
+
+    // End.
+    scroller.onTouchEnd(createTouchEndEvent());
+    scroller.assertOnDragStartCalled(false);
+    scroller.assertOnDragMoveCalled(false);
+    scroller.assertOnDragEndCalled(true);
+    assertFalse(scroller.isTouching());
+    assertFalse(scroller.isDragging());
+  }
+
+  public void testOnDragEnd() {
+    // Start a drag sequence.
+    double millis = Duration.currentTimeMillis();
+    scroller.getRecentTouchPosition().setTemporalPoint(new Point(0, 0), millis);
+    scroller.getLastTouchPosition().setTemporalPoint(new Point(100, 100), millis + 100);
+
+    // End the drag sequence.
+    scroller.onDragEnd(createTouchEndEvent());
+    assertTrue(scroller.isMomentumActive());
+  }
+
+  public void testOnDragEndNoMomentum() {
+    // Disable momentum for this test.
+    scroller.setMomentum(null);
+
+    // Start a drag sequence.
+    double millis = Duration.currentTimeMillis();
+    scroller.getRecentTouchPosition().setTemporalPoint(new Point(0, 0), millis);
+    scroller.getLastTouchPosition().setTemporalPoint(new Point(100, 100), millis + 100);
+
+    // End the drag sequence.
+    scroller.onDragEnd(createTouchEndEvent());
+    assertFalse(scroller.isMomentumActive());
+  }
+
+  public void testOnDragMove() {
+    // Disable momentum for this test.
+    scroller.setMomentum(null);
+
+    // Start at 100x100;
+    scrollPanel.setHorizontalScrollPosition(100);
+    scrollPanel.setVerticalScrollPosition(150);
+
+    // Start touching.
+    scroller.onTouchStart(createTouchStartEvent(0, 0));
+
+    // Drag in a positive direction (negative scroll).
+    TouchEvent<?> touchMove = createTouchMoveEvent(40, 50);
+    scroller.onTouchMove(touchMove);
+    scroller.assertOnDragMoveCalled(true);
+    assertEquals(60, scrollPanel.getHorizontalScrollPosition());
+    assertEquals(100, scrollPanel.getVerticalScrollPosition());
+
+    // Drag in a negative direction (positive scroll).
+    touchMove = createTouchMoveEvent(-20, -30);
+    scroller.onTouchMove(touchMove);
+    scroller.assertOnDragMoveCalled(true);
+    assertEquals(120, scrollPanel.getHorizontalScrollPosition());
+    assertEquals(180, scrollPanel.getVerticalScrollPosition());
+  }
+
+  /**
+   * Test that touch end events are ignored if not touching.
+   */
+  public void testOnTouchEndIgnored() {
+    // Disable momentum for this test.
+    scroller.setMomentum(null);
+
+    // Initial state.
+    assertFalse(scroller.isTouching());
+    assertFalse(scroller.isDragging());
+
+    // Verify that an extraneous touch end event is ignored.
+    scroller.onTouchEnd(createTouchEndEvent());
+    assertFalse(scroller.isTouching());
+    assertFalse(scroller.isDragging());
+  }
+
+  /**
+   * Test that we handle touch end events that occur without initiating a drag
+   * sequence.
+   */
+  public void testOnTouchEndWithoutDrag() {
+    // Disable momentum for this test.
+    scroller.setMomentum(null);
+
+    // Initial state.
+    assertFalse(scroller.isTouching());
+    assertFalse(scroller.isDragging());
+
+    // Start touching.
+    scroller.onTouchStart(createTouchStartEvent(0, 0));
+    scroller.assertOnDragStartCalled(false);
+    assertTrue(scroller.isTouching());
+    assertFalse(scroller.isDragging());
+
+    // Move, but not enough to drag.
+    scroller.onTouchMove(createTouchMoveEvent(1, 0));
+    scroller.assertOnDragStartCalled(false);
+    scroller.assertOnDragMoveCalled(false);
+    assertTrue(scroller.isTouching());
+    assertFalse(scroller.isDragging());
+
+    // End.
+    scroller.onTouchEnd(createTouchEndEvent());
+    scroller.assertOnDragStartCalled(false);
+    scroller.assertOnDragMoveCalled(false);
+    scroller.assertOnDragEndCalled(false);
+    assertFalse(scroller.isTouching());
+    assertFalse(scroller.isDragging());
+  }
+
+  /**
+   * Test that touch move events are ignored if not touching.
+   */
+  public void testOnTouchMoveIgnored() {
+    // Disable momentum for this test.
+    scroller.setMomentum(null);
+
+    // Initial state.
+    assertFalse(scroller.isTouching());
+    assertFalse(scroller.isDragging());
+
+    // Verify that an extraneous touchmove event is ignored.
+    scroller.onTouchMove(createTouchMoveEvent(0, 0));
+    assertFalse(scroller.isTouching());
+    assertFalse(scroller.isDragging());
+  }
+
+  /**
+   * Test that touch start events cancel any active momentum.
+   */
+  public void testOnTouchCancelsMomentum() {
+    // Start momentum.
+    double millis = Duration.currentTimeMillis();
+    scroller.getRecentTouchPosition().setTemporalPoint(new Point(0, 0), millis);
+    scroller.getLastTouchPosition().setTemporalPoint(new Point(100, 100), millis + 100);
+    scroller.onDragEnd(createTouchEndEvent());
+    assertTrue(scroller.isMomentumActive());
+
+    // Touch again.
+    scroller.onTouchStart(createTouchStartEvent(0, 0));
+    assertFalse(scroller.isMomentumActive());
+  }
+
+  /**
+   * Test that touch start events are ignored if already touching.
+   */
+  public void testOnTouchStartIgnored() {
+    scroller.setMomentum(null); // Disable momentum for this test.
+
+    // Initial state.
+    assertFalse(scroller.isTouching());
+    assertFalse(scroller.isDragging());
+
+    // Start touching.
+    scroller.onTouchStart(createTouchStartEvent(0, 0));
+    scroller.assertOnDragStartCalled(false);
+    assertTrue(scroller.isTouching());
+    assertFalse(scroller.isDragging());
+
+    // Verify that additional start events do not cause errors.
+    scroller.onTouchStart(createTouchStartEvent(0, 0));
+    scroller.assertOnDragStartCalled(false);
+    assertTrue(scroller.isTouching());
+    assertFalse(scroller.isDragging());
+  }
+
+  @Override
+  protected void gwtSetUp() throws Exception {
+    // Create and attach a widget that has scrolling.
+    scrollPanel = new CustomScrollPanel();
+    scrollPanel.setTouchScrollingDisabled(true);
+    scrollPanel.setPixelSize(500, 500);
+    Label content = new Label("Content");
+    content.setPixelSize(10000, 10000);
+    RootPanel.get().add(scrollPanel);
+
+    // Add scrolling support.
+    scroller = new CustomTouchScroller(scrollPanel);
+  }
+
+  /**
+   * A replacement for JUnit's {@link #tearDown()} method. This method runs once
+   * per test method in your subclass, just after your each test method runs and
+   * can be used to perform cleanup. Override this method instead of
+   * {@link #tearDown()}. This method is run even in pure Java mode (non-GWT).
+   * 
+   * @see #setForcePureJava
+   */
+  @Override
+  protected void gwtTearDown() throws Exception {
+    // Detach the widget.
+    RootPanel.get().remove(scrollPanel.asWidget());
+    scrollPanel = null;
+    scroller = null;
+  }
+
+  private native boolean isTouchSupported() /*-{
+    var elem = document.createElement('div');
+    elem.setAttribute('ontouchstart', 'return;');
+    return (typeof elem.ontouchstart) == "function";
+  }-*/;
+}
diff --git a/user/test/com/google/gwt/user/client/ui/ScrollPanelTest.java b/user/test/com/google/gwt/user/client/ui/ScrollPanelTest.java
index be1cd38..6dd2668 100644
--- a/user/test/com/google/gwt/user/client/ui/ScrollPanelTest.java
+++ b/user/test/com/google/gwt/user/client/ui/ScrollPanelTest.java
@@ -15,11 +15,59 @@
  */
 package com.google.gwt.user.client.ui;
 
+import com.google.gwt.core.client.Scheduler;
+import com.google.gwt.core.client.Scheduler.ScheduledCommand;
+import com.google.gwt.junit.DoNotRunWith;
+import com.google.gwt.junit.Platform;
+import com.google.gwt.touch.client.TouchScroller;
+
 /**
  * Tests the ScrollPanel widget.
  */
 public class ScrollPanelTest extends SimplePanelTestBase<ScrollPanel> {
 
+  @DoNotRunWith(Platform.HtmlUnitLayout)
+  public void testGetMaximumScrollPosition() {
+    final ScrollPanel scrollPanel = createPanel();
+    scrollPanel.setPixelSize(200, 300);
+    RootPanel.get().add(scrollPanel);
+
+    Label content = new Label("Hello World");
+    content.setPixelSize(500, 700);
+    scrollPanel.setWidget(content);
+
+    delayTestFinish(3000);
+    Scheduler.get().scheduleDeferred(new ScheduledCommand() {
+      public void execute() {
+        int maxHorizontalPos = scrollPanel.getMaximumHorizontalScrollPosition();
+        int maxVerticalPos = scrollPanel.getMaximumVerticalScrollPosition();
+
+        // Account for scrollbars up to 50 pixels.
+        assertTrue(maxHorizontalPos > 300 && maxHorizontalPos < 350);
+        assertTrue(maxVerticalPos > 400 && maxHorizontalPos < 450);
+        RootPanel.get().remove(scrollPanel);
+        finishTest();
+      }
+    });
+  }
+
+  public void testSetTouchScrollingDisabled() {
+    ScrollPanel scrollPanel = createPanel();
+
+    // Touch support is enabled by default for browsers that support it.
+    assertEquals(TouchScroller.isSupported(),
+        !scrollPanel.isTouchScrollingDisabled());
+
+    // Disable touch support.
+    scrollPanel.setTouchScrollingDisabled(true);
+    assertTrue(scrollPanel.isTouchScrollingDisabled());
+
+    // Enable touch support.
+    scrollPanel.setTouchScrollingDisabled(false);
+    assertEquals(TouchScroller.isSupported(),
+        !scrollPanel.isTouchScrollingDisabled());
+  }
+
   @Override
   protected ScrollPanel createPanel() {
     return new ScrollPanel();