blob: a99bcc5b35663b4fee6817df50b95330371c6b32 [file] [log] [blame]
/*
* 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 androidx.wear.widget;
import android.content.Context;
import android.content.res.Resources;
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.widget.FrameLayout;
import androidx.annotation.Nullable;
import androidx.annotation.RestrictTo;
import androidx.annotation.RestrictTo.Scope;
import androidx.annotation.UiThread;
/**
* Special layout that finishes its activity when swiped away.
*
* <p>This is a modified copy of the internal framework class
* com.android.internal.widget.SwipeDismissLayout.
*
* @hide
*/
@RestrictTo(Scope.LIBRARY)
@UiThread
class SwipeDismissLayout extends FrameLayout {
private static final String TAG = "SwipeDismissLayout";
public static final float DEFAULT_DISMISS_DRAG_WIDTH_RATIO = .33f;
// A value between 0.0 and 1.0 determining the percentage of the screen on the left-hand-side
// where edge swipe gestures are permitted to begin.
private static final float EDGE_SWIPE_THRESHOLD = 0.1f;
/** Called when the layout is about to consider a swipe. */
@UiThread
interface OnPreSwipeListener {
/**
* Notifies listeners that the view is now considering to start a dismiss gesture from a
* particular point on the screen. The default implementation returns true for all
* coordinates so that is is possible to start a swipe-to-dismiss gesture from any location.
* If any one instance of this Callback returns false for a given set of coordinates,
* swipe-to-dismiss will not be allowed to start in that point.
*
* @param xDown the x coordinate of the initial {@link android.view.MotionEvent#ACTION_DOWN}
* event for this motion
* @param yDown the y coordinate of the initial {@link android.view.MotionEvent#ACTION_DOWN}
* event for this motion
* @return {@code true} if these coordinates should be considered as a start of a swipe
* gesture, {@code false} otherwise
*/
boolean onPreSwipe(SwipeDismissLayout swipeDismissLayout, float xDown, float yDown);
}
/**
* Interface enabling listeners to react to when the swipe gesture is done and the view should
* probably be dismissed from the UI.
*/
@UiThread
interface OnDismissedListener {
void onDismissed(SwipeDismissLayout layout);
}
/**
* Interface enabling listeners to react to changes in the progress of the swipe-to-dismiss
* gesture.
*/
@UiThread
interface OnSwipeProgressChangedListener {
/**
* Called when the layout has been swiped and the position of the window should change.
*
* @param layout the layout associated with this listener.
* @param progress a number in [0, 1] representing how far to the right the window has
* been swiped
* @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 progress, float translate);
/**
* Called when the layout started to be swiped away but then the gesture was canceled.
*
* @param layout the layout associated with this listener
*/
void onSwipeCanceled(SwipeDismissLayout layout);
}
// Cached ViewConfiguration and system-wide constant values
private int mSlop;
private int mMinFlingVelocity;
private float mGestureThresholdPx;
// Transient properties
private int mActiveTouchId;
private float mDownX;
private float mDownY;
private boolean mSwipeable;
private boolean mSwiping;
// This variable holds information about whether the initial move of a longer swipe
// (consisting of multiple move events) has conformed to the definition of a horizontal
// swipe-to-dismiss. A swipe gesture is only ever allowed to be recognized if this variable is
// set to true. Otherwise, the motion events will be allowed to propagate to the children.
private boolean mCanStartSwipe = true;
private boolean mDismissed;
private boolean mDiscardIntercept;
private VelocityTracker mVelocityTracker;
private float mTranslationX;
private boolean mDisallowIntercept;
@Nullable
private OnPreSwipeListener mOnPreSwipeListener;
private OnDismissedListener mDismissedListener;
private OnSwipeProgressChangedListener mProgressListener;
private float mLastX;
private float mDismissMinDragWidthRatio = DEFAULT_DISMISS_DRAG_WIDTH_RATIO;
SwipeDismissLayout(Context context) {
this(context, null);
}
SwipeDismissLayout(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
SwipeDismissLayout(Context context, AttributeSet attrs, int defStyle) {
this(context, attrs, defStyle, 0);
}
SwipeDismissLayout(Context context, AttributeSet attrs, int defStyle, int defStyleRes) {
super(context, attrs, defStyle, defStyleRes);
ViewConfiguration vc = ViewConfiguration.get(context);
mSlop = vc.getScaledTouchSlop();
mMinFlingVelocity = vc.getScaledMinimumFlingVelocity();
mGestureThresholdPx =
Resources.getSystem().getDisplayMetrics().widthPixels * EDGE_SWIPE_THRESHOLD;
// By default, the view is swipeable.
setSwipeable(true);
}
/**
* Sets the minimum ratio of the screen after which the swipe gesture is treated as swipe-to-
* dismiss.
*
* @param ratio the ratio of the screen at which the swipe gesture is treated as
* swipe-to-dismiss. should be provided as a fraction of the screen
*/
public void setDismissMinDragWidthRatio(float ratio) {
mDismissMinDragWidthRatio = ratio;
}
/**
* Returns the current ratio of te screen at which the swipe gesture is treated as
* swipe-to-dismiss.
*
* @return the current ratio of te screen at which the swipe gesture is treated as
* swipe-to-dismiss
*/
public float getDismissMinDragWidthRatio() {
return mDismissMinDragWidthRatio;
}
/**
* Sets the layout to swipeable or not. This effectively turns the functionality of this layout
* on or off.
*
* @param swipeable whether the layout should react to the swipe gesture
*/
public void setSwipeable(boolean swipeable) {
mSwipeable = swipeable;
}
/** Returns true if the layout reacts to swipe gestures. */
public boolean isSwipeable() {
return mSwipeable;
}
void setOnPreSwipeListener(@Nullable OnPreSwipeListener listener) {
mOnPreSwipeListener = listener;
}
void setOnDismissedListener(@Nullable OnDismissedListener listener) {
mDismissedListener = listener;
}
void setOnSwipeProgressChangedListener(@Nullable OnSwipeProgressChangedListener listener) {
mProgressListener = listener;
}
@Override
public void requestDisallowInterceptTouchEvent(boolean disallowIntercept) {
mDisallowIntercept = disallowIntercept;
if (getParent() != null) {
getParent().requestDisallowInterceptTouchEvent(disallowIntercept);
}
}
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
if (!mSwipeable) {
return super.onInterceptTouchEvent(ev);
}
// offset because the view is translated during swipe
ev.offsetLocation(mTranslationX, 0);
switch (ev.getActionMasked()) {
case MotionEvent.ACTION_DOWN:
resetMembers();
mDownX = ev.getRawX();
mDownY = ev.getRawY();
mActiveTouchId = ev.getPointerId(0);
mVelocityTracker = VelocityTracker.obtain();
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 && mDownX >= mGestureThresholdPx && canScroll(this, false, dx, x, y)) {
mDiscardIntercept = true;
break;
}
updateSwiping(ev);
break;
}
if ((mOnPreSwipeListener == null && !mDisallowIntercept)
|| mOnPreSwipeListener.onPreSwipe(this, mDownX, mDownY)) {
return (!mDiscardIntercept && mSwiping);
}
return false;
}
@Override
public boolean canScrollHorizontally(int direction) {
// This view can only be swiped horizontally from left to right - this means a negative
// SCROLLING direction. We return false if the view is not visible to avoid capturing swipe
// gestures when the view is hidden.
return direction < 0 && isSwipeable() && getVisibility() == View.VISIBLE;
}
/**
* Helper function determining if a particular move gesture was verbose enough to qualify as a
* beginning of a swipe.
*
* @param dx distance traveled in the x direction, from the initial touch down
* @param dy distance traveled in the y direction, from the initial touch down
* @return {@code true} if the gesture was long enough to be considered a potential swipe
*/
private boolean isPotentialSwipe(float dx, float dy) {
return (dx * dx) + (dy * dy) > mSlop * mSlop;
}
@Override
public boolean onTouchEvent(MotionEvent ev) {
if (!mSwipeable) {
return super.onTouchEvent(ev);
}
if (mVelocityTracker == null) {
return super.onTouchEvent(ev);
}
if (mOnPreSwipeListener != null && !mOnPreSwipeListener.onPreSwipe(this, mDownX, mDownY)) {
return super.onTouchEvent(ev);
}
// offset because the view is translated during swipe
ev.offsetLocation(mTranslationX, 0);
switch (ev.getActionMasked()) {
case MotionEvent.ACTION_UP:
updateDismiss(ev);
if (mDismissed) {
dismiss();
} else if (mSwiping) {
cancel();
}
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) {
mTranslationX = deltaX;
if (mProgressListener != null && deltaX >= 0) {
mProgressListener.onSwipeProgressChanged(this, deltaX / getWidth(), deltaX);
}
}
private void dismiss() {
if (mDismissedListener != null) {
mDismissedListener.onDismissed(this);
}
}
private void cancel() {
if (mProgressListener != null) {
mProgressListener.onSwipeCanceled(this);
}
}
/** Resets internal members when canceling or finishing a given gesture. */
private void resetMembers() {
if (mVelocityTracker != null) {
mVelocityTracker.recycle();
}
mVelocityTracker = null;
mTranslationX = 0;
mDownX = 0;
mDownY = 0;
mSwiping = false;
mDismissed = false;
mDiscardIntercept = false;
mCanStartSwipe = true;
mDisallowIntercept = false;
}
private void updateSwiping(MotionEvent ev) {
if (!mSwiping) {
float deltaX = ev.getRawX() - mDownX;
float deltaY = ev.getRawY() - mDownY;
if (isPotentialSwipe(deltaX, deltaY)) {
// There are three conditions on which we want want to start swiping:
// 1. The swipe is from left to right AND
// 2. It is horizontal AND
// 3. We actually can start swiping
mSwiping = mCanStartSwipe && Math.abs(deltaY) < Math.abs(deltaX) && deltaX > 0;
mCanStartSwipe = mSwiping;
}
}
}
private void updateDismiss(MotionEvent ev) {
float deltaX = ev.getRawX() - mDownX;
mVelocityTracker.addMovement(ev);
mVelocityTracker.computeCurrentVelocity(1000);
if (!mDismissed) {
if ((deltaX > (getWidth() * mDismissMinDragWidthRatio) && ev.getRawX() >= mLastX)
|| mVelocityTracker.getXVelocity() >= 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 (mVelocityTracker.getXVelocity() < -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
* ({@code true}), or just its children ({@code 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 {@code 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);
}
}