changeset 6715:ca938a1131ad

RT-35306: Add scroll gesture recognizer.
author Seeon Birger <seeon.birger@oracle.com>
date Sun, 13 Apr 2014 21:33:39 +0300
parents 75c057104dcd
children 9ed69a143922
files buildSrc/armv7hft.gradle buildSrc/armv7sft.gradle buildSrc/x86egl.gradle modules/graphics/src/main/java/com/sun/javafx/tk/quantum/GlassViewEventHandler.java modules/graphics/src/main/java/com/sun/javafx/tk/quantum/ScrollGestureRecognizer.java
diffstat 5 files changed, 387 insertions(+), 3 deletions(-) [+]
line wrap: on
line diff
--- a/buildSrc/armv7hft.gradle	Sun Apr 13 12:58:43 2014 +0300
+++ b/buildSrc/armv7hft.gradle	Sun Apr 13 21:33:39 2014 +0300
@@ -218,6 +218,9 @@
 directfb.prism.order=sw
 directfb.com.sun.javafx.isEmbedded=true
 directfb.com.sun.javafx.scene.control.skin.FXVK.cache=true
+directfb.com.sun.javafx.gestures.zoom=true
+directfb.com.sun.javafx.gestures.rotate=true
+directfb.com.sun.javafx.gestures.scroll=true
 eglfb.com.sun.javafx.scene.control.skin.ListViewSkin.pannable=true
 eglfb.com.sun.javafx.scene.control.skin.TreeViewSkin.pannable=true
 eglfb.com.sun.javafx.scene.control.skin.TableViewSkin.pannable=true
@@ -234,6 +237,9 @@
 eglfb.com.sun.javafx.isEmbedded=true
 eglfb.doNativeComposite=true
 eglfb.com.sun.javafx.scene.control.skin.FXVK.cache=true
+eglfb.com.sun.javafx.gestures.zoom=true
+eglfb.com.sun.javafx.gestures.rotate=true
+eglfb.com.sun.javafx.gestures.scroll=true
 eglfb.prism.glDepthSize=0
 fb.com.sun.javafx.scene.control.skin.ListViewSkin.pannable=true
 fb.com.sun.javafx.scene.control.skin.TreeViewSkin.pannable=true
@@ -244,6 +250,9 @@
 fb.com.sun.javafx.isEmbedded=true
 fb.glass.restrictWindowToScreen=true
 fb.com.sun.javafx.scene.control.skin.FXVK.cache=true
+fb.com.sun.javafx.gestures.zoom=true
+fb.com.sun.javafx.gestures.rotate=true
+fb.com.sun.javafx.gestures.scroll=true
 monocle.glass.platform=Monocle
 monocle.prism.order=es2,sw
 monocle.prism.eglfb=true
@@ -256,6 +265,9 @@
 monocle.com.sun.javafx.isEmbedded=true
 monocle.doNativeComposite=true
 monocle.com.sun.javafx.scene.control.skin.FXVK.cache=true
+monocle.com.sun.javafx.gestures.zoom=true
+monocle.com.sun.javafx.gestures.rotate=true
+monocle.com.sun.javafx.gestures.scroll=true
 monocle.prism.glDepthSize=0
 eglx11.com.sun.javafx.scene.control.skin.ListViewSkin.pannable=true
 eglx11.com.sun.javafx.scene.control.skin.TreeViewSkin.pannable=true
@@ -272,6 +284,9 @@
 eglx11.embedded=eglx11
 eglx11.com.sun.javafx.isEmbedded=true
 eglx11.com.sun.javafx.scene.control.skin.FXVK.cache=true
+eglx11.com.sun.javafx.gestures.zoom=true
+eglx11.com.sun.javafx.gestures.rotate=true
+eglx11.com.sun.javafx.gestures.scroll=true
 eglx11.prism.glDepthSize=0
 gtk.com.sun.javafx.scene.control.skin.ListViewSkin.pannable=true
 gtk.com.sun.javafx.scene.control.skin.TreeViewSkin.pannable=true
@@ -279,7 +294,10 @@
 gtk.glass.platform=gtk
 gtk.prism.order=sw
 gtk.com.sun.javafx.isEmbedded=true
-gtk.com.sun.javafx.scene.control.skin.FXVK.cache=true"""
+gtk.com.sun.javafx.scene.control.skin.FXVK.cache=true
+gtk.com.sun.javafx.gestures.zoom=true
+gtk.com.sun.javafx.gestures.rotate=true
+gtk.com.sun.javafx.gestures.scroll=true"""
 
 def pangoCCFlags = [extraCFlags, "-D_ENABLE_PANGO"];
 def pangoLinkFlags = [extraLFlags];
