/* * Copyright (C) 2019 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package android.widget; import static android.widget.Editor.logCursor; import static com.android.internal.annotations.VisibleForTesting.Visibility.PACKAGE; import android.annotation.IntDef; import android.view.InputDevice; import android.view.MotionEvent; import android.view.ViewConfiguration; import com.android.internal.annotations.VisibleForTesting; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; /** * Helper class used by {@link Editor} to track state for touch events. Ideally the logic here * should be replaced with {@link android.view.GestureDetector}. * * @hide */ @VisibleForTesting(visibility = PACKAGE) public class EditorTouchState { private float mLastDownX, mLastDownY; private long mLastDownMillis; private float mLastUpX, mLastUpY; private long mLastUpMillis; private boolean mIsOnHandle; @IntDef({MultiTapStatus.NONE, MultiTapStatus.FIRST_TAP, MultiTapStatus.DOUBLE_TAP, MultiTapStatus.TRIPLE_CLICK}) @Retention(RetentionPolicy.SOURCE) @VisibleForTesting public @interface MultiTapStatus { int NONE = 0; int FIRST_TAP = 1; int DOUBLE_TAP = 2; int TRIPLE_CLICK = 3; // Only for mouse input. } @MultiTapStatus private int mMultiTapStatus = MultiTapStatus.NONE; private boolean mMultiTapInSameArea; private boolean mMovedEnoughForDrag; private float mInitialDragDirectionXYRatio; public float getLastDownX() { return mLastDownX; } public float getLastDownY() { return mLastDownY; } public float getLastUpX() { return mLastUpX; } public float getLastUpY() { return mLastUpY; } public boolean isDoubleTap() { return mMultiTapStatus == MultiTapStatus.DOUBLE_TAP; } public boolean isTripleClick() { return mMultiTapStatus == MultiTapStatus.TRIPLE_CLICK; } public boolean isMultiTap() { return mMultiTapStatus == MultiTapStatus.DOUBLE_TAP || mMultiTapStatus == MultiTapStatus.TRIPLE_CLICK; } public boolean isMultiTapInSameArea() { return isMultiTap() && mMultiTapInSameArea; } public boolean isMovedEnoughForDrag() { return mMovedEnoughForDrag; } /** * When {@link #isMovedEnoughForDrag()} is {@code true}, this function returns the x/y ratio for * the initial drag direction. Smaller values indicate that the direction is closer to vertical, * while larger values indicate that the direction is closer to horizontal. For example: * * This function never returns negative values, regardless of the direction of the drag. */ public float getInitialDragDirectionXYRatio() { return mInitialDragDirectionXYRatio; } public void setIsOnHandle(boolean onHandle) { mIsOnHandle = onHandle; } public boolean isOnHandle() { return mIsOnHandle; } /** * Updates the state based on the new event. */ public void update(MotionEvent event, ViewConfiguration config) { final int action = event.getActionMasked(); if (action == MotionEvent.ACTION_DOWN) { final boolean isMouse = event.isFromSource(InputDevice.SOURCE_MOUSE); // We check both the time between the last up and current down event, as well as the // time between the first down and up events. The latter check is necessary to handle // the case when the user taps, drags/holds for some time, and then lifts up and // quickly taps in the same area. This scenario should not be treated as a double-tap. // This follows the behavior in GestureDetector. final long millisSinceLastUp = event.getEventTime() - mLastUpMillis; final long millisBetweenLastDownAndLastUp = mLastUpMillis - mLastDownMillis; // Detect double tap and triple click. if (millisSinceLastUp <= ViewConfiguration.getDoubleTapTimeout() && millisBetweenLastDownAndLastUp <= ViewConfiguration.getDoubleTapTimeout() && (mMultiTapStatus == MultiTapStatus.FIRST_TAP || (mMultiTapStatus == MultiTapStatus.DOUBLE_TAP && isMouse))) { if (mMultiTapStatus == MultiTapStatus.FIRST_TAP) { mMultiTapStatus = MultiTapStatus.DOUBLE_TAP; } else { mMultiTapStatus = MultiTapStatus.TRIPLE_CLICK; } mMultiTapInSameArea = isDistanceWithin(mLastDownX, mLastDownY, event.getX(), event.getY(), config.getScaledDoubleTapSlop()); if (TextView.DEBUG_CURSOR) { String status = isDoubleTap() ? "double" : "triple"; String inSameArea = mMultiTapInSameArea ? "in same area" : "not in same area"; logCursor("EditorTouchState", "ACTION_DOWN: %s tap detected, %s", status, inSameArea); } } else { mMultiTapStatus = MultiTapStatus.FIRST_TAP; mMultiTapInSameArea = false; if (TextView.DEBUG_CURSOR) { logCursor("EditorTouchState", "ACTION_DOWN: first tap detected"); } } mLastDownX = event.getX(); mLastDownY = event.getY(); mLastDownMillis = event.getEventTime(); mMovedEnoughForDrag = false; mInitialDragDirectionXYRatio = 0.0f; } else if (action == MotionEvent.ACTION_UP) { if (TextView.DEBUG_CURSOR) { logCursor("EditorTouchState", "ACTION_UP"); } mLastUpX = event.getX(); mLastUpY = event.getY(); mLastUpMillis = event.getEventTime(); mMovedEnoughForDrag = false; mInitialDragDirectionXYRatio = 0.0f; } else if (action == MotionEvent.ACTION_MOVE) { if (!mMovedEnoughForDrag) { float deltaX = event.getX() - mLastDownX; float deltaY = event.getY() - mLastDownY; float deltaXSquared = deltaX * deltaX; float distanceSquared = (deltaXSquared) + (deltaY * deltaY); int touchSlop = config.getScaledTouchSlop(); mMovedEnoughForDrag = distanceSquared > touchSlop * touchSlop; if (mMovedEnoughForDrag) { mInitialDragDirectionXYRatio = (deltaY == 0) ? Float.MAX_VALUE : Math.abs(deltaX / deltaY); } } } else if (action == MotionEvent.ACTION_CANCEL) { mLastDownMillis = 0; mLastUpMillis = 0; mMultiTapStatus = MultiTapStatus.NONE; mMultiTapInSameArea = false; mMovedEnoughForDrag = false; mInitialDragDirectionXYRatio = 0.0f; } } /** * Returns true if the distance between the given coordinates is <= to the specified max. * This is useful to be able to determine e.g. when the user's touch has moved enough in * order to be considered a drag (no longer within touch slop). */ public static boolean isDistanceWithin(float x1, float y1, float x2, float y2, int maxDistance) { float deltaX = x2 - x1; float deltaY = y2 - y1; float distanceSquared = (deltaX * deltaX) + (deltaY * deltaY); return distanceSquared <= maxDistance * maxDistance; } /** * Returns the x/y ratio corresponding to the given angle relative to vertical. Smaller angle * values (ie, closer to vertical) will result in a smaller x/y ratio. For example: * * If the passed-in value is <= 0, this function returns 0. If the passed-in value is >= 90, * this function returns {@link Float#MAX_VALUE}. * * @see #getInitialDragDirectionXYRatio() */ public static float getXYRatio(int angleFromVerticalInDegrees) { if (angleFromVerticalInDegrees <= 0) { return 0.0f; } if (angleFromVerticalInDegrees >= 90) { return Float.MAX_VALUE; } return (float) Math.tan(Math.toRadians(angleFromVerticalInDegrees)); } }