| /* |
| * 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.ReceiverCallNotAllowedException; |
| 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; |
| 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(); |
| try { |
| mScreenOffReceiver = new BroadcastReceiver() { |
| @Override |
| public void onReceive(Context context, Intent intent) { |
| post(() -> { |
| if (mDismissed) { |
| dismiss(); |
| } else { |
| cancel(); |
| } |
| resetMembers(); |
| }); |
| } |
| }; |
| getContext().registerReceiver(mScreenOffReceiver, mScreenOffFilter); |
| } catch (ReceiverCallNotAllowedException e) { |
| /* Exception is thrown if the context is a ReceiverRestrictedContext object. As |
| * ReceiverRestrictedContext is not public, the context type cannot be checked before |
| * calling registerReceiver. The most likely scenario in which the exception would be |
| * thrown would be when a BroadcastReceiver creates a dialog to show the user. */ |
| mScreenOffReceiver = null; // clear receiver since it was not used. |
| } |
| } |
| |
| @Override |
| protected void onDetachedFromWindow() { |
| if (mScreenOffReceiver != null) { |
| getContext().unregisterReceiver(mScreenOffReceiver); |
| mScreenOffReceiver = null; |
| } |
| 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) { |
| } |
| } |
| } |