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.
5 package org
.chromium
.chromoting
;
7 import android
.content
.Context
;
8 import android
.graphics
.Matrix
;
9 import android
.graphics
.PointF
;
10 import android
.view
.GestureDetector
;
11 import android
.view
.MotionEvent
;
12 import android
.view
.ScaleGestureDetector
;
13 import android
.widget
.Scroller
;
16 * This class implements the cursor-tracking behavior and gestures.
18 public class TrackingInputHandler
implements TouchInputHandler
{
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.
23 private static final double MIN_ZOOM_DELTA
= 0.05;
26 * Maximum allowed zoom level - see {@link #repositionImageWithZoom()}.
28 private static final float MAX_ZOOM_FACTOR
= 100.0f
;
30 private DesktopViewInterface mViewer
;
31 private RenderData mRenderData
;
33 private GestureDetector mScroller
;
34 private ScaleGestureDetector mZoomer
;
35 private TapGestureDetector mTapDetector
;
37 /** Used to calculate the physics for flinging the cursor. */
38 private Scroller mFlingScroller
;
40 /** Used to disambiguate a 2-finger gesture as a swipe or a pinch. */
41 private SwipePinchDetector mSwipePinchDetector
;
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.
47 private PointF mCursorPosition
;
50 * Used for tracking swipe gestures. Only the Y-direction is needed for responding to swipe-up
53 private float mTotalMotionY
= 0;
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.
59 private float mSwipeThreshold
;
61 /** Mouse-button currently held down, or BUTTON_UNDEFINED otherwise. */
62 private int mHeldButton
= BUTTON_UNDEFINED
;
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.
68 private boolean mSuppressCursorMovement
= false;
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.
74 private boolean mSuppressFling
= false;
77 * Set to true when 3-finger swipe gesture is complete, so that further movement doesn't
78 * trigger more swipe actions.
80 private boolean mSwipeCompleted
= false;
82 public TrackingInputHandler(DesktopViewInterface viewer
, Context context
,
83 RenderData renderData
) {
85 mRenderData
= renderData
;
87 GestureListener listener
= new GestureListener();
88 mScroller
= new GestureDetector(context
, listener
, null, false);
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
94 mScroller
.setIsLongpressEnabled(false);
96 mZoomer
= new ScaleGestureDetector(context
, listener
);
97 mTapDetector
= new TapGestureDetector(context
, listener
);
98 mFlingScroller
= new Scroller(context
);
99 mSwipePinchDetector
= new SwipePinchDetector(context
);
101 mCursorPosition
= new PointF();
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
;
111 * Moves the mouse-cursor, injects a mouse-move event and repositions the image.
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
);
124 mViewer
.injectMouseEvent((int) newX
, (int) newY
, BUTTON_UNDEFINED
, false);
128 * Repositions the image by translating it (without affecting the zoom level) to place the
129 * cursor close to the center of the screen.
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
);
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]);
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.
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
);
151 float leftDelta
= rectScreen
[0];
152 float rightDelta
= rectScreen
[2] - mRenderData
.screenWidth
;
153 float topDelta
= rectScreen
[1];
154 float bottomDelta
= rectScreen
[3] - mRenderData
.screenHeight
;
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
);
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
);
178 mRenderData
.transform
.postTranslate(xAdjust
, yAdjust
);
180 mViewer
.transformationChanged();
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.
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) {
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
);
202 // Get image size scaled to screen coordinates.
203 float[] imageSize
= {mRenderData
.imageWidth
, mRenderData
.imageHeight
};
204 mRenderData
.transform
.mapVectors(imageSize
);
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
);
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
);
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();
235 mSuppressCursorMovement
= true;
236 mSuppressFling
= true;
237 mSwipeCompleted
= true;
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
;
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
);
258 switch (event
.getActionMasked()) {
259 case MotionEvent
.ACTION_DOWN
:
260 mViewer
.setAnimationEnabled(false);
261 mSuppressCursorMovement
= false;
262 mSuppressFling
= false;
263 mSwipeCompleted
= false;
266 case MotionEvent
.ACTION_POINTER_DOWN
:
270 case MotionEvent
.ACTION_UP
:
271 releaseAnyHeldButton();
281 public void onScreenConfigurationChanged() {
285 public void onClientSizeChanged(int width
, int height
) {
286 repositionImageWithZoom();
290 public void onHostSizeChanged(int width
, int height
) {
291 moveCursor((float) width
/ 2, (float) height
/ 2);
292 repositionImageWithZoom();
296 public void processAnimation() {
297 int previousX
= mFlingScroller
.getCurrX();
298 int previousY
= mFlingScroller
.getCurrY();
299 if (!mFlingScroller
.computeScrollOffset()) {
300 mViewer
.setAnimationEnabled(false);
303 int deltaX
= mFlingScroller
.getCurrX() - previousX
;
304 int deltaY
= mFlingScroller
.getCurrY() - previousY
;
305 float[] delta
= {deltaX
, deltaY
};
306 synchronized (mRenderData
) {
307 Matrix canvasToImage
= new Matrix();
308 mRenderData
.transform
.invert(canvasToImage
);
309 canvasToImage
.mapVectors(delta
);
312 moveCursor(mCursorPosition
.x
+ delta
[0], mCursorPosition
.y
+ delta
[1]);
315 /** Responds to touch events filtered by the gesture detectors. */
316 private class GestureListener
extends GestureDetector
.SimpleOnGestureListener
317 implements ScaleGestureDetector
.OnScaleGestureListener
,
318 TapGestureDetector
.OnTapListener
{
320 * Called when the user drags one or more fingers across the touchscreen.
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
329 mTotalMotionY
-= distanceY
;
333 if (pointerCount
== 2 && mSwipePinchDetector
.isSwiping()) {
334 mViewer
.injectMouseWheelDeltaEvent(-(int) distanceX
, -(int) distanceY
);
336 // Prevent the cursor being moved or flung by the gesture.
337 mSuppressCursorMovement
= true;
341 if (pointerCount
!= 1 || mSuppressCursorMovement
) {
345 float[] delta
= {distanceX
, distanceY
};
346 synchronized (mRenderData
) {
347 Matrix canvasToImage
= new Matrix();
348 mRenderData
.transform
.invert(canvasToImage
);
349 canvasToImage
.mapVectors(delta
);
352 moveCursor(mCursorPosition
.x
- delta
[0], mCursorPosition
.y
- delta
[1]);
357 * Called when a fling gesture is recognized.
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
) {
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);
385 /** Called when the user is in the process of pinch-zooming. */
387 public boolean onScale(ScaleGestureDetector detector
) {
388 if (!mSwipePinchDetector
.isPinching()) {
392 if (Math
.abs(detector
.getScaleFactor() - 1) < MIN_ZOOM_DELTA
) {
396 float scaleFactor
= detector
.getScaleFactor();
397 synchronized (mRenderData
) {
398 mRenderData
.transform
.postScale(
399 scaleFactor
, scaleFactor
, detector
.getFocusX(), detector
.getFocusY());
401 repositionImageWithZoom();
405 /** Called whenever a gesture starts. Always accepts the gesture so it isn't ignored. */
407 public boolean onDown(MotionEvent e
) {
412 * Called when the user starts to zoom. Always accepts the zoom so that
413 * onScale() can decide whether to respond to it.
416 public boolean onScaleBegin(ScaleGestureDetector detector
) {
420 /** Called when the user is done zooming. Defers to onScale()'s judgement. */
422 public void onScaleEnd(ScaleGestureDetector detector
) {
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
) {
434 return BUTTON_MIDDLE
;
436 return BUTTON_UNDEFINED
;
440 /** Called when the user taps the screen with one or more fingers. */
442 public boolean onTap(int pointerCount
) {
443 int button
= mouseButtonFromPointerCount(pointerCount
);
444 if (button
== BUTTON_UNDEFINED
) {
447 injectButtonEvent(button
, true);
448 injectButtonEvent(button
, false);
453 /** Called when a long-press is triggered for one or more fingers. */
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;