| // Copyright 2013 The Chromium Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| package org.chromium.chromoting; |
| |
| import android.content.Context; |
| import android.graphics.Matrix; |
| import android.graphics.PointF; |
| import android.view.GestureDetector; |
| import android.view.MotionEvent; |
| import android.view.ScaleGestureDetector; |
| import android.widget.Scroller; |
| |
| /** |
| * This class implements the cursor-tracking behavior and gestures. |
| */ |
| public class TrackingInputHandler implements TouchInputHandler { |
| /** |
| * Minimum change to the scaling factor to be recognized as a zoom gesture. Setting lower |
| * values here will result in more frequent canvas redraws during zooming. |
| */ |
| private static final double MIN_ZOOM_DELTA = 0.05; |
| |
| /** |
| * Maximum allowed zoom level - see {@link #repositionImageWithZoom()}. |
| */ |
| private static final float MAX_ZOOM_FACTOR = 100.0f; |
| |
| private DesktopViewInterface mViewer; |
| private RenderData mRenderData; |
| |
| private GestureDetector mScroller; |
| private ScaleGestureDetector mZoomer; |
| private TapGestureDetector mTapDetector; |
| |
| /** Used to calculate the physics for flinging the cursor. */ |
| private Scroller mFlingScroller; |
| |
| /** Used to disambiguate a 2-finger gesture as a swipe or a pinch. */ |
| private SwipePinchDetector mSwipePinchDetector; |
| |
| /** |
| * The current cursor position is stored here as floats, so that the desktop image can be |
| * positioned with sub-pixel accuracy, to give a smoother panning animation at high zoom levels. |
| */ |
| private PointF mCursorPosition; |
| |
| /** |
| * Used for tracking swipe gestures. Only the Y-direction is needed for responding to swipe-up |
| * or swipe-down. |
| */ |
| private float mTotalMotionY = 0; |
| |
| /** |
| * Distance in pixels beyond which a motion gesture is considered to be a swipe. This is |
| * initialized using the Context passed into the ctor. |
| */ |
| private float mSwipeThreshold; |
| |
| /** Mouse-button currently held down, or BUTTON_UNDEFINED otherwise. */ |
| private int mHeldButton = BUTTON_UNDEFINED; |
| |
| /** |
| * Set to true to prevent any further movement of the cursor, for example, when showing the |
| * keyboard to prevent the cursor wandering from the area where keystrokes should be sent. |
| */ |
| private boolean mSuppressCursorMovement = false; |
| |
| /** |
| * Set to true to suppress the fling animation at the end of a gesture, for example, when |
| * dragging whilst a button is held down. |
| */ |
| private boolean mSuppressFling = false; |
| |
| /** |
| * Set to true when 3-finger swipe gesture is complete, so that further movement doesn't |
| * trigger more swipe actions. |
| */ |
| private boolean mSwipeCompleted = false; |
| |
| public TrackingInputHandler(DesktopViewInterface viewer, Context context, |
| RenderData renderData) { |
| mViewer = viewer; |
| mRenderData = renderData; |
| |
| GestureListener listener = new GestureListener(); |
| mScroller = new GestureDetector(context, listener, null, false); |
| |
| // If long-press is enabled, the gesture-detector will not emit any further onScroll |
| // notifications after the onLongPress notification. Since onScroll is being used for |
| // moving the cursor, it means that the cursor would become stuck if the finger were held |
| // down too long. |
| mScroller.setIsLongpressEnabled(false); |
| |
| mZoomer = new ScaleGestureDetector(context, listener); |
| mTapDetector = new TapGestureDetector(context, listener); |
| mFlingScroller = new Scroller(context); |
| mSwipePinchDetector = new SwipePinchDetector(context); |
| |
| mCursorPosition = new PointF(); |
| |
| // The threshold needs to be bigger than the ScaledTouchSlop used by the gesture-detectors, |
| // so that a gesture cannot be both a tap and a swipe. It also needs to be small enough so |
| // that intentional swipes are usually detected. |
| float density = context.getResources().getDisplayMetrics().density; |
| mSwipeThreshold = 40 * density; |
| } |
| |
| /** |
| * Moves the mouse-cursor, injects a mouse-move event and repositions the image. |
| */ |
| private void moveCursor(float newX, float newY) { |
| synchronized (mRenderData) { |
| // Constrain cursor to the image area. |
| if (newX < 0) newX = 0; |
| if (newY < 0) newY = 0; |
| if (newX > mRenderData.imageWidth) newX = mRenderData.imageWidth; |
| if (newY > mRenderData.imageHeight) newY = mRenderData.imageHeight; |
| mCursorPosition.set(newX, newY); |
| repositionImage(); |
| } |
| |
| mViewer.injectMouseEvent((int)newX, (int)newY, BUTTON_UNDEFINED, false); |
| } |
| |
| /** |
| * Repositions the image by translating it (without affecting the zoom level) to place the |
| * cursor close to the center of the screen. |
| */ |
| private void repositionImage() { |
| synchronized (mRenderData) { |
| // Get the current cursor position in screen coordinates. |
| float[] cursorScreen = {mCursorPosition.x, mCursorPosition.y}; |
| mRenderData.transform.mapPoints(cursorScreen); |
| |
| // Translate so the cursor is displayed in the middle of the screen. |
| mRenderData.transform.postTranslate( |
| (float)mRenderData.screenWidth / 2 - cursorScreen[0], |
| (float)mRenderData.screenHeight / 2 - cursorScreen[1]); |
| |
| // Now the cursor is displayed in the middle of the screen, see if the image can be |
| // panned so that more of it is visible. The primary goal is to show as much of the |
| // image as possible. The secondary goal is to keep the cursor in the middle. |
| |
| // Get the coordinates of the desktop rectangle (top-left/bottom-right corners) in |
| // screen coordinates. Order is: left, top, right, bottom. |
| float[] rectScreen = {0, 0, mRenderData.imageWidth, mRenderData.imageHeight}; |
| mRenderData.transform.mapPoints(rectScreen); |
| |
| float leftDelta = rectScreen[0]; |
| float rightDelta = rectScreen[2] - mRenderData.screenWidth; |
| float topDelta = rectScreen[1]; |
| float bottomDelta = rectScreen[3] - mRenderData.screenHeight; |
| float xAdjust = 0; |
| float yAdjust = 0; |
| |
| if (rectScreen[2] - rectScreen[0] < mRenderData.screenWidth) { |
| // Image is narrower than the screen, so center it. |
| xAdjust = -(rightDelta + leftDelta) / 2; |
| } else if (leftDelta > 0 && rightDelta > 0) { |
| // Panning the image left will show more of it. |
| xAdjust = -Math.min(leftDelta, rightDelta); |
| } else if (leftDelta < 0 && rightDelta < 0) { |
| // Pan the image right. |
| xAdjust = Math.min(-leftDelta, -rightDelta); |
| } |
| |
| // Apply similar logic for yAdjust. |
| if (rectScreen[3] - rectScreen[1] < mRenderData.screenHeight) { |
| yAdjust = -(bottomDelta + topDelta) / 2; |
| } else if (topDelta > 0 && bottomDelta > 0) { |
| yAdjust = -Math.min(topDelta, bottomDelta); |
| } else if (topDelta < 0 && bottomDelta < 0) { |
| yAdjust = Math.min(-topDelta, -bottomDelta); |
| } |
| |
| mRenderData.transform.postTranslate(xAdjust, yAdjust); |
| } |
| mViewer.transformationChanged(); |
| } |
| |
| /** |
| * Repositions the image by translating and zooming it, to keep the zoom level within sensible |
| * limits. The minimum zoom level is chosen to avoid black space around all 4 sides. The |
| * maximum zoom level is set arbitrarily, so that the user can zoom out again in a reasonable |
| * time, and to prevent arithmetic overflow problems from displaying the image. |
| */ |
| private void repositionImageWithZoom() { |
| synchronized (mRenderData) { |
| // Avoid division by zero in case this gets called before the image size is initialized. |
| if (mRenderData.imageWidth == 0 || mRenderData.imageHeight == 0) { |
| return; |
| } |
| |
| // Zoom out if the zoom level is too high. |
| float currentZoomLevel = mRenderData.transform.mapRadius(1.0f); |
| if (currentZoomLevel > MAX_ZOOM_FACTOR) { |
| mRenderData.transform.setScale(MAX_ZOOM_FACTOR, MAX_ZOOM_FACTOR); |
| } |
| |
| // Get image size scaled to screen coordinates. |
| float[] imageSize = {(float)mRenderData.imageWidth, (float)mRenderData.imageHeight}; |
| mRenderData.transform.mapVectors(imageSize); |
| |
| if (imageSize[0] < mRenderData.screenWidth && imageSize[1] < mRenderData.screenHeight) { |
| // Displayed image is too small in both directions, so apply the minimum zoom |
| // level needed to fit either the width or height. |
| float scale = Math.min((float)mRenderData.screenWidth / mRenderData.imageWidth, |
| (float)mRenderData.screenHeight / mRenderData.imageHeight); |
| mRenderData.transform.setScale(scale, scale); |
| } |
| |
| repositionImage(); |
| } |
| } |
| |
| /** Injects a button event using the current cursor location. */ |
| private void injectButtonEvent(int button, boolean pressed) { |
| mViewer.injectMouseEvent((int)mCursorPosition.x, (int)mCursorPosition.y, button, pressed); |
| } |
| |
| /** Processes a (multi-finger) swipe gesture. */ |
| private boolean onSwipe() { |
| if (mTotalMotionY > mSwipeThreshold) { |
| // Swipe down occurred. |
| mViewer.showActionBar(); |
| } else if (mTotalMotionY < -mSwipeThreshold) { |
| // Swipe up occurred. |
| mViewer.showKeyboard(); |
| } else { |
| return false; |
| } |
| |
| mSuppressCursorMovement = true; |
| mSuppressFling = true; |
| mSwipeCompleted = true; |
| return true; |
| } |
| |
| /** Injects a button-up event if the button is currently held down (during a drag event). */ |
| private void releaseAnyHeldButton() { |
| if (mHeldButton != BUTTON_UNDEFINED) { |
| injectButtonEvent(mHeldButton, false); |
| mHeldButton = BUTTON_UNDEFINED; |
| } |
| } |
| |
| @Override |
| public boolean onTouchEvent(MotionEvent event) { |
| // Avoid short-circuit logic evaluation - ensure all gesture detectors see all events so |
| // that they generate correct notifications. |
| boolean handled = mScroller.onTouchEvent(event); |
| handled |= mZoomer.onTouchEvent(event); |
| handled |= mTapDetector.onTouchEvent(event); |
| mSwipePinchDetector.onTouchEvent(event); |
| |
| switch (event.getActionMasked()) { |
| case MotionEvent.ACTION_DOWN: |
| mViewer.setAnimationEnabled(false); |
| mSuppressCursorMovement = false; |
| mSuppressFling = false; |
| mSwipeCompleted = false; |
| break; |
| |
| case MotionEvent.ACTION_POINTER_DOWN: |
| mTotalMotionY = 0; |
| break; |
| |
| case MotionEvent.ACTION_UP: |
| releaseAnyHeldButton(); |
| break; |
| |
| default: |
| break; |
| } |
| return handled; |
| } |
| |
| @Override |
| public void onScreenConfigurationChanged() { |
| } |
| |
| @Override |
| public void onClientSizeChanged(int width, int height) { |
| repositionImageWithZoom(); |
| } |
| |
| @Override |
| public void onHostSizeChanged(int width, int height) { |
| moveCursor((float)width / 2, (float)height / 2); |
| repositionImageWithZoom(); |
| } |
| |
| @Override |
| public void processAnimation() { |
| int previousX = mFlingScroller.getCurrX(); |
| int previousY = mFlingScroller.getCurrY(); |
| if (!mFlingScroller.computeScrollOffset()) { |
| mViewer.setAnimationEnabled(false); |
| return; |
| } |
| int deltaX = mFlingScroller.getCurrX() - previousX; |
| int deltaY = mFlingScroller.getCurrY() - previousY; |
| float delta[] = {(float)deltaX, (float)deltaY}; |
| synchronized (mRenderData) { |
| Matrix canvasToImage = new Matrix(); |
| mRenderData.transform.invert(canvasToImage); |
| canvasToImage.mapVectors(delta); |
| } |
| |
| moveCursor(mCursorPosition.x + delta[0], mCursorPosition.y + delta[1]); |
| } |
| |
| /** Responds to touch events filtered by the gesture detectors. */ |
| private class GestureListener extends GestureDetector.SimpleOnGestureListener |
| implements ScaleGestureDetector.OnScaleGestureListener, |
| TapGestureDetector.OnTapListener { |
| /** |
| * Called when the user drags one or more fingers across the touchscreen. |
| */ |
| @Override |
| public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) { |
| int pointerCount = e2.getPointerCount(); |
| if (pointerCount == 3 && !mSwipeCompleted) { |
| // Note that distance values are reversed. For example, dragging a finger in the |
| // direction of increasing Y coordinate (downwards) results in distanceY being |
| // negative. |
| mTotalMotionY -= distanceY; |
| return onSwipe(); |
| } |
| |
| if (pointerCount == 2 && mSwipePinchDetector.isSwiping()) { |
| mViewer.injectMouseWheelDeltaEvent(-(int)distanceX, -(int)distanceY); |
| |
| // Prevent the cursor being moved or flung by the gesture. |
| mSuppressCursorMovement = true; |
| return true; |
| } |
| |
| if (pointerCount != 1 || mSuppressCursorMovement) { |
| return false; |
| } |
| |
| float[] delta = {distanceX, distanceY}; |
| synchronized (mRenderData) { |
| Matrix canvasToImage = new Matrix(); |
| mRenderData.transform.invert(canvasToImage); |
| canvasToImage.mapVectors(delta); |
| } |
| |
| moveCursor(mCursorPosition.x - delta[0], mCursorPosition.y - delta[1]); |
| return true; |
| } |
| |
| /** |
| * Called when a fling gesture is recognized. |
| */ |
| @Override |
| public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) { |
| // If cursor movement is suppressed, fling also needs to be suppressed, as the |
| // gesture-detector will still generate onFling() notifications based on movement of |
| // the fingers, which would result in unwanted cursor movement. |
| if (mSuppressCursorMovement || mSuppressFling) { |
| return false; |
| } |
| |
| // The fling physics calculation is based on screen coordinates, so that it will |
| // behave consistently at different zoom levels (and will work nicely at high zoom |
| // levels, since |mFlingScroller| outputs integer coordinates). However, the desktop |
| // will usually be panned as the cursor is moved across the desktop, which means the |
| // transformation mapping from screen to desktop coordinates will change. To deal with |
| // this, the cursor movement is computed from relative coordinate changes from |
| // |mFlingScroller|. This means the fling can be started at (0, 0) with no bounding |
| // constraints - the cursor is already constrained by the desktop size. |
| mFlingScroller.fling(0, 0, (int)velocityX, (int)velocityY, Integer.MIN_VALUE, |
| Integer.MAX_VALUE, Integer.MIN_VALUE, Integer.MAX_VALUE); |
| // Initialize the scroller's current offset coordinates, since they are used for |
| // calculating the delta values. |
| mFlingScroller.computeScrollOffset(); |
| mViewer.setAnimationEnabled(true); |
| return true; |
| } |
| |
| /** Called when the user is in the process of pinch-zooming. */ |
| @Override |
| public boolean onScale(ScaleGestureDetector detector) { |
| if (!mSwipePinchDetector.isPinching()) { |
| return false; |
| } |
| |
| if (Math.abs(detector.getScaleFactor() - 1) < MIN_ZOOM_DELTA) { |
| return false; |
| } |
| |
| float scaleFactor = detector.getScaleFactor(); |
| synchronized (mRenderData) { |
| mRenderData.transform.postScale( |
| scaleFactor, scaleFactor, detector.getFocusX(), detector.getFocusY()); |
| } |
| repositionImageWithZoom(); |
| return true; |
| } |
| |
| /** Called whenever a gesture starts. Always accepts the gesture so it isn't ignored. */ |
| @Override |
| public boolean onDown(MotionEvent e) { |
| return true; |
| } |
| |
| /** |
| * Called when the user starts to zoom. Always accepts the zoom so that |
| * onScale() can decide whether to respond to it. |
| */ |
| @Override |
| public boolean onScaleBegin(ScaleGestureDetector detector) { |
| return true; |
| } |
| |
| /** Called when the user is done zooming. Defers to onScale()'s judgement. */ |
| @Override |
| public void onScaleEnd(ScaleGestureDetector detector) { |
| onScale(detector); |
| } |
| |
| /** Maps the number of fingers in a tap or long-press gesture to a mouse-button. */ |
| private int mouseButtonFromPointerCount(int pointerCount) { |
| switch (pointerCount) { |
| case 1: |
| return BUTTON_LEFT; |
| case 2: |
| return BUTTON_RIGHT; |
| case 3: |
| return BUTTON_MIDDLE; |
| default: |
| return BUTTON_UNDEFINED; |
| } |
| } |
| |
| /** Called when the user taps the screen with one or more fingers. */ |
| @Override |
| public boolean onTap(int pointerCount) { |
| int button = mouseButtonFromPointerCount(pointerCount); |
| if (button == BUTTON_UNDEFINED) { |
| return false; |
| } else { |
| injectButtonEvent(button, true); |
| injectButtonEvent(button, false); |
| return true; |
| } |
| } |
| |
| /** Called when a long-press is triggered for one or more fingers. */ |
| @Override |
| public void onLongPress(int pointerCount) { |
| mHeldButton = mouseButtonFromPointerCount(pointerCount); |
| if (mHeldButton != BUTTON_UNDEFINED) { |
| injectButtonEvent(mHeldButton, true); |
| mViewer.showLongPressFeedback(); |
| mSuppressFling = true; |
| } |
| } |
| } |
| } |