--- a/buildSrc/armv7sft.gradle	Sun Apr 13 12:58:43 2014 +0300
+++ b/buildSrc/armv7sft.gradle	Sun Apr 13 21:33:39 2014 +0300
@@ -225,6 +225,9 @@
 directfb.prism.order=sw
 directfb.com.sun.javafx.isEmbedded=true
 directfb.com.sun.javafx.scene.control.skin.FXVK.cache=true
+directfb.com.sun.javafx.gestures.zoom=true
+directfb.com.sun.javafx.gestures.rotate=true
+directfb.com.sun.javafx.gestures.scroll=true
 eglfb.com.sun.javafx.scene.control.skin.ListViewSkin.pannable=true
 eglfb.com.sun.javafx.scene.control.skin.TreeViewSkin.pannable=true
 eglfb.com.sun.javafx.scene.control.skin.TableViewSkin.pannable=true
@@ -242,6 +245,9 @@
 eglfb.embedded=eglfb
 eglfb.com.sun.javafx.isEmbedded=true
 eglfb.com.sun.javafx.scene.control.skin.FXVK.cache=true
+eglfb.com.sun.javafx.gestures.zoom=true
+eglfb.com.sun.javafx.gestures.rotate=true
+eglfb.com.sun.javafx.gestures.scroll=true
 eglfb.prism.glDepthSize=0
 fb.com.sun.javafx.scene.control.skin.ListViewSkin.pannable=true
 fb.com.sun.javafx.scene.control.skin.TreeViewSkin.pannable=true
@@ -252,6 +258,9 @@
 fb.com.sun.javafx.isEmbedded=true
 fb.glass.restrictWindowToScreen=true
 fb.com.sun.javafx.scene.control.skin.FXVK.cache=true
+fb.com.sun.javafx.gestures.zoom=true
+fb.com.sun.javafx.gestures.rotate=true
+fb.com.sun.javafx.gestures.scroll=true
 monocle.glass.platform=Monocle
 monocle.prism.order=es2,sw
 monocle.prism.eglfb=true
@@ -264,6 +273,9 @@
 monocle.com.sun.javafx.isEmbedded=true
 monocle.doNativeComposite=true
 monocle.com.sun.javafx.scene.control.skin.FXVK.cache=true
+monocle.com.sun.javafx.gestures.zoom=true
+monocle.com.sun.javafx.gestures.rotate=true
+monocle.com.sun.javafx.gestures.scroll=true
 monocle.prism.glDepthSize=0
 eglx11.com.sun.javafx.scene.control.skin.ListViewSkin.pannable=true
 eglx11.com.sun.javafx.scene.control.skin.TreeViewSkin.pannable=true
@@ -280,6 +292,9 @@
 eglx11.embedded=eglx11
 eglx11.com.sun.javafx.isEmbedded=true
 eglx11.com.sun.javafx.scene.control.skin.FXVK.cache=true
+eglx11.com.sun.javafx.gestures.zoom=true
+eglx11.com.sun.javafx.gestures.rotate=true
+eglx11.com.sun.javafx.gestures.scroll=true
 eglx11.prism.glDepthSize=0
 gtk.com.sun.javafx.scene.control.skin.ListViewSkin.pannable=true
 gtk.com.sun.javafx.scene.control.skin.TreeViewSkin.pannable=true
@@ -287,7 +302,10 @@
 gtk.glass.platform=gtk
 gtk.prism.order=sw
 gtk.com.sun.javafx.isEmbedded=true
-gtk.com.sun.javafx.scene.control.skin.FXVK.cache=true"""
+gtk.com.sun.javafx.scene.control.skin.FXVK.cache=true
+gtk.com.sun.javafx.gestures.zoom=true
+gtk.com.sun.javafx.gestures.rotate=true
+gtk.com.sun.javafx.gestures.scroll=true"""
 
 def pangoCCFlags = [extraCFlags, "-D_ENABLE_PANGO"];
 def pangoLinkFlags = [extraLFlags];
--- a/buildSrc/x86egl.gradle	Sun Apr 13 12:58:43 2014 +0300
+++ b/buildSrc/x86egl.gradle	Sun Apr 13 21:33:39 2014 +0300
@@ -190,6 +190,9 @@
 directfb.prism.order=sw
 directfb.com.sun.javafx.isEmbedded=true
 directfb.com.sun.javafx.scene.control.skin.FXVK.cache=true
