/* * Copyright (C) 2017 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 com.android.launcher3.touch; import static android.view.MotionEvent.INVALID_POINTER_ID; import android.content.Context; import android.graphics.PointF; import android.support.annotation.NonNull; import android.support.annotation.VisibleForTesting; import android.util.Log; import android.view.MotionEvent; import android.view.ViewConfiguration; import android.view.animation.Interpolator; /** * One dimensional scroll/drag/swipe gesture detector. * * Definition of swipe is different from android system in that this detector handles * 'swipe to dismiss', 'swiping up/down a container' but also keeps scrolling state before * swipe action happens */ public class SwipeDetector { private static final boolean DBG = false; private static final String TAG = "SwipeDetector"; private int mScrollConditions; public static final int DIRECTION_POSITIVE = 1 << 0; public static final int DIRECTION_NEGATIVE = 1 << 1; public static final int DIRECTION_BOTH = DIRECTION_NEGATIVE | DIRECTION_POSITIVE; private static final float ANIMATION_DURATION = 1200; private static final float FAST_FLING_PX_MS = 10; protected int mActivePointerId = INVALID_POINTER_ID; /** * The minimum release velocity in pixels per millisecond that triggers fling.. */ public static final float RELEASE_VELOCITY_PX_MS = 1.0f; /** * The time constant used to calculate dampening in the low-pass filter of scroll velocity. * Cutoff frequency is set at 10 Hz. */ public static final float SCROLL_VELOCITY_DAMPENING_RC = 1000f / (2f * (float) Math.PI * 10); /* Scroll state, this is set to true during dragging and animation. */ private ScrollState mState = ScrollState.IDLE; enum ScrollState { IDLE, DRAGGING, // onDragStart, onDrag SETTLING // onDragEnd } public static abstract class Direction { abstract float getDisplacement(MotionEvent ev, int pointerIndex, PointF refPoint); /** * Distance in pixels a touch can wander before we think the user is scrolling. */ abstract float getActiveTouchSlop(MotionEvent ev, int pointerIndex, PointF downPos); } public static final Direction VERTICAL = new Direction() { @Override float getDisplacement(MotionEvent ev, int pointerIndex, PointF refPoint) { return ev.getY(pointerIndex) - refPoint.y; } @Override float getActiveTouchSlop(MotionEvent ev, int pointerIndex, PointF downPos) { return Math.abs(ev.getX(pointerIndex) - downPos.x); } }; public static final Direction HORIZONTAL = new Direction() { @Override float getDisplacement(MotionEvent ev, int pointerIndex, PointF refPoint) { return ev.getX(pointerIndex) - refPoint.x; } @Override float getActiveTouchSlop(MotionEvent ev, int pointerIndex, PointF downPos) { return Math.abs(ev.getY(pointerIndex) - downPos.y); } }; //------------------- ScrollState transition diagram ----------------------------------- // // IDLE -> (mDisplacement > mTouchSlop) -> DRAGGING // DRAGGING -> (MotionEvent#ACTION_UP, MotionEvent#ACTION_CANCEL) -> SETTLING // SETTLING -> (MotionEvent#ACTION_DOWN) -> DRAGGING // SETTLING -> (View settled) -> IDLE private void setState(ScrollState newState) { if (DBG) { Log.d(TAG, "setState:" + mState + "->" + newState); } // onDragStart and onDragEnd is reported ONLY on state transition if (newState == ScrollState.DRAGGING) { initializeDragging(); if (mState == ScrollState.IDLE) { reportDragStart(false /* recatch */); } else if (mState == ScrollState.SETTLING) { reportDragStart(true /* recatch */); } } if (newState == ScrollState.SETTLING) { reportDragEnd(); } mState = newState; } public boolean isDraggingOrSettling() { return mState == ScrollState.DRAGGING || mState == ScrollState.SETTLING; } /** * There's no touch and there's no animation. */ public boolean isIdleState() { return mState == ScrollState.IDLE; } public boolean isSettlingState() { return mState == ScrollState.SETTLING; } public boolean isDraggingState() { return mState == ScrollState.DRAGGING; } private final PointF mDownPos = new PointF(); private final PointF mLastPos = new PointF(); private final Direction mDir; private final float mTouchSlop; /* Client of this gesture detector can register a callback. */ private final Listener mListener; private long mCurrentMillis; private float mVelocity; private float mLastDisplacement; private float mDisplacement; private float mSubtractDisplacement; private boolean mIgnoreSlopWhenSettling; public interface Listener { void onDragStart(boolean start); boolean onDrag(float displacement, float velocity); void onDragEnd(float velocity, boolean fling); } public SwipeDetector(@NonNull Context context, @NonNull Listener l, @NonNull Direction dir) { this(ViewConfiguration.get(context).getScaledTouchSlop(), l, dir); } @VisibleForTesting protected SwipeDetector(float touchSlope, @NonNull Listener l, @NonNull Direction dir) { mTouchSlop = touchSlope; mListener = l; mDir = dir; } public void setDetectableScrollConditions(int scrollDirectionFlags, boolean ignoreSlop) { mScrollConditions = scrollDirectionFlags; mIgnoreSlopWhenSettling = ignoreSlop; } private boolean shouldScrollStart(MotionEvent ev, int pointerIndex) { // reject cases where the angle or slop condition is not met. if (Math.max(mDir.getActiveTouchSlop(ev, pointerIndex, mDownPos), mTouchSlop) > Math.abs(mDisplacement)) { return false; } // Check if the client is interested in scroll in current direction. if (((mScrollConditions & DIRECTION_NEGATIVE) > 0 && mDisplacement > 0) || ((mScrollConditions & DIRECTION_POSITIVE) > 0 && mDisplacement < 0)) { return true; } return false; } public boolean onTouchEvent(MotionEvent ev) { switch (ev.getActionMasked()) { case MotionEvent.ACTION_DOWN: mActivePointerId = ev.getPointerId(0); mDownPos.set(ev.getX(), ev.getY()); mLastPos.set(mDownPos); mLastDisplacement = 0; mDisplacement = 0; mVelocity = 0; if (mState == ScrollState.SETTLING && mIgnoreSlopWhenSettling) { setState(ScrollState.DRAGGING); } break; //case MotionEvent.ACTION_POINTER_DOWN: case MotionEvent.ACTION_POINTER_UP: int ptrIdx = ev.getActionIndex(); int ptrId = ev.getPointerId(ptrIdx); if (ptrId == mActivePointerId) { final int newPointerIdx = ptrIdx == 0 ? 1 : 0; mDownPos.set( ev.getX(newPointerIdx) - (mLastPos.x - mDownPos.x), ev.getY(newPointerIdx) - (mLastPos.y - mDownPos.y)); mLastPos.set(ev.getX(newPointerIdx), ev.getY(newPointerIdx)); mActivePointerId = ev.getPointerId(newPointerIdx); } break; case MotionEvent.ACTION_MOVE: int pointerIndex = ev.findPointerIndex(mActivePointerId); if (pointerIndex == INVALID_POINTER_ID) { break; } mDisplacement = mDir.getDisplacement(ev, pointerIndex, mDownPos); computeVelocity(mDir.getDisplacement(ev, pointerIndex, mLastPos), ev.getEventTime()); // handle state and listener calls. if (mState != ScrollState.DRAGGING && shouldScrollStart(ev, pointerIndex)) { setState(ScrollState.DRAGGING); } if (mState == ScrollState.DRAGGING) { reportDragging(); } mLastPos.set(ev.getX(pointerIndex), ev.getY(pointerIndex)); break; case MotionEvent.ACTION_CANCEL: case MotionEvent.ACTION_UP: // These are synthetic events and there is no need to update internal values. if (mState == ScrollState.DRAGGING) { setState(ScrollState.SETTLING); } break; default: break; } return true; } public void finishedScrolling() { setState(ScrollState.IDLE); } private boolean reportDragStart(boolean recatch) { mListener.onDragStart(!recatch); if (DBG) { Log.d(TAG, "onDragStart recatch:" + recatch); } return true; } private void initializeDragging() { if (mState == ScrollState.SETTLING && mIgnoreSlopWhenSettling) { mSubtractDisplacement = 0; } if (mDisplacement > 0) { mSubtractDisplacement = mTouchSlop; } else { mSubtractDisplacement = -mTouchSlop; } } private boolean reportDragging() { if (mDisplacement != mLastDisplacement) { if (DBG) { Log.d(TAG, String.format("onDrag disp=%.1f, velocity=%.1f", mDisplacement, mVelocity)); } mLastDisplacement = mDisplacement; return mListener.onDrag(mDisplacement - mSubtractDisplacement, mVelocity); } return true; } private void reportDragEnd() { if (DBG) { Log.d(TAG, String.format("onScrollEnd disp=%.1f, velocity=%.1f", mDisplacement, mVelocity)); } mListener.onDragEnd(mVelocity, Math.abs(mVelocity) > RELEASE_VELOCITY_PX_MS); } /** * Computes the damped velocity. */ public float computeVelocity(float delta, long currentMillis) { long previousMillis = mCurrentMillis; mCurrentMillis = currentMillis; float deltaTimeMillis = mCurrentMillis - previousMillis; float velocity = (deltaTimeMillis > 0) ? (delta / deltaTimeMillis) : 0; if (Math.abs(mVelocity) < 0.001f) { mVelocity = velocity; } else { float alpha = computeDampeningFactor(deltaTimeMillis); mVelocity = interpolate(mVelocity, velocity, alpha); } return mVelocity; } /** * Returns a time-dependent dampening factor using delta time. */ private static float computeDampeningFactor(float deltaTime) { return deltaTime / (SCROLL_VELOCITY_DAMPENING_RC + deltaTime); } /** * Returns the linear interpolation between two values */ private static float interpolate(float from, float to, float alpha) { return (1.0f - alpha) * from + alpha * to; } public static long calculateDuration(float velocity, float progressNeeded) { // TODO: make these values constants after tuning. float velocityDivisor = Math.max(2f, Math.abs(0.5f * velocity)); float travelDistance = Math.max(0.2f, progressNeeded); long duration = (long) Math.max(100, ANIMATION_DURATION / velocityDivisor * travelDistance); if (DBG) { Log.d(TAG, String.format("calculateDuration=%d, v=%f, d=%f", duration, velocity, progressNeeded)); } return duration; } public static class ScrollInterpolator implements Interpolator { boolean mSteeper; public void setVelocityAtZero(float velocity) { mSteeper = velocity > FAST_FLING_PX_MS; } public float getInterpolation(float t) { t -= 1.0f; float output = t * t * t; if (mSteeper) { output *= t * t; // Make interpolation initial slope steeper } return output + 1; } } }