TrackingInputHandler.java revision a3f6a49ab37290eeeb8db0f41ec0f1cb74a68be7
1// Copyright 2013 The Chromium Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5package org.chromium.chromoting;
6
7import android.content.Context;
8import android.graphics.Matrix;
9import android.graphics.PointF;
10import android.view.GestureDetector;
11import android.view.MotionEvent;
12import android.view.ScaleGestureDetector;
13import android.widget.Scroller;
14
15/**
16 * This class implements the cursor-tracking behavior and gestures.
17 */
18public class TrackingInputHandler implements TouchInputHandler {
19    /**
20     * Minimum change to the scaling factor to be recognized as a zoom gesture. Setting lower
21     * values here will result in more frequent canvas redraws during zooming.
22     */
23    private static final double MIN_ZOOM_DELTA = 0.05;
24
25    /**
26     * Maximum allowed zoom level - see {@link #repositionImageWithZoom()}.
27     */
28    private static final float MAX_ZOOM_FACTOR = 100.0f;
29
30    private DesktopViewInterface mViewer;
31    private RenderData mRenderData;
32
33    private GestureDetector mScroller;
34    private ScaleGestureDetector mZoomer;
35    private TapGestureDetector mTapDetector;
36
37    /** Used to calculate the physics for flinging the cursor. */
38    private Scroller mFlingScroller;
39
40    /** Used to disambiguate a 2-finger gesture as a swipe or a pinch. */
41    private SwipePinchDetector mSwipePinchDetector;
42
43    /**
44     * The current cursor position is stored here as floats, so that the desktop image can be
45     * positioned with sub-pixel accuracy, to give a smoother panning animation at high zoom levels.
46     */
47    private PointF mCursorPosition;
48
49    /**
50     * Used for tracking swipe gestures. Only the Y-direction is needed for responding to swipe-up
51     * or swipe-down.
52     */
53    private float mTotalMotionY = 0;
54
55    /**
56     * Distance in pixels beyond which a motion gesture is considered to be a swipe. This is
57     * initialized using the Context passed into the ctor.
58     */
59    private float mSwipeThreshold;
60
61    /** Mouse-button currently held down, or BUTTON_UNDEFINED otherwise. */
62    private int mHeldButton = BUTTON_UNDEFINED;
63
64    /**
65     * Set to true to prevent any further movement of the cursor, for example, when showing the
66     * keyboard to prevent the cursor wandering from the area where keystrokes should be sent.
67     */
68    private boolean mSuppressCursorMovement = false;
69
70    /**
71     * Set to true to suppress the fling animation at the end of a gesture, for example, when
72     * dragging whilst a button is held down.
73     */
74    private boolean mSuppressFling = false;
75
76    /**
77     * Set to true when 3-finger swipe gesture is complete, so that further movement doesn't
78     * trigger more swipe actions.
79     */
80    private boolean mSwipeCompleted = false;
81
82    public TrackingInputHandler(DesktopViewInterface viewer, Context context,
83                                RenderData renderData) {
84        mViewer = viewer;
85        mRenderData = renderData;
86
87        GestureListener listener = new GestureListener();
88        mScroller = new GestureDetector(context, listener, null, false);
89
90        // If long-press is enabled, the gesture-detector will not emit any further onScroll
91        // notifications after the onLongPress notification. Since onScroll is being used for
92        // moving the cursor, it means that the cursor would become stuck if the finger were held
93        // down too long.
94        mScroller.setIsLongpressEnabled(false);
95
96        mZoomer = new ScaleGestureDetector(context, listener);
97        mTapDetector = new TapGestureDetector(context, listener);
98        mFlingScroller = new Scroller(context);
99        mSwipePinchDetector = new SwipePinchDetector(context);
100
101        mCursorPosition = new PointF();
102
103        // The threshold needs to be bigger than the ScaledTouchSlop used by the gesture-detectors,
104        // so that a gesture cannot be both a tap and a swipe. It also needs to be small enough so
105        // that intentional swipes are usually detected.
106        float density = context.getResources().getDisplayMetrics().density;
107        mSwipeThreshold = 40 * density;
108    }
109
110    /**
111     * Moves the mouse-cursor, injects a mouse-move event and repositions the image.
112     */
113    private void moveCursor(float newX, float newY) {
114        synchronized (mRenderData) {
115            // Constrain cursor to the image area.
116            if (newX < 0) newX = 0;
117            if (newY < 0) newY = 0;
118            if (newX > mRenderData.imageWidth) newX = mRenderData.imageWidth;
119            if (newY > mRenderData.imageHeight) newY = mRenderData.imageHeight;
120            mCursorPosition.set(newX, newY);
121            repositionImage();
122        }
123
124        mViewer.injectMouseEvent((int)newX, (int)newY, BUTTON_UNDEFINED, false);
125    }
126
127    /**
128     * Repositions the image by translating it (without affecting the zoom level) to place the
129     * cursor close to the center of the screen.
130     */
131    private void repositionImage() {
132        synchronized (mRenderData) {
133            // Get the current cursor position in screen coordinates.
134            float[] cursorScreen = {mCursorPosition.x, mCursorPosition.y};
135            mRenderData.transform.mapPoints(cursorScreen);
136
137            // Translate so the cursor is displayed in the middle of the screen.
138            mRenderData.transform.postTranslate(
139                    (float)mRenderData.screenWidth / 2 - cursorScreen[0],
140                    (float)mRenderData.screenHeight / 2 - cursorScreen[1]);
141
142            // Now the cursor is displayed in the middle of the screen, see if the image can be
143            // panned so that more of it is visible. The primary goal is to show as much of the
144            // image as possible. The secondary goal is to keep the cursor in the middle.
145
146            // Get the coordinates of the desktop rectangle (top-left/bottom-right corners) in
147            // screen coordinates. Order is: left, top, right, bottom.
148            float[] rectScreen = {0, 0, mRenderData.imageWidth, mRenderData.imageHeight};
149            mRenderData.transform.mapPoints(rectScreen);
150
151            float leftDelta = rectScreen[0];
152            float rightDelta = rectScreen[2] - mRenderData.screenWidth;
153            float topDelta = rectScreen[1];
154            float bottomDelta = rectScreen[3] - mRenderData.screenHeight;
155            float xAdjust = 0;
156            float yAdjust = 0;
157
158            if (rectScreen[2] - rectScreen[0] < mRenderData.screenWidth) {
159                // Image is narrower than the screen, so center it.
160                xAdjust = -(rightDelta + leftDelta) / 2;
161            } else if (leftDelta > 0 && rightDelta > 0) {
162                // Panning the image left will show more of it.
163                xAdjust = -Math.min(leftDelta, rightDelta);
164            } else if (leftDelta < 0 && rightDelta < 0) {
165                // Pan the image right.
166                xAdjust = Math.min(-leftDelta, -rightDelta);
167            }
168
169            // Apply similar logic for yAdjust.
170            if (rectScreen[3] - rectScreen[1] < mRenderData.screenHeight) {
171                yAdjust = -(bottomDelta + topDelta) / 2;
172            } else if (topDelta > 0 && bottomDelta > 0) {
173                yAdjust = -Math.min(topDelta, bottomDelta);
174            } else if (topDelta < 0 && bottomDelta < 0) {
175                yAdjust = Math.min(-topDelta, -bottomDelta);
176            }
177
178            mRenderData.transform.postTranslate(xAdjust, yAdjust);
179        }
180        mViewer.transformationChanged();
181    }
182
183    /**
184     * Repositions the image by translating and zooming it, to keep the zoom level within sensible
185     * limits. The minimum zoom level is chosen to avoid black space around all 4 sides. The
186     * maximum zoom level is set arbitrarily, so that the user can zoom out again in a reasonable
187     * time, and to prevent arithmetic overflow problems from displaying the image.
188     */
189    private void repositionImageWithZoom() {
190        synchronized (mRenderData) {
191            // Avoid division by zero in case this gets called before the image size is initialized.
192            if (mRenderData.imageWidth == 0 || mRenderData.imageHeight == 0) {
193                return;
194            }
195
196            // Zoom out if the zoom level is too high.
197            float currentZoomLevel = mRenderData.transform.mapRadius(1.0f);
198            if (currentZoomLevel > MAX_ZOOM_FACTOR) {
199                mRenderData.transform.setScale(MAX_ZOOM_FACTOR, MAX_ZOOM_FACTOR);
200            }
201
202            // Get image size scaled to screen coordinates.
203            float[] imageSize = {(float)mRenderData.imageWidth, (float)mRenderData.imageHeight};
204            mRenderData.transform.mapVectors(imageSize);
205
206            if (imageSize[0] < mRenderData.screenWidth && imageSize[1] < mRenderData.screenHeight) {
207                // Displayed image is too small in both directions, so apply the minimum zoom
208                // level needed to fit either the width or height.
209                float scale = Math.min((float)mRenderData.screenWidth / mRenderData.imageWidth,
210                                       (float)mRenderData.screenHeight / mRenderData.imageHeight);
211                mRenderData.transform.setScale(scale, scale);
212            }
213
214            repositionImage();
215        }
216    }
217
218    /** Injects a button event using the current cursor location. */
219    private void injectButtonEvent(int button, boolean pressed) {
220        mViewer.injectMouseEvent((int)mCursorPosition.x, (int)mCursorPosition.y, button, pressed);
221    }
222
223    /** Processes a (multi-finger) swipe gesture. */
224    private boolean onSwipe() {
225        if (mTotalMotionY > mSwipeThreshold) {
226            // Swipe down occurred.
227            mViewer.showActionBar();
228        } else if (mTotalMotionY < -mSwipeThreshold) {
229            // Swipe up occurred.
230            mViewer.showKeyboard();
231        } else {
232            return false;
233        }
234
235        mSuppressCursorMovement = true;
236        mSuppressFling = true;
237        mSwipeCompleted = true;
238        return true;
239    }
240
241    /** Injects a button-up event if the button is currently held down (during a drag event). */
242    private void releaseAnyHeldButton() {
243        if (mHeldButton != BUTTON_UNDEFINED) {
244            injectButtonEvent(mHeldButton, false);
245            mHeldButton = BUTTON_UNDEFINED;
246        }
247    }
248
249    @Override
250    public boolean onTouchEvent(MotionEvent event) {
251        // Avoid short-circuit logic evaluation - ensure all gesture detectors see all events so
252        // that they generate correct notifications.
253        boolean handled = mScroller.onTouchEvent(event);
254        handled |= mZoomer.onTouchEvent(event);
255        handled |= mTapDetector.onTouchEvent(event);
256        mSwipePinchDetector.onTouchEvent(event);
257
258        switch (event.getActionMasked()) {
259            case MotionEvent.ACTION_DOWN:
260                mViewer.setAnimationEnabled(false);
261                mSuppressCursorMovement = false;
262                mSuppressFling = false;
263                mSwipeCompleted = false;
264                break;
265
266            case MotionEvent.ACTION_POINTER_DOWN:
267                mTotalMotionY = 0;
268                break;
269
270            case MotionEvent.ACTION_UP:
271                releaseAnyHeldButton();
272                break;
273
274            default:
275                break;
276        }
277        return handled;
278    }
279
280    @Override
281    public void onScreenConfigurationChanged() {
282    }
283
284    @Override
285    public void onClientSizeChanged(int width, int height) {
286        repositionImageWithZoom();
287    }
288
289    @Override
290    public void onHostSizeChanged(int width, int height) {
291        moveCursor((float)width / 2, (float)height / 2);
292        repositionImageWithZoom();
293    }
294
295    @Override
296    public void processAnimation() {
297        int previousX = mFlingScroller.getCurrX();
298        int previousY = mFlingScroller.getCurrY();
299        if (!mFlingScroller.computeScrollOffset()) {
300            mViewer.setAnimationEnabled(false);
301            return;
302        }
303        int deltaX = mFlingScroller.getCurrX() - previousX;
304        int deltaY = mFlingScroller.getCurrY() - previousY;
305        float delta[] = {(float)deltaX, (float)deltaY};
306        synchronized (mRenderData) {
307            Matrix canvasToImage = new Matrix();
308            mRenderData.transform.invert(canvasToImage);
309            canvasToImage.mapVectors(delta);
310        }
311
312        moveCursor(mCursorPosition.x + delta[0], mCursorPosition.y + delta[1]);
313    }
314
315    /** Responds to touch events filtered by the gesture detectors. */
316    private class GestureListener extends GestureDetector.SimpleOnGestureListener
317            implements ScaleGestureDetector.OnScaleGestureListener,
318                       TapGestureDetector.OnTapListener {
319        /**
320         * Called when the user drags one or more fingers across the touchscreen.
321         */
322        @Override
323        public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
324            int pointerCount = e2.getPointerCount();
325            if (pointerCount == 3 && !mSwipeCompleted) {
326                // Note that distance values are reversed. For example, dragging a finger in the
327                // direction of increasing Y coordinate (downwards) results in distanceY being
328                // negative.
329                mTotalMotionY -= distanceY;
330                return onSwipe();
331            }
332
333            if (pointerCount == 2 && mSwipePinchDetector.isSwiping()) {
334                mViewer.injectMouseWheelDeltaEvent(-(int)distanceX, -(int)distanceY);
335
336                // Prevent the cursor being moved or flung by the gesture.
337                mSuppressCursorMovement = true;
338                return true;
339            }
340
341            if (pointerCount != 1 || mSuppressCursorMovement) {
342                return false;
343            }
344
345            float[] delta = {distanceX, distanceY};
346            synchronized (mRenderData) {
347                Matrix canvasToImage = new Matrix();
348                mRenderData.transform.invert(canvasToImage);
349                canvasToImage.mapVectors(delta);
350            }
351
352            moveCursor(mCursorPosition.x - delta[0], mCursorPosition.y - delta[1]);
353            return true;
354        }
355
356        /**
357         * Called when a fling gesture is recognized.
358         */
359        @Override
360        public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
361            // If cursor movement is suppressed, fling also needs to be suppressed, as the
362            // gesture-detector will still generate onFling() notifications based on movement of
363            // the fingers, which would result in unwanted cursor movement.
364            if (mSuppressCursorMovement || mSuppressFling) {
365                return false;
366            }
367
368            // The fling physics calculation is based on screen coordinates, so that it will
369            // behave consistently at different zoom levels (and will work nicely at high zoom
370            // levels, since |mFlingScroller| outputs integer coordinates). However, the desktop
371            // will usually be panned as the cursor is moved across the desktop, which means the
372            // transformation mapping from screen to desktop coordinates will change. To deal with
373            // this, the cursor movement is computed from relative coordinate changes from
374            // |mFlingScroller|. This means the fling can be started at (0, 0) with no bounding
375            // constraints - the cursor is already constrained by the desktop size.
376            mFlingScroller.fling(0, 0, (int)velocityX, (int)velocityY, Integer.MIN_VALUE,
377                    Integer.MAX_VALUE, Integer.MIN_VALUE, Integer.MAX_VALUE);
378            // Initialize the scroller's current offset coordinates, since they are used for
379            // calculating the delta values.
380            mFlingScroller.computeScrollOffset();
381            mViewer.setAnimationEnabled(true);
382            return true;
383        }
384
385        /** Called when the user is in the process of pinch-zooming. */
386        @Override
387        public boolean onScale(ScaleGestureDetector detector) {
388            if (!mSwipePinchDetector.isPinching()) {
389                return false;
390            }
391
392            if (Math.abs(detector.getScaleFactor() - 1) < MIN_ZOOM_DELTA) {
393                return false;
394            }
395
396            float scaleFactor = detector.getScaleFactor();
397            synchronized (mRenderData) {
398                mRenderData.transform.postScale(
399                        scaleFactor, scaleFactor, detector.getFocusX(), detector.getFocusY());
400            }
401            repositionImageWithZoom();
402            return true;
403        }
404
405        /** Called whenever a gesture starts. Always accepts the gesture so it isn't ignored. */
406        @Override
407        public boolean onDown(MotionEvent e) {
408            return true;
409        }
410
411        /**
412         * Called when the user starts to zoom. Always accepts the zoom so that
413         * onScale() can decide whether to respond to it.
414         */
415        @Override
416        public boolean onScaleBegin(ScaleGestureDetector detector) {
417            return true;
418        }
419
420        /** Called when the user is done zooming. Defers to onScale()'s judgement. */
421        @Override
422        public void onScaleEnd(ScaleGestureDetector detector) {
423            onScale(detector);
424        }
425
426        /** Maps the number of fingers in a tap or long-press gesture to a mouse-button. */
427        private int mouseButtonFromPointerCount(int pointerCount) {
428            switch (pointerCount) {
429                case 1:
430                    return BUTTON_LEFT;
431                case 2:
432                    return BUTTON_RIGHT;
433                case 3:
434                    return BUTTON_MIDDLE;
435                default:
436                    return BUTTON_UNDEFINED;
437            }
438        }
439
440        /** Called when the user taps the screen with one or more fingers. */
441        @Override
442        public boolean onTap(int pointerCount) {
443            int button = mouseButtonFromPointerCount(pointerCount);
444            if (button == BUTTON_UNDEFINED) {
445                return false;
446            } else {
447                injectButtonEvent(button, true);
448                injectButtonEvent(button, false);
449                return true;
450            }
451        }
452
453        /** Called when a long-press is triggered for one or more fingers. */
454        @Override
455        public void onLongPress(int pointerCount) {
456            mHeldButton = mouseButtonFromPointerCount(pointerCount);
457            if (mHeldButton != BUTTON_UNDEFINED) {
458                injectButtonEvent(mHeldButton, true);
459                mViewer.showLongPressFeedback();
460                mSuppressFling = true;
461            }
462        }
463    }
464}
465