+directfb.com.sun.javafx.gestures.zoom=true
+directfb.com.sun.javafx.gestures.rotate=true
+directfb.com.sun.javafx.gestures.scroll=true
 eglfb.com.sun.javafx.scene.control.skin.ListViewSkin.pannable=true
 eglfb.com.sun.javafx.scene.control.skin.TreeViewSkin.pannable=true
 eglfb.com.sun.javafx.scene.control.skin.TableViewSkin.pannable=true
@@ -204,6 +207,9 @@
 eglfb.com.sun.javafx.isEmbedded=true
 eglfb.doNativeComposite=true
 eglfb.com.sun.javafx.scene.control.skin.FXVK.cache=true
+eglfb.com.sun.javafx.gestures.zoom=true
+eglfb.com.sun.javafx.gestures.rotate=true
+eglfb.com.sun.javafx.gestures.scroll=true
 fb.com.sun.javafx.scene.control.skin.ListViewSkin.pannable=true
 fb.com.sun.javafx.scene.control.skin.TreeViewSkin.pannable=true
 fb.com.sun.javafx.scene.control.skin.TableViewSkin.pannable=true
@@ -213,6 +219,9 @@
 fb.com.sun.javafx.isEmbedded=true
 fb.glass.restrictWindowToScreen=true
 fb.com.sun.javafx.scene.control.skin.FXVK.cache=true
+fb.com.sun.javafx.gestures.zoom=true
+fb.com.sun.javafx.gestures.rotate=true
+fb.com.sun.javafx.gestures.scroll=true
 monocle.glass.platform=Monocle
 monocle.prism.order=es2,sw
 monocle.prism.eglfb=true
@@ -225,6 +234,9 @@
 monocle.com.sun.javafx.isEmbedded=true
 monocle.doNativeComposite=true
 monocle.com.sun.javafx.scene.control.skin.FXVK.cache=true
+monocle.com.sun.javafx.gestures.zoom=true
+monocle.com.sun.javafx.gestures.rotate=true
+monocle.com.sun.javafx.gestures.scroll=true
 monocle.prism.glDepthSize=0
 eglx11.com.sun.javafx.scene.control.skin.ListViewSkin.pannable=true
 eglx11.com.sun.javafx.scene.control.skin.TreeViewSkin.pannable=true
@@ -239,13 +251,19 @@
 eglx11.embedded=eglx11
 eglx11.com.sun.javafx.isEmbedded=true
 eglx11.com.sun.javafx.scene.control.skin.FXVK.cache=true
+eglx11.com.sun.javafx.gestures.zoom=true
+eglx11.com.sun.javafx.gestures.rotate=true
+eglx11.com.sun.javafx.gestures.scroll=true
 gtk.com.sun.javafx.scene.control.skin.ListViewSkin.pannable=true
 gtk.com.sun.javafx.scene.control.skin.TreeViewSkin.pannable=true
 gtk.com.sun.javafx.scene.control.skin.TableViewSkin.pannable=true
 gtk.glass.platform=gtk
 gtk.prism.order=sw
 gtk.com.sun.javafx.isEmbedded=true
-gtk.com.sun.javafx.scene.control.skin.FXVK.cache=true"""
+gtk.com.sun.javafx.scene.control.skin.FXVK.cache=true
+gtk.com.sun.javafx.gestures.zoom=true
+gtk.com.sun.javafx.gestures.rotate=true
+gtk.com.sun.javafx.gestures.scroll=true"""
 
 def pangoCCFlags = ["-D_ENABLE_PANGO"];
 def pangoLinkFlags = [];
--- a/modules/graphics/src/main/java/com/sun/javafx/tk/quantum/GlassViewEventHandler.java	Sun Apr 13 12:58:43 2014 +0300
+++ b/modules/graphics/src/main/java/com/sun/javafx/tk/quantum/GlassViewEventHandler.java	Sun Apr 13 21:33:39 2014 +0300
@@ -65,10 +65,12 @@
 
     static boolean zoomGestureEnabled;
     static boolean rotateGestureEnabled;
+    static boolean scrollGestureEnabled;
     static {
         AccessController.doPrivileged((PrivilegedAction<Void>) () -> {
             zoomGestureEnabled = Boolean.valueOf(System.getProperty("com.sun.javafx.gestures.zoom", "false"));
             rotateGestureEnabled = Boolean.valueOf(System.getProperty("com.sun.javafx.gestures.rotate", "false"));
+            scrollGestureEnabled = Boolean.valueOf(System.getProperty("com.sun.javafx.gestures.scroll", "false"));
             return null;
         });
     }    
