blob: 6d814bf14bc0a9f9a8de7b111d19d256dadd76ec [file] [log] [blame]
/*
* Copyright (C) 2014 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.internal.widget;
import android.animation.Animator;
import android.animation.TimeInterpolator;
import android.animation.ValueAnimator;
import android.animation.ValueAnimator.AnimatorUpdateListener;
import android.app.Activity;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.ContextWrapper;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.res.TypedArray;
import android.util.AttributeSet;
import android.util.Log;
import android.view.MotionEvent;
import android.view.VelocityTracker;
import android.view.View;
import android.view.ViewConfiguration;
import android.view.ViewGroup;
import android.view.animation.DecelerateInterpolator;
import android.widget.FrameLayout;
/**
* Special layout that finishes its activity when swiped away.
*/
public class SwipeDismissLayout extends FrameLayout {
private static final String TAG = "SwipeDismissLayout";
private static final float MAX_DIST_THRESHOLD = .33f;
private static final float MIN_DIST_THRESHOLD = .1f;
public interface OnDismissedListener {
void onDismissed(SwipeDismissLayout layout);
}
public interface OnSwipeProgressChangedListener {
/**
* Called when the layout has been swiped and the position of the window should change.
*
* @param alpha A number in [0, 1] representing what the alpha transparency of the window
* should be.
* @param translate A number in [0, w], where w is the width of the
* layout. This is equivalent to progress * layout.getWidth().
*/
void onSwipeProgressChanged(SwipeDismissLayout layout, float alpha, float translate);
void onSwipeCancelled(SwipeDismissLayout layout);
}
private boolean mIsWindowNativelyTranslucent;
// Cached ViewConfiguration and system-wide constant values
private int mSlop;
private int mMinFlingVelocity;
// Transient properties
private int mActiveTouchId;
private float mDownX;
private float mDownY;
private float mLastX;
private boolean mSwiping;
private boolean mDismissed;
private boolean mDiscardIntercept;
private VelocityTracker mVelocityTracker;
private boolean mBlockGesture = false;
private boolean mActivityTranslucencyConverted = false;
private final DismissAnimator mDismissAnimator = new DismissAnimator();
private OnDismissedListener mDismissedListener;
private OnSwipeProgressChangedListener mProgressListener;
private BroadcastReceiver mScreenOffReceiver = new BroadcastReceiver() {
private Runnable mRunnable = new Runnable() {
@Override
public void run() {
if (mDismissed) {
dismiss();
} else {
cancel();
}
resetMembers();
}
};
@Override
public void onReceive(Context context, Intent intent) {
post(mRunnable);
}
};
private IntentFilter mScreenOffFilter = new IntentFilter(Intent.ACTION_SCREEN_OFF);
private boolean mDismissable = true;
public SwipeDismissLayout(Context context) {
super(context);
init(context);
}
public SwipeDismissLayout(Context context, AttributeSet attrs) {
super(context, attrs);
init(context);
}
public SwipeDismissLayout(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
init(context);
}
private void init(Context context) {
ViewConfiguration vc = ViewConfiguration.get(context);
mSlop = vc.getScaledTouchSlop();
mMinFlingVelocity = vc.getScaledMinimumFlingVelocity();
TypedArray a = context.getTheme().obtainStyledAttributes(
com.android.internal.R.styleable.Theme);
mIsWindowNativelyTranslucent = a.getBoolean(
com.android.internal.R.styleable.Window_windowIsTranslucent, false);
a.recycle();
}
public void setOnDismissedListener(OnDismissedListener listener) {
mDismissedListener = listener;
}
public void setOnSwipeProgressChangedListener(OnSwipeProgressChangedListener listener) {
mProgressListener = listener;
}
@Override
protected void onAttachedToWindow() {
super.onAttachedToWindow();
getContext().registerReceiver(mScreenOffReceiver, mScreenOffFilter);
}
@Override
protected void onDetachedFromWindow() {
getContext().unregisterReceiver(mScreenOffReceiver);
super.onDetachedFromWindow();
}
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
checkGesture((ev));
if (mBlockGesture) {
return true;
}
if (!mDismissable) {
return super.onInterceptTouchEvent(ev);
}
// Offset because the view is translated during swipe, match X with raw X. Active touch
// coordinates are mostly used by the velocity tracker, so offset it to match the raw
// coordinates which is what is primarily used elsewhere.
ev.offsetLocation(ev.getRawX() - ev.getX(), 0);
switch (ev.getActionMasked()) {
case MotionEvent.ACTION_DOWN:
resetMembers();
mDownX = ev.getRawX();
mDownY = ev.getRawY();
mActiveTouchId = ev.getPointerId(0);
mVelocityTracker = VelocityTracker.obtain("int1");
mVelocityTracker.addMovement(ev);
break;
case MotionEvent.ACTION_POINTER_DOWN:
int actionIndex = ev.getActionIndex();
mActiveTouchId = ev.getPointerId(actionIndex);
break;
case MotionEvent.ACTION_POINTER_UP:
actionIndex = ev.getActionIndex();
int pointerId = ev.getPointerId(actionIndex);
if (pointerId == mActiveTouchId) {
// This was our active pointer going up. Choose a new active pointer.
int newActionIndex = actionIndex == 0 ? 1 : 0;
mActiveTouchId = ev.getPointerId(newActionIndex);
}
break;
case MotionEvent.ACTION_CANCEL:
case MotionEvent.ACTION_UP:
resetMembers();
break;
case MotionEvent.ACTION_MOVE:
if (mVelocityTracker == null || mDiscardIntercept) {
break;
}
int pointerIndex = ev.findPointerIndex(mActiveTouchId);
if (pointerIndex == -1) {
Log.e(TAG, "Invalid pointer index: ignoring.");
mDiscardIntercept = true;
break;
}
float dx = ev.getRawX() - mDownX;
float x = ev.getX(pointerIndex);
float y = ev.getY(pointerIndex);
if (dx != 0 && canScroll(this, false, dx, x, y)) {
mDiscardIntercept = true;
break;
}
updateSwiping(ev);
break;
}
return !mDiscardIntercept && mSwiping;
}
@Override
public boolean onTouchEvent(MotionEvent ev) {
checkGesture((ev));
if (mBlockGesture) {
return true;
}
if (mVelocityTracker == null || !mDismissable) {
return super.onTouchEvent(ev);
}
// Offset because the view is translated during swipe, match X with raw X. Active touch
// coordinates are mostly used by the velocity tracker, so offset it to match the raw
// coordinates which is what is primarily used elsewhere.
ev.offsetLocation(ev.getRawX() - ev.getX(), 0);
switch (ev.getActionMasked()) {
case MotionEvent.ACTION_UP:
updateDismiss(ev);
if (mDismissed) {
mDismissAnimator.animateDismissal(ev.getRawX() - mDownX);
} else if (mSwiping
// Only trigger animation if we had a MOVE event that would shift the
// underlying view, otherwise the animation would be janky.
&& mLastX != Integer.MIN_VALUE) {
mDismissAnimator.animateRecovery(ev.getRawX() - mDownX);
}
resetMembers();
break;
case MotionEvent.ACTION_CANCEL:
cancel();
resetMembers();
break;
case MotionEvent.ACTION_MOVE:
mVelocityTracker.addMovement(ev);
mLastX = ev.getRawX();
updateSwiping(ev);
if (mSwiping) {
setProgress(ev.getRawX() - mDownX);
break;
}
}
return true;
}
private void setProgress(float deltaX) {
if (mProgressListener != null && deltaX >= 0) {
mProgressListener.onSwipeProgressChanged(
this, progressToAlpha(deltaX / getWidth()), deltaX);
}
}
private void dismiss() {
if (mDismissedListener != null) {
mDismissedListener.onDismissed(this);
}
}
protected void cancel() {
if (!mIsWindowNativelyTranslucent) {
Activity activity = findActivity();
if (activity != null && mActivityTranslucencyConverted) {
activity.convertFromTranslucent();
mActivityTranslucencyConverted = false;
}
}
if (mProgressListener != null) {
mProgressListener.onSwipeCancelled(this);
}
}
/**
* Resets internal members when canceling.
*/
private void resetMembers() {
if (mVelocityTracker != null) {
mVelocityTracker.recycle();
}
mVelocityTracker = null;
mDownX = 0;
mLastX = Integer.MIN_VALUE;
mDownY = 0;
mSwiping = false;
mDismissed = false;
mDiscardIntercept = false;
}
private void updateSwiping(MotionEvent ev) {
boolean oldSwiping = mSwiping;
if (!mSwiping) {
float deltaX = ev.getRawX() - mDownX;
float deltaY = ev.getRawY() - mDownY;
if ((deltaX * deltaX) + (deltaY * deltaY) > mSlop * mSlop) {
mSwiping = deltaX > mSlop * 2 && Math.abs(deltaY) < Math.abs(deltaX);
} else {
mSwiping = false;
}
}
if (mSwiping && !oldSwiping) {
// Swiping has started
if (!mIsWindowNativelyTranslucent) {
Activity activity = findActivity();
if (activity != null) {
mActivityTranslucencyConverted = activity.convertToTranslucent(null, null);
}
}
}
}
private void updateDismiss(MotionEvent ev) {
float deltaX = ev.getRawX() - mDownX;
// Don't add the motion event as an UP event would clear the velocity tracker
mVelocityTracker.computeCurrentVelocity(1000);
float xVelocity = mVelocityTracker.getXVelocity();
if (mLastX == Integer.MIN_VALUE) {
// If there's no changes to mLastX, we have only one point of data, and therefore no
// velocity. Estimate velocity from just the up and down event in that case.
xVelocity = deltaX / ((ev.getEventTime() - ev.getDownTime()) / 1000);
}
if (!mDismissed) {
// Adjust the distance threshold linearly between the min and max threshold based on the
// x-velocity scaled with the the fling threshold speed
float distanceThreshold = getWidth() * Math.max(
Math.min((MIN_DIST_THRESHOLD - MAX_DIST_THRESHOLD)
* xVelocity / mMinFlingVelocity // scale x-velocity with fling velocity
+ MAX_DIST_THRESHOLD, // offset to start at max threshold
MAX_DIST_THRESHOLD), // cap at max threshold
MIN_DIST_THRESHOLD); // bottom out at min threshold
if ((deltaX > distanceThreshold && ev.getRawX() >= mLastX)
|| xVelocity >= mMinFlingVelocity) {
mDismissed = true;
}
}
// Check if the user tried to undo this.
if (mDismissed && mSwiping) {
// Check if the user's finger is actually flinging back to left
if (xVelocity < -mMinFlingVelocity) {
mDismissed = false;
}
}
}
/**
* Tests scrollability within child views of v in the direction of dx.
*
* @param v View to test for horizontal scrollability
* @param checkV Whether the view v passed should itself be checked for scrollability (true),
* or just its children (false).
* @param dx Delta scrolled in pixels. Only the sign of this is used.
* @param x X coordinate of the active touch point
* @param y Y coordinate of the active touch point
* @return true if child views of v can be scrolled by delta of dx.
*/
protected boolean canScroll(View v, boolean checkV, float dx, float x, float y) {
if (v instanceof ViewGroup) {
final ViewGroup group = (ViewGroup) v;
final int scrollX = v.getScrollX();
final int scrollY = v.getScrollY();
final int count = group.getChildCount();
for (int i = count - 1; i >= 0; i--) {
final View child = group.getChildAt(i);
if (x + scrollX >= child.getLeft() && x + scrollX < child.getRight() &&
y + scrollY >= child.getTop() && y + scrollY < child.getBottom() &&
canScroll(child, true, dx, x + scrollX - child.getLeft(),
y + scrollY - child.getTop())) {
return true;
}
}
}
return checkV && v.canScrollHorizontally((int) -dx);
}
public void setDismissable(boolean dismissable) {
if (!dismissable && mDismissable) {
cancel();
resetMembers();
}
mDismissable = dismissable;
}
private void checkGesture(MotionEvent ev) {
if (ev.getActionMasked() == MotionEvent.ACTION_DOWN) {
mBlockGesture = mDismissAnimator.isAnimating();
}
}
private float progressToAlpha(float progress) {
return 1 - progress * progress * progress;
}
private Activity findActivity() {
Context context = getContext();
while (context instanceof ContextWrapper) {
if (context instanceof Activity) {
return (Activity) context;
}
context = ((ContextWrapper) context).getBaseContext();
}
return null;
}
private class DismissAnimator implements AnimatorUpdateListener, Animator.AnimatorListener {
private final TimeInterpolator DISMISS_INTERPOLATOR = new DecelerateInterpolator(1.5f);
private final long DISMISS_DURATION = 250;
private final ValueAnimator mDismissAnimator = new ValueAnimator();
private boolean mWasCanceled = false;
private boolean mDismissOnComplete = false;
/* package */ DismissAnimator() {
mDismissAnimator.addUpdateListener(this);
mDismissAnimator.addListener(this);
}
/* package */ void animateDismissal(float currentTranslation) {
animate(
currentTranslation / getWidth(),
1,
DISMISS_DURATION,
DISMISS_INTERPOLATOR,
true /* dismiss */);
}
/* package */ void animateRecovery(float currentTranslation) {
animate(
currentTranslation / getWidth(),
0,
DISMISS_DURATION,
DISMISS_INTERPOLATOR,
false /* don't dismiss */);
}
/* package */ boolean isAnimating() {
return mDismissAnimator.isStarted();
}
private void animate(float from, float to, long duration, TimeInterpolator interpolator,
boolean dismissOnComplete) {
mDismissAnimator.cancel();
mDismissOnComplete = dismissOnComplete;
mDismissAnimator.setFloatValues(from, to);
mDismissAnimator.setDuration(duration);
mDismissAnimator.setInterpolator(interpolator);
mDismissAnimator.start();
}
@Override
public void onAnimationUpdate(ValueAnimator animation) {
float value = (Float) animation.getAnimatedValue();
setProgress(value * getWidth());
}
@Override
public void onAnimationStart(Animator animation) {
mWasCanceled = false;
}
@Override
public void onAnimationCancel(Animator animation) {
mWasCanceled = true;
}
@Override
public void onAnimationEnd(Animator animation) {
if (!mWasCanceled) {
if (mDismissOnComplete) {
dismiss();
} else {
cancel();
}
}
}
@Override
public void onAnimationRepeat(Animator animation) {
}
}
}