blob: 5c50b5ce47d9a32055156f2d6f4cdc54f43222ca [file] [log] [blame]
/*
* Copyright (C) 2011 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.systemui;
import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.animation.ObjectAnimator;
import android.animation.ValueAnimator;
import android.animation.ValueAnimator.AnimatorUpdateListener;
import android.content.Context;
import android.graphics.RectF;
import android.os.Handler;
import android.util.Log;
import android.view.MotionEvent;
import android.view.VelocityTracker;
import android.view.View;
import android.view.ViewConfiguration;
import android.view.accessibility.AccessibilityEvent;
import com.android.systemui.classifier.FalsingManager;
import com.android.systemui.statusbar.FlingAnimationUtils;
import java.util.HashMap;
public class SwipeHelper implements Gefingerpoken {
static final String TAG = "com.android.systemui.SwipeHelper";
private static final boolean DEBUG = false;
private static final boolean DEBUG_INVALIDATE = false;
private static final boolean SLOW_ANIMATIONS = false; // DEBUG;
private static final boolean CONSTRAIN_SWIPE = true;
private static final boolean FADE_OUT_DURING_SWIPE = true;
private static final boolean DISMISS_IF_SWIPED_FAR_ENOUGH = true;
public static final int X = 0;
public static final int Y = 1;
private float SWIPE_ESCAPE_VELOCITY = 100f; // dp/sec
private int DEFAULT_ESCAPE_ANIMATION_DURATION = 200; // ms
private int MAX_ESCAPE_ANIMATION_DURATION = 400; // ms
private int MAX_DISMISS_VELOCITY = 4000; // dp/sec
private static final int SNAP_ANIM_LEN = SLOW_ANIMATIONS ? 1000 : 150; // ms
static final float SWIPE_PROGRESS_FADE_END = 0.5f; // fraction of thumbnail width
// beyond which swipe progress->0
private float mMinSwipeProgress = 0f;
private float mMaxSwipeProgress = 1f;
private FlingAnimationUtils mFlingAnimationUtils;
private float mPagingTouchSlop;
private Callback mCallback;
private Handler mHandler;
private int mSwipeDirection;
private VelocityTracker mVelocityTracker;
private FalsingManager mFalsingManager;
private float mInitialTouchPos;
private float mPerpendicularInitialTouchPos;
private boolean mDragging;
private boolean mSnappingChild;
private View mCurrView;
private boolean mCanCurrViewBeDimissed;
private float mDensityScale;
private float mTranslation = 0;
private boolean mLongPressSent;
private LongPressListener mLongPressListener;
private Runnable mWatchLongPress;
private long mLongPressTimeout;
final private int[] mTmpPos = new int[2];
private int mFalsingThreshold;
private boolean mTouchAboveFalsingThreshold;
private boolean mDisableHwLayers;
private HashMap<View, Animator> mDismissPendingMap = new HashMap<>();
public SwipeHelper(int swipeDirection, Callback callback, Context context) {
mCallback = callback;
mHandler = new Handler();
mSwipeDirection = swipeDirection;
mVelocityTracker = VelocityTracker.obtain();
mDensityScale = context.getResources().getDisplayMetrics().density;
mPagingTouchSlop = ViewConfiguration.get(context).getScaledPagingTouchSlop();
mLongPressTimeout = (long) (ViewConfiguration.getLongPressTimeout() * 1.5f); // extra long-press!
mFalsingThreshold = context.getResources().getDimensionPixelSize(
R.dimen.swipe_helper_falsing_threshold);
mFalsingManager = FalsingManager.getInstance(context);
mFlingAnimationUtils = new FlingAnimationUtils(context, getMaxEscapeAnimDuration() / 1000f);
}
public void setLongPressListener(LongPressListener listener) {
mLongPressListener = listener;
}
public void setDensityScale(float densityScale) {
mDensityScale = densityScale;
}
public void setPagingTouchSlop(float pagingTouchSlop) {
mPagingTouchSlop = pagingTouchSlop;
}
public void setDisableHardwareLayers(boolean disableHwLayers) {
mDisableHwLayers = disableHwLayers;
}
private float getPos(MotionEvent ev) {
return mSwipeDirection == X ? ev.getX() : ev.getY();
}
private float getPerpendicularPos(MotionEvent ev) {
return mSwipeDirection == X ? ev.getY() : ev.getX();
}
protected float getTranslation(View v) {
return mSwipeDirection == X ? v.getTranslationX() : v.getTranslationY();
}
private float getVelocity(VelocityTracker vt) {
return mSwipeDirection == X ? vt.getXVelocity() :
vt.getYVelocity();
}
protected ObjectAnimator createTranslationAnimation(View v, float newPos) {
ObjectAnimator anim = ObjectAnimator.ofFloat(v,
mSwipeDirection == X ? View.TRANSLATION_X : View.TRANSLATION_Y, newPos);
return anim;
}
private float getPerpendicularVelocity(VelocityTracker vt) {
return mSwipeDirection == X ? vt.getYVelocity() :
vt.getXVelocity();
}
protected Animator getViewTranslationAnimator(View v, float target,
AnimatorUpdateListener listener) {
ObjectAnimator anim = createTranslationAnimation(v, target);
if (listener != null) {
anim.addUpdateListener(listener);
}
return anim;
}
protected void setTranslation(View v, float translate) {
if (v == null) {
return;
}
if (mSwipeDirection == X) {
v.setTranslationX(translate);
} else {
v.setTranslationY(translate);
}
}
protected float getSize(View v) {
return mSwipeDirection == X ? v.getMeasuredWidth() :
v.getMeasuredHeight();
}
public void setMinSwipeProgress(float minSwipeProgress) {
mMinSwipeProgress = minSwipeProgress;
}
public void setMaxSwipeProgress(float maxSwipeProgress) {
mMaxSwipeProgress = maxSwipeProgress;
}
private float getSwipeProgressForOffset(View view, float translation) {
float viewSize = getSize(view);
float result = Math.abs(translation / viewSize);
return Math.min(Math.max(mMinSwipeProgress, result), mMaxSwipeProgress);
}
private float getSwipeAlpha(float progress) {
return Math.min(0, Math.max(1, progress / SWIPE_PROGRESS_FADE_END));
}
private void updateSwipeProgressFromOffset(View animView, boolean dismissable) {
updateSwipeProgressFromOffset(animView, dismissable, getTranslation(animView));
}
private void updateSwipeProgressFromOffset(View animView, boolean dismissable,
float translation) {
float swipeProgress = getSwipeProgressForOffset(animView, translation);
if (!mCallback.updateSwipeProgress(animView, dismissable, swipeProgress)) {
if (FADE_OUT_DURING_SWIPE && dismissable) {
float alpha = swipeProgress;
if (!mDisableHwLayers) {
if (alpha != 0f && alpha != 1f) {
animView.setLayerType(View.LAYER_TYPE_HARDWARE, null);
} else {
animView.setLayerType(View.LAYER_TYPE_NONE, null);
}
}
animView.setAlpha(getSwipeAlpha(swipeProgress));
}
}
invalidateGlobalRegion(animView);
}
// invalidate the view's own bounds all the way up the view hierarchy
public static void invalidateGlobalRegion(View view) {
invalidateGlobalRegion(
view,
new RectF(view.getLeft(), view.getTop(), view.getRight(), view.getBottom()));
}
// invalidate a rectangle relative to the view's coordinate system all the way up the view
// hierarchy
public static void invalidateGlobalRegion(View view, RectF childBounds) {
//childBounds.offset(view.getTranslationX(), view.getTranslationY());
if (DEBUG_INVALIDATE)
Log.v(TAG, "-------------");
while (view.getParent() != null && view.getParent() instanceof View) {
view = (View) view.getParent();
view.getMatrix().mapRect(childBounds);
view.invalidate((int) Math.floor(childBounds.left),
(int) Math.floor(childBounds.top),
(int) Math.ceil(childBounds.right),
(int) Math.ceil(childBounds.bottom));
if (DEBUG_INVALIDATE) {
Log.v(TAG, "INVALIDATE(" + (int) Math.floor(childBounds.left)
+ "," + (int) Math.floor(childBounds.top)
+ "," + (int) Math.ceil(childBounds.right)
+ "," + (int) Math.ceil(childBounds.bottom));
}
}
}
public void removeLongPressCallback() {
if (mWatchLongPress != null) {
mHandler.removeCallbacks(mWatchLongPress);
mWatchLongPress = null;
}
}
public boolean onInterceptTouchEvent(final MotionEvent ev) {
final int action = ev.getAction();
switch (action) {
case MotionEvent.ACTION_DOWN:
mTouchAboveFalsingThreshold = false;
mDragging = false;
mSnappingChild = false;
mLongPressSent = false;
mVelocityTracker.clear();
mCurrView = mCallback.getChildAtPosition(ev);
if (mCurrView != null) {
onDownUpdate(mCurrView);
mCanCurrViewBeDimissed = mCallback.canChildBeDismissed(mCurrView);
mVelocityTracker.addMovement(ev);
mInitialTouchPos = getPos(ev);
mPerpendicularInitialTouchPos = getPerpendicularPos(ev);
mTranslation = getTranslation(mCurrView);
if (mLongPressListener != null) {
if (mWatchLongPress == null) {
mWatchLongPress = new Runnable() {
@Override
public void run() {
if (mCurrView != null && !mLongPressSent) {
mLongPressSent = true;
mCurrView.sendAccessibilityEvent(
AccessibilityEvent.TYPE_VIEW_LONG_CLICKED);
mCurrView.getLocationOnScreen(mTmpPos);
final int x = (int) ev.getRawX() - mTmpPos[0];
final int y = (int) ev.getRawY() - mTmpPos[1];
mLongPressListener.onLongPress(mCurrView, x, y);
}
}
};
}
mHandler.postDelayed(mWatchLongPress, mLongPressTimeout);
}
}
break;
case MotionEvent.ACTION_MOVE:
if (mCurrView != null && !mLongPressSent) {
mVelocityTracker.addMovement(ev);
float pos = getPos(ev);
float perpendicularPos = getPerpendicularPos(ev);
float delta = pos - mInitialTouchPos;
float deltaPerpendicular = perpendicularPos - mPerpendicularInitialTouchPos;
if (Math.abs(delta) > mPagingTouchSlop
&& Math.abs(delta) > Math.abs(deltaPerpendicular)) {
mCallback.onBeginDrag(mCurrView);
mDragging = true;
mInitialTouchPos = getPos(ev);
mTranslation = getTranslation(mCurrView);
removeLongPressCallback();
}
}
break;
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
final boolean captured = (mDragging || mLongPressSent);
mDragging = false;
mCurrView = null;
mLongPressSent = false;
removeLongPressCallback();
if (captured) return true;
break;
}
return mDragging || mLongPressSent;
}
/**
* @param view The view to be dismissed
* @param velocity The desired pixels/second speed at which the view should move
* @param useAccelerateInterpolator Should an accelerating Interpolator be used
*/
public void dismissChild(final View view, float velocity, boolean useAccelerateInterpolator) {
dismissChild(view, velocity, null /* endAction */, 0 /* delay */,
useAccelerateInterpolator, 0 /* fixedDuration */, false /* isDismissAll */);
}
/**
* @param view The view to be dismissed
* @param velocity The desired pixels/second speed at which the view should move
* @param endAction The action to perform at the end
* @param delay The delay after which we should start
* @param useAccelerateInterpolator Should an accelerating Interpolator be used
* @param fixedDuration If not 0, this exact duration will be taken
*/
public void dismissChild(final View animView, float velocity, final Runnable endAction,
long delay, boolean useAccelerateInterpolator, long fixedDuration,
boolean isDismissAll) {
final boolean canBeDismissed = mCallback.canChildBeDismissed(animView);
float newPos;
boolean isLayoutRtl = animView.getLayoutDirection() == View.LAYOUT_DIRECTION_RTL;
// if we use the Menu to dismiss an item in landscape, animate up
boolean animateUpForMenu = velocity == 0 && (getTranslation(animView) == 0 || isDismissAll)
&& mSwipeDirection == Y;
// if the language is rtl we prefer swiping to the left
boolean animateLeftForRtl = velocity == 0 && (getTranslation(animView) == 0 || isDismissAll)
&& isLayoutRtl;
boolean animateLeft = velocity < 0
|| (velocity == 0 && getTranslation(animView) < 0 && !isDismissAll);
if (animateLeft || animateLeftForRtl || animateUpForMenu) {
newPos = -getSize(animView);
} else {
newPos = getSize(animView);
}
long duration;
if (fixedDuration == 0) {
duration = MAX_ESCAPE_ANIMATION_DURATION;
if (velocity != 0) {
duration = Math.min(duration,
(int) (Math.abs(newPos - getTranslation(animView)) * 1000f / Math
.abs(velocity))
);
} else {
duration = DEFAULT_ESCAPE_ANIMATION_DURATION;
}
} else {
duration = fixedDuration;
}
if (!mDisableHwLayers) {
animView.setLayerType(View.LAYER_TYPE_HARDWARE, null);
}
AnimatorUpdateListener updateListener = new AnimatorUpdateListener() {
public void onAnimationUpdate(ValueAnimator animation) {
onTranslationUpdate(animView, (float) animation.getAnimatedValue(), canBeDismissed);
}
};
Animator anim = getViewTranslationAnimator(animView, newPos, updateListener);
if (anim == null) {
return;
}
if (useAccelerateInterpolator) {
anim.setInterpolator(Interpolators.FAST_OUT_LINEAR_IN);
anim.setDuration(duration);
} else {
mFlingAnimationUtils.applyDismissing(anim, getTranslation(animView),
newPos, velocity, getSize(animView));
}
if (delay > 0) {
anim.setStartDelay(delay);
}
anim.addListener(new AnimatorListenerAdapter() {
private boolean mCancelled;
public void onAnimationCancel(Animator animation) {
mCancelled = true;
}
public void onAnimationEnd(Animator animation) {
updateSwipeProgressFromOffset(animView, canBeDismissed);
mDismissPendingMap.remove(animView);
if (!mCancelled) {
mCallback.onChildDismissed(animView);
}
if (endAction != null) {
endAction.run();
}
if (!mDisableHwLayers) {
animView.setLayerType(View.LAYER_TYPE_NONE, null);
}
}
});
prepareDismissAnimation(animView, anim);
mDismissPendingMap.put(animView, anim);
anim.start();
}
/**
* Called to update the dismiss animation.
*/
protected void prepareDismissAnimation(View view, Animator anim) {
// Do nothing
}
public void snapChild(final View animView, final float targetLeft, float velocity) {
final boolean canBeDismissed = mCallback.canChildBeDismissed(animView);
AnimatorUpdateListener updateListener = new AnimatorUpdateListener() {
public void onAnimationUpdate(ValueAnimator animation) {
onTranslationUpdate(animView, (float) animation.getAnimatedValue(), canBeDismissed);
}
};
Animator anim = getViewTranslationAnimator(animView, targetLeft, updateListener);
if (anim == null) {
return;
}
int duration = SNAP_ANIM_LEN;
anim.setDuration(duration);
anim.addListener(new AnimatorListenerAdapter() {
public void onAnimationEnd(Animator animator) {
mSnappingChild = false;
updateSwipeProgressFromOffset(animView, canBeDismissed);
mCallback.onChildSnappedBack(animView, targetLeft);
}
});
prepareSnapBackAnimation(animView, anim);
mSnappingChild = true;
anim.start();
}
/**
* Called to update the snap back animation.
*/
protected void prepareSnapBackAnimation(View view, Animator anim) {
// Do nothing
}
/**
* Called when there's a down event.
*/
public void onDownUpdate(View currView) {
// Do nothing
}
/**
* Called on a move event.
*/
protected void onMoveUpdate(View view, float totalTranslation, float delta) {
// Do nothing
}
/**
* Called in {@link AnimatorUpdateListener#onAnimationUpdate(ValueAnimator)} when the current
* view is being animated to dismiss or snap.
*/
public void onTranslationUpdate(View animView, float value, boolean canBeDismissed) {
updateSwipeProgressFromOffset(animView, canBeDismissed, value);
}
private void snapChildInstantly(final View view) {
final boolean canAnimViewBeDismissed = mCallback.canChildBeDismissed(view);
setTranslation(view, 0);
updateSwipeProgressFromOffset(view, canAnimViewBeDismissed);
}
/**
* Called when a view is updated to be non-dismissable, if the view was being dismissed before
* the update this will handle snapping it back into place.
*
* @param view the view to snap if necessary.
* @param animate whether to animate the snap or not.
* @param targetLeft the target to snap to.
*/
public void snapChildIfNeeded(final View view, boolean animate, float targetLeft) {
if ((mDragging && mCurrView == view) || mSnappingChild) {
return;
}
boolean needToSnap = false;
Animator dismissPendingAnim = mDismissPendingMap.get(view);
if (dismissPendingAnim != null) {
needToSnap = true;
dismissPendingAnim.cancel();
} else if (getTranslation(view) != 0) {
needToSnap = true;
}
if (needToSnap) {
if (animate) {
snapChild(view, targetLeft, 0.0f /* velocity */);
} else {
snapChildInstantly(view);
}
}
}
public boolean onTouchEvent(MotionEvent ev) {
if (mLongPressSent) {
return true;
}
if (!mDragging) {
if (mCallback.getChildAtPosition(ev) != null) {
// We are dragging directly over a card, make sure that we also catch the gesture
// even if nobody else wants the touch event.
onInterceptTouchEvent(ev);
return true;
} else {
// We are not doing anything, make sure the long press callback
// is not still ticking like a bomb waiting to go off.
removeLongPressCallback();
return false;
}
}
mVelocityTracker.addMovement(ev);
final int action = ev.getAction();
switch (action) {
case MotionEvent.ACTION_OUTSIDE:
case MotionEvent.ACTION_MOVE:
if (mCurrView != null) {
float delta = getPos(ev) - mInitialTouchPos;
float absDelta = Math.abs(delta);
if (absDelta >= getFalsingThreshold()) {
mTouchAboveFalsingThreshold = true;
}
// don't let items that can't be dismissed be dragged more than
// maxScrollDistance
if (CONSTRAIN_SWIPE && !mCallback.canChildBeDismissed(mCurrView)) {
float size = getSize(mCurrView);
float maxScrollDistance = 0.25f * size;
if (absDelta >= size) {
delta = delta > 0 ? maxScrollDistance : -maxScrollDistance;
} else {
delta = maxScrollDistance * (float) Math.sin((delta/size)*(Math.PI/2));
}
}
setTranslation(mCurrView, mTranslation + delta);
updateSwipeProgressFromOffset(mCurrView, mCanCurrViewBeDimissed);
onMoveUpdate(mCurrView, mTranslation + delta, delta);
}
break;
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
if (mCurrView == null) {
break;
}
mVelocityTracker.computeCurrentVelocity(1000 /* px/sec */, getMaxVelocity());
float velocity = getVelocity(mVelocityTracker);
if (!handleUpEvent(ev, mCurrView, velocity, getTranslation(mCurrView))) {
if (isDismissGesture(ev)) {
// flingadingy
dismissChild(mCurrView, velocity,
!swipedFastEnough() /* useAccelerateInterpolator */);
} else {
// snappity
mCallback.onDragCancelled(mCurrView);
snapChild(mCurrView, 0 /* leftTarget */, velocity);
}
mCurrView = null;
}
mDragging = false;
break;
}
return true;
}
private int getFalsingThreshold() {
float factor = mCallback.getFalsingThresholdFactor();
return (int) (mFalsingThreshold * factor);
}
private float getMaxVelocity() {
return MAX_DISMISS_VELOCITY * mDensityScale;
}
protected float getEscapeVelocity() {
return getUnscaledEscapeVelocity() * mDensityScale;
}
protected float getUnscaledEscapeVelocity() {
return SWIPE_ESCAPE_VELOCITY;
}
protected long getMaxEscapeAnimDuration() {
return MAX_ESCAPE_ANIMATION_DURATION;
}
protected boolean swipedFarEnough() {
float translation = getTranslation(mCurrView);
return DISMISS_IF_SWIPED_FAR_ENOUGH && Math.abs(translation) > 0.4 * getSize(mCurrView);
}
protected boolean isDismissGesture(MotionEvent ev) {
boolean falsingDetected = mCallback.isAntiFalsingNeeded();
if (mFalsingManager.isClassiferEnabled()) {
falsingDetected = falsingDetected && mFalsingManager.isFalseTouch();
} else {
falsingDetected = falsingDetected && !mTouchAboveFalsingThreshold;
}
return !falsingDetected && (swipedFastEnough() || swipedFarEnough())
&& ev.getActionMasked() == MotionEvent.ACTION_UP
&& mCallback.canChildBeDismissed(mCurrView);
}
protected boolean swipedFastEnough() {
float velocity = getVelocity(mVelocityTracker);
float translation = getTranslation(mCurrView);
boolean ret = (Math.abs(velocity) > getEscapeVelocity())
&& (velocity > 0) == (translation > 0);
return ret;
}
protected boolean handleUpEvent(MotionEvent ev, View animView, float velocity,
float translation) {
return false;
}
public interface Callback {
View getChildAtPosition(MotionEvent ev);
boolean canChildBeDismissed(View v);
boolean isAntiFalsingNeeded();
void onBeginDrag(View v);
void onChildDismissed(View v);
void onDragCancelled(View v);
/**
* Called when the child is snapped to a position.
*
* @param animView the view that was snapped.
* @param targetLeft the left position the view was snapped to.
*/
void onChildSnappedBack(View animView, float targetLeft);
/**
* Updates the swipe progress on a child.
*
* @return if true, prevents the default alpha fading.
*/
boolean updateSwipeProgress(View animView, boolean dismissable, float swipeProgress);
/**
* @return The factor the falsing threshold should be multiplied with
*/
float getFalsingThresholdFactor();
}
/**
* Equivalent to View.OnLongClickListener with coordinates
*/
public interface LongPressListener {
/**
* Equivalent to {@link View.OnLongClickListener#onLongClick(View)} with coordinates
* @return whether the longpress was handled
*/
boolean onLongPress(View v, int x, int y);
}
}