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 5 package org.chromium.chromoting; 6 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; 14 15 /** 16 * This class implements the cursor-tracking behavior and gestures. 17 */ 18 public 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