@@ -92,6 +94,9 @@
         if (rotateGestureEnabled) {
             gestures.add(new RotateGestureRecognizer(scene));
         }
+        if (scrollGestureEnabled) {
+            gestures.add(new ScrollGestureRecognizer(scene));
+        }
     }
 
     // Default fullscreen allows limited keyboard input.
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/modules/graphics/src/main/java/com/sun/javafx/tk/quantum/ScrollGestureRecognizer.java	Sun Apr 13 21:33:39 2014 +0300
@@ -0,0 +1,325 @@
+/*
+ * Copyright (c) 2014, Oracle and/or its affiliates. All rights reserved.
+ * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
+ *
+ * This code is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License version 2 only, as
+ * published by the Free Software Foundation.  Oracle designates this
+ * particular file as subject to the "Classpath" exception as provided
+ * by Oracle in the LICENSE file that accompanied this code.
+ *
+ * This code is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License
+ * version 2 for more details (a copy is included in the LICENSE file that
+ * accompanied this code).
+ *
+ * You should have received a copy of the GNU General Public License version
+ * 2 along with this work; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+ *
+ * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
+ * or visit www.oracle.com if you need additional information or have any
+ * questions.
+ */
+
+package com.sun.javafx.tk.quantum;
+
+import com.sun.glass.events.KeyEvent;
+import com.sun.glass.events.TouchEvent;
+
+import java.security.AccessController;
+import java.security.PrivilegedAction;
+import java.util.HashMap;
+import java.util.Map;
+import javafx.event.EventType;
+import javafx.scene.input.ScrollEvent;
+
+class ScrollGestureRecognizer implements GestureRecognizer {
+    // gesture will be activated if |scroll amount| > SCROLL_THRESHOLD
+    private static double SCROLL_THRESHOLD = 10; //in pixels
+    static {
+        AccessController.doPrivileged((PrivilegedAction<Void>) () -> {
+            String s = System.getProperty("com.sun.javafx.gestures.scroll.threshold");
+            if (s != null) {
+                SCROLL_THRESHOLD = Double.valueOf(s);
+            }
+            return null;
+        });
+    }    
+
+    private ViewScene scene;
+
+    private ScrollRecognitionState state = ScrollRecognitionState.IDLE;
+
+    private Map<Long, TouchPointTracker> trackers =
+            new HashMap<Long, TouchPointTracker>();
+
+    private int modifiers;
+    private boolean direct;
+
+    private int currentTouchCount = 0;
+    private int lastTouchCount;
+    private boolean touchPointsSetChanged;
+
+    private double centerX, centerY;
+    private double centerAbsX, centerAbsY;
+    private double lastCenterAbsX, lastCenterAbsY;
+
+    private double deltaX, deltaY;
+    private double totalDeltaX, totalDeltaY;
+    
+    ScrollGestureRecognizer(final ViewScene scene) {
+        this.scene = scene;
+    }
+
+    @Override
+    public void notifyBeginTouchEvent(long time, int modifiers, boolean isDirect,
+            int touchEventCount) {
+        params(modifiers, isDirect);
+        touchPointsSetChanged = false;
+    }
+
+    @Override
+    public void notifyNextTouchEvent(long time, int type, long touchId,
+                                     int x, int y, int xAbs, int yAbs) {
+        switch(type) {
+            case TouchEvent.TOUCH_PRESSED:
+                touchPointsSetChanged = true;
+                touchPressed(touchId, time, x, y, xAbs, yAbs);
+                break;
+            case TouchEvent.TOUCH_STILL:
+                break;
+            case TouchEvent.TOUCH_MOVED:
+                touchMoved(touchId, time, x, y, xAbs, yAbs);
+                break;
+            case TouchEvent.TOUCH_RELEASED:
+                touchPointsSetChanged = true;
+                touchReleased(touchId, time, x, y, xAbs, yAbs);
+                break;
+            default:
+                throw new RuntimeException("Error in Scroll gesture recognition: "
+                        + "unknown touch state: " + state);
+        }
+    }
+
+    private void calculateCenter() {
+        if (currentTouchCount <= 0) {
+            throw new RuntimeException("Error in Scroll gesture recognition: "
+                    + "touch count is zero!");
+        }
+        double totalX = 0.0;
+        double totalY = 0.0;
+        double totalAbsX = 0.0;
+        double totalAbsY = 0.0;
+        for (TouchPointTracker tracker : trackers.values()) {
+            totalX += tracker.getX();
+            totalY += tracker.getY();
+            totalAbsX += tracker.getAbsX();
+            totalAbsY += tracker.getAbsY();
+        }      
+        centerX = totalX / currentTouchCount;
+        centerY = totalY / currentTouchCount;
+        centerAbsX = totalAbsX / currentTouchCount;
+        centerAbsY = totalAbsY / currentTouchCount;
+    }
+    
+    @Override
+    public void notifyEndTouchEvent(long time) {
+        if (currentTouchCount != trackers.size()) {
+            throw new RuntimeException("Error in Scroll gesture recognition: "
+                    + "touch count is wrong: " + currentTouchCount);
+        }
+
+        if (currentTouchCount < 1) {
+            if (state == ScrollRecognitionState.ACTIVE) {
+                sendScrollFinishedEvent(lastCenterAbsX, lastCenterAbsY, lastTouchCount);
+            }
+            reset();
+        } else {
+            // currentTouchCount >= 1
+            calculateCenter();
+
+            if (touchPointsSetChanged) {
+                if (state == ScrollRecognitionState.IDLE) {
+                    state = ScrollRecognitionState.TRACKING;
+                }
+                if (state == ScrollRecognitionState.ACTIVE) {
+                    //finish previous gesture
+                    sendScrollFinishedEvent(lastCenterAbsX, lastCenterAbsY, lastTouchCount);
+                    totalDeltaX = 0.0;
+                    totalDeltaY = 0.0;
+                    //start previous gesture
+                    sendScrollStartedEvent(centerAbsX, centerAbsY, currentTouchCount);
+                }
+                lastTouchCount = currentTouchCount;
+                lastCenterAbsX = centerAbsX;
+                lastCenterAbsY = centerAbsY;
+            } else {
+                //state should be either TRACKING or ACTIVE
+                deltaX = centerAbsX - lastCenterAbsX;
+                deltaY = centerAbsY - lastCenterAbsY;
+                if (state == ScrollRecognitionState.TRACKING) {
+                    if ( Math.abs(deltaX) > SCROLL_THRESHOLD || Math.abs(deltaY) > SCROLL_THRESHOLD) {
+                        state = ScrollRecognitionState.ACTIVE;
+                        sendScrollStartedEvent(centerAbsX, centerAbsY, currentTouchCount);
+                    }
+                }
+                if (state == ScrollRecognitionState.ACTIVE) {
+                    totalDeltaX += deltaX;
+                    totalDeltaY += deltaY;
+
+                    sendScrollEvent(centerAbsX, centerAbsY, currentTouchCount);
+
+                    lastCenterAbsX = centerAbsX;
+                    lastCenterAbsY = centerAbsY;
+                }
+            }
+        }
+    }
+    
+    private void sendScrollStartedEvent(double centerX, double centerY, int touchCount) {
+        AccessController.doPrivileged((PrivilegedAction<Void>) () -> {
+            if (scene.sceneListener != null) {
+                scene.sceneListener.scrollEvent(ScrollEvent.SCROLL_STARTED,
+                    0, 0,
+                    0, 0,
+                    1 /*xMultiplier*/, 1 /*yMultiplier*/,
+                    touchCount,
+                    0 /*scrollTextX*/, 0 /*scrollTextY*/,
+                    0 /*defaultTextX*/, 0 /*defaultTextY*/,
+                    centerX, centerY, centerAbsX, centerAbsY,
+                    (modifiers & KeyEvent.MODIFIER_SHIFT) != 0,
+                    (modifiers & KeyEvent.MODIFIER_CONTROL) != 0,
+                    (modifiers & KeyEvent.MODIFIER_ALT) != 0,
+                    (modifiers & KeyEvent.MODIFIER_WINDOWS) != 0,
+                    direct, false /*inertia*/);
+            }
+            return null;
+        }, scene.getAccessControlContext());
+    }
+
+    private void sendScrollEvent(double centerX, double centerY, int touchCount) {
+        AccessController.doPrivileged((PrivilegedAction<Void>) () -> {
+            if (scene.sceneListener != null) {
+                scene.sceneListener.scrollEvent(ScrollEvent.SCROLL,
+                    deltaX, deltaY,
+                    totalDeltaX, totalDeltaY,
+                    1 /*xMultiplier*/, 1 /*yMultiplier*/,
+                    touchCount,
+                    0 /*scrollTextX*/, 0 /*scrollTextY*/,
+                    0 /*defaultTextX*/, 0 /*defaultTextY*/,
+                    centerX, centerY, centerAbsX, centerAbsY,
+                    (modifiers & KeyEvent.MODIFIER_SHIFT) != 0,
+                    (modifiers & KeyEvent.MODIFIER_CONTROL) != 0,
+                    (modifiers & KeyEvent.MODIFIER_ALT) != 0,
+                    (modifiers & KeyEvent.MODIFIER_WINDOWS) != 0,
+                    direct, false /*inertia*/);
+            }
+            return null;
+        }, scene.getAccessControlContext());
+    }
+
+    private void sendScrollFinishedEvent(double centerX, double centerY, int touchCount) {
+        AccessController.doPrivileged((PrivilegedAction<Void>) () -> {
+            if (scene.sceneListener != null) {
+                scene.sceneListener.scrollEvent(ScrollEvent.SCROLL_FINISHED,
+                    0, 0,
+                    totalDeltaX, totalDeltaY,
+                    1 /*xMultiplier*/, 1 /*yMultiplier*/,
+                    touchCount,
+                    0 /*scrollTextX*/, 0 /*scrollTextY*/,
+                    0 /*defaultTextX*/, 0 /*defaultTextY*/,
+                    centerX, centerY, centerAbsX, centerAbsY,
+                    (modifiers & KeyEvent.MODIFIER_SHIFT) != 0,
+                    (modifiers & KeyEvent.MODIFIER_CONTROL) != 0,
+                    (modifiers & KeyEvent.MODIFIER_ALT) != 0,
+                    (modifiers & KeyEvent.MODIFIER_WINDOWS) != 0,
+                    direct, false /*inertia*/);
+            }
+            return null;
+        }, scene.getAccessControlContext());
+    }
+
+    public void params(int modifiers, boolean direct) {
+        this.modifiers = modifiers;
+        this.direct = direct;
+    }
+
+    public void touchPressed(long id, long nanos, int x, int y, int xAbs, int yAbs) {
+        currentTouchCount++;
+        TouchPointTracker tracker = new TouchPointTracker();
+        tracker.update(nanos, x, y, xAbs, yAbs);
+        trackers.put(id, tracker);
+    }
+
+    public void touchReleased(long id, long nanos, int x, int y, int xAbs, int yAbs) {
+        if (state != ScrollRecognitionState.FAILURE) {
+            TouchPointTracker tracker = trackers.get(id);
+            if (tracker == null) {
+                // we don't know this ID, something went completely wrong
+                state = ScrollRecognitionState.FAILURE;
+                throw new RuntimeException("Error in Scroll gesture "
+                        + "recognition: released unknown touch point");
+            }
+            trackers.remove(id);
+        }
+        currentTouchCount--;
+    }
+
+    public void touchMoved(long id, long nanos, int x, int y, int xAbs, int yAbs) {
+        if (state == ScrollRecognitionState.FAILURE) {
+            return;
+        }
+
+        TouchPointTracker tracker = trackers.get(id);
+        if (tracker == null) {
+            // we don't know this ID, something went completely wrong
+            state = ScrollRecognitionState.FAILURE;
+            throw new RuntimeException("Error in scroll gesture "
+                    + "recognition: reported unknown touch point");
+        }
+        tracker.update(nanos, x, y, xAbs, yAbs);
+    }
+
+    void reset() {
+        state = ScrollRecognitionState.IDLE;
+        totalDeltaX = 0.0;
+        totalDeltaY = 0.0;
+    }
+
+    private static class TouchPointTracker {
+        double x, y;
+        double absX, absY;
+
+        public void update(long nanos, double x, double y, double absX, double absY) {
+            this.x = x;
+            this.y = y;
+            this.absX = absX;
+            this.absY = absY;
+        }
+
+        public double getX() {
+            return x;
+        }
+
+        public double getY() {
+            return y;
+        }
+
+        public double getAbsX() {
+            return absX;
+        }
+
+        public double getAbsY() {
+            return absY;
+        }
+    }
+
+    private enum ScrollRecognitionState {
+        IDLE,       // no touch points available
+        TRACKING,   // 1+ touch points, center position is tracked
+        ACTIVE,     // threshold accepted, gesture is started
+        FAILURE
+    }
+}