1 /* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
2  * This Source Code Form is subject to the terms of the Mozilla Public
3  * License, v. 2.0. If a copy of the MPL was not distributed with this
4  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
5 
6 package org.mozilla.gecko.gfx;
7 
8 import android.graphics.PointF;
9 import android.util.Log;
10 import android.view.MotionEvent;
11 
12 import org.json.JSONException;
13 
14 import java.util.LinkedList;
15 import java.util.ListIterator;
16 import java.util.Stack;
17 
18 /**
19  * A less buggy, and smoother, replacement for the built-in Android ScaleGestureDetector.
20  *
21  * This gesture detector is more reliable than the built-in ScaleGestureDetector because:
22  *
23  *   - It doesn't assume that pointer IDs are numbered 0 and 1.
24  *
25  *   - It doesn't attempt to correct for "slop" when resting one's hand on the device. On some
26  *     devices (e.g. the Droid X) this can cause the ScaleGestureDetector to lose track of how many
27  *     pointers are down, with disastrous results (bug 706684).
28  *
29  *   - Cancelling a zoom into a pan is handled correctly.
30  *
31  *   - Starting with three or more fingers down, releasing fingers so that only two are down, and
32  *     then performing a scale gesture is handled correctly.
33  *
34  *   - It doesn't take pressure into account, which results in smoother scaling.
35  */
36 public class SimpleScaleGestureDetector {
37     private static final String LOGTAG = "GeckoSimpleScaleGestureDetector";
38 
39     private SimpleScaleGestureListener mListener;
40     private long mLastEventTime;
41     private boolean mScaleResult;
42 
43     /* Information about all pointers that are down. */
44     private LinkedList<PointerInfo> mPointerInfo;
45 
46     /** Creates a new gesture detector with the given listener. */
SimpleScaleGestureDetector(SimpleScaleGestureListener listener)47     public SimpleScaleGestureDetector(SimpleScaleGestureListener listener) {
48         mListener = listener;
49         mPointerInfo = new LinkedList<PointerInfo>();
50     }
51 
52     /** Forward touch events to this function. */
onTouchEvent(MotionEvent event)53     public void onTouchEvent(MotionEvent event) {
54         switch (event.getAction() & MotionEvent.ACTION_MASK) {
55         case MotionEvent.ACTION_DOWN:
56             // If we get ACTION_DOWN while still tracking any pointers,
57             // something is wrong.  Cancel the current gesture and start over.
58             if (getPointersDown() > 0)
59                 onTouchEnd(event);
60             onTouchStart(event);
61             break;
62         case MotionEvent.ACTION_POINTER_DOWN:
63             onTouchStart(event);
64             break;
65         case MotionEvent.ACTION_MOVE:
66             onTouchMove(event);
67             break;
68         case MotionEvent.ACTION_POINTER_UP:
69         case MotionEvent.ACTION_UP:
70         case MotionEvent.ACTION_CANCEL:
71             onTouchEnd(event);
72             break;
73         }
74     }
75 
getPointersDown()76     private int getPointersDown() {
77         return mPointerInfo.size();
78     }
79 
getActionIndex(MotionEvent event)80     private int getActionIndex(MotionEvent event) {
81         return (event.getAction() & MotionEvent.ACTION_POINTER_INDEX_MASK)
82             >> MotionEvent.ACTION_POINTER_INDEX_SHIFT;
83     }
84 
onTouchStart(MotionEvent event)85     private void onTouchStart(MotionEvent event) {
86         mLastEventTime = event.getEventTime();
87         mPointerInfo.addFirst(PointerInfo.create(event, getActionIndex(event)));
88         if (getPointersDown() == 2) {
89             sendScaleGesture(EventType.BEGIN);
90         }
91     }
92 
onTouchMove(MotionEvent event)93     private void onTouchMove(MotionEvent event) {
94         mLastEventTime = event.getEventTime();
95         for (int i = 0; i < event.getPointerCount(); i++) {
96             PointerInfo pointerInfo = pointerInfoForEventIndex(event, i);
97             if (pointerInfo != null) {
98                 pointerInfo.populate(event, i);
99             }
100         }
101 
102         if (getPointersDown() == 2) {
103             sendScaleGesture(EventType.CONTINUE);
104         }
105     }
106 
onTouchEnd(MotionEvent event)107     private void onTouchEnd(MotionEvent event) {
108         mLastEventTime = event.getEventTime();
109 
110         int action = event.getAction() & MotionEvent.ACTION_MASK;
111         boolean isCancel = (action == MotionEvent.ACTION_CANCEL ||
112                             action == MotionEvent.ACTION_DOWN);
113 
114         int id = event.getPointerId(getActionIndex(event));
115         ListIterator<PointerInfo> iterator = mPointerInfo.listIterator();
116         while (iterator.hasNext()) {
117             PointerInfo pointerInfo = iterator.next();
118             if (!(isCancel || pointerInfo.getId() == id)) {
119                 continue;
120             }
121 
122             // One of the pointers we were tracking was lifted. Remove its info object from the
123             // list, recycle it to avoid GC pauses, and send an onScaleEnd() notification if this
124             // ended the gesture.
125             iterator.remove();
126             pointerInfo.recycle();
127             if (getPointersDown() == 1) {
128                 sendScaleGesture(EventType.END);
129             }
130         }
131     }
132 
133     /**
134      * Returns the X coordinate of the focus location (the midpoint of the two fingers). If only
135      * one finger is down, returns the location of that finger.
136      */
getFocusX()137     public float getFocusX() {
138         switch (getPointersDown()) {
139         case 1:
140             return mPointerInfo.getFirst().getCurrent().x;
141         case 2:
142             PointerInfo pointerA = mPointerInfo.getFirst(), pointerB = mPointerInfo.getLast();
143             return (pointerA.getCurrent().x + pointerB.getCurrent().x) / 2.0f;
144         }
145 
146         Log.e(LOGTAG, "No gesture taking place in getFocusX()!");
147         return 0.0f;
148     }
149 
150     /**
151      * Returns the Y coordinate of the focus location (the midpoint of the two fingers). If only
152      * one finger is down, returns the location of that finger.
153      */
getFocusY()154     public float getFocusY() {
155         switch (getPointersDown()) {
156         case 1:
157             return mPointerInfo.getFirst().getCurrent().y;
158         case 2:
159             PointerInfo pointerA = mPointerInfo.getFirst(), pointerB = mPointerInfo.getLast();
160             return (pointerA.getCurrent().y + pointerB.getCurrent().y) / 2.0f;
161         }
162 
163         Log.e(LOGTAG, "No gesture taking place in getFocusY()!");
164         return 0.0f;
165     }
166 
167     /** Returns the most recent distance between the two pointers. */
getCurrentSpan()168     public float getCurrentSpan() {
169         if (getPointersDown() != 2) {
170             Log.e(LOGTAG, "No gesture taking place in getCurrentSpan()!");
171             return 0.0f;
172         }
173 
174         PointerInfo pointerA = mPointerInfo.getFirst(), pointerB = mPointerInfo.getLast();
175         return PointUtils.distance(pointerA.getCurrent(), pointerB.getCurrent());
176     }
177 
178     /** Returns the second most recent distance between the two pointers. */
getPreviousSpan()179     public float getPreviousSpan() {
180         if (getPointersDown() != 2) {
181             Log.e(LOGTAG, "No gesture taking place in getPreviousSpan()!");
182             return 0.0f;
183         }
184 
185         PointerInfo pointerA = mPointerInfo.getFirst(), pointerB = mPointerInfo.getLast();
186         PointF a = pointerA.getPrevious(), b = pointerB.getPrevious();
187         if (a == null || b == null) {
188             a = pointerA.getCurrent();
189             b = pointerB.getCurrent();
190         }
191 
192         return PointUtils.distance(a, b);
193     }
194 
195     /** Returns the time of the last event related to the gesture. */
getEventTime()196     public long getEventTime() {
197         return mLastEventTime;
198     }
199 
200     /** Returns true if the scale gesture is in progress and false otherwise. */
isInProgress()201     public boolean isInProgress() {
202         return getPointersDown() == 2;
203     }
204 
205     /* Sends the requested scale gesture notification to the listener. */
sendScaleGesture(EventType eventType)206     private void sendScaleGesture(EventType eventType) {
207         switch (eventType) {
208         case BEGIN:
209             mScaleResult = mListener.onScaleBegin(this);
210             break;
211         case CONTINUE:
212             if (mScaleResult) {
213                 mListener.onScale(this);
214             }
215             break;
216         case END:
217             if (mScaleResult) {
218                 mListener.onScaleEnd(this);
219             }
220             break;
221         }
222     }
223 
224     /*
225      * Returns the pointer info corresponding to the given pointer index, or null if the pointer
226      * isn't one that's being tracked.
227      */
pointerInfoForEventIndex(MotionEvent event, int index)228     private PointerInfo pointerInfoForEventIndex(MotionEvent event, int index) {
229         int id = event.getPointerId(index);
230         for (PointerInfo pointerInfo : mPointerInfo) {
231             if (pointerInfo.getId() == id) {
232                 return pointerInfo;
233             }
234         }
235         return null;
236     }
237 
238     private enum EventType {
239         BEGIN,
240         CONTINUE,
241         END,
242     }
243 
244     /* Encapsulates information about one of the two fingers involved in the gesture. */
245     private static class PointerInfo {
246         /* A free list that recycles pointer info objects, to reduce GC pauses. */
247         private static Stack<PointerInfo> sPointerInfoFreeList;
248 
249         private int mId;
250         private PointF mCurrent, mPrevious;
251 
PointerInfo()252         private PointerInfo() {
253             // External users should use create() instead.
254         }
255 
256         /* Creates or recycles a new PointerInfo instance from an event and a pointer index. */
create(MotionEvent event, int index)257         public static PointerInfo create(MotionEvent event, int index) {
258             if (sPointerInfoFreeList == null) {
259                 sPointerInfoFreeList = new Stack<PointerInfo>();
260             }
261 
262             PointerInfo pointerInfo;
263             if (sPointerInfoFreeList.empty()) {
264                 pointerInfo = new PointerInfo();
265             } else {
266                 pointerInfo = sPointerInfoFreeList.pop();
267             }
268 
269             pointerInfo.populate(event, index);
270             return pointerInfo;
271         }
272 
273         /*
274          * Fills in the fields of this instance from the given motion event and pointer index
275          * within that event.
276          */
populate(MotionEvent event, int index)277         public void populate(MotionEvent event, int index) {
278             mId = event.getPointerId(index);
279             mPrevious = mCurrent;
280             mCurrent = new PointF(event.getX(index), event.getY(index));
281         }
282 
recycle()283         public void recycle() {
284             mId = -1;
285             mPrevious = mCurrent = null;
286             sPointerInfoFreeList.push(this);
287         }
288 
getId()289         public int getId() { return mId; }
getCurrent()290         public PointF getCurrent() { return mCurrent; }
getPrevious()291         public PointF getPrevious() { return mPrevious; }
292 
293         @Override
toString()294         public String toString() {
295             if (mId == -1) {
296                 return "(up)";
297             }
298 
299             try {
300                 String prevString;
301                 if (mPrevious == null) {
302                     prevString = "n/a";
303                 } else {
304                     prevString = PointUtils.toJSON(mPrevious).toString();
305                 }
306 
307                 // The current position should always be non-null.
308                 String currentString = PointUtils.toJSON(mCurrent).toString();
309                 return "id=" + mId + " cur=" + currentString + " prev=" + prevString;
310             } catch (JSONException e) {
311                 throw new RuntimeException(e);
312             }
313         }
314     }
315 
316     public static interface SimpleScaleGestureListener {
onScale(SimpleScaleGestureDetector detector)317         public boolean onScale(SimpleScaleGestureDetector detector);
onScaleBegin(SimpleScaleGestureDetector detector)318         public boolean onScaleBegin(SimpleScaleGestureDetector detector);
onScaleEnd(SimpleScaleGestureDetector detector)319         public void onScaleEnd(SimpleScaleGestureDetector detector);
320     }
321 }
322 
323