Home | History | Annotate | Download | only in chromoting
      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