滑動返回是ios設(shè)備中默認(rèn)支持的一種滑動退出效果,由于IPhone設(shè)備沒有返回鍵,所以滑動退出使用起來十分方便。而如今隨著手機(jī)屏幕越來越大,而單手使用手機(jī)的情況愈發(fā)頻繁,所以在Android端添加測滑返回也是各大app的一項趨勢,今天我們就通過分析下實現(xiàn)測滑退出的幾種方式,來實現(xiàn)一套自己的“測滑退出”方案。
滑動返回是ios設(shè)備中默認(rèn)支持的一種滑動退出效果,由于IPhone設(shè)備沒有返回鍵,所以滑動退出使用起來十分方便。而如今隨著手機(jī)屏幕越來越大,而單手使用手機(jī)的情況愈發(fā)頻繁,所以在Android端添加測滑返回也是各大app的一項趨勢,今天我們就通過分析下實現(xiàn)測滑退出的幾種方式,來實現(xiàn)一套自己的“測滑退出”方案。
一、滑動返回案例
網(wǎng)易新聞:

今日頭條:

上面是網(wǎng)易新聞和今日頭條中實現(xiàn)的滑動返回樣式,從gif圖中我們看到,網(wǎng)易新聞在測滑時底部蒙層有一個透明度的變化,而底部Activity樣式并沒發(fā)生變化。而頭條中實現(xiàn)的樣式中,底部Activity(前一個Activity)有一個漸變的動畫。通過這兩種不同的樣式,我們可以將“測滑退出”分為兩個過程:
- 實現(xiàn)當(dāng)前Activity跟隨手指進(jìn)行滑動;
- 展現(xiàn)底部(上一級)Activity的view,并對其進(jìn)行相應(yīng)操作(各種動畫);
而常見的實現(xiàn)方案有兩種,其中一種為:“透明主題樣式方案”,另一種為:“視覺差方案”。這兩種方案針對上述過程1并無差別,主要差別在過程2。下面將分別從“測滑退出”的兩個過程來進(jìn)行具體分析。
二、實現(xiàn)當(dāng)前Activity跟隨手指進(jìn)行滑動
而實現(xiàn)此效果可以有以下幾種方式:
- 重寫View的dispatchTouchEvent()方法及onTouchEvent():參考 swipeback
- 結(jié)合GestureDetector類實現(xiàn):對于GestureDetector這個類,不了解的可以參考官方文檔GestureDetector
- DrawerLayout/SlidingPaneLayout:采用DrawerLayout實現(xiàn)時,需要修改DrawerLayout的滑動范圍,可以采用反射的方式修改其私有屬性mEdgeSize,將DrawLayout修改為劃出后為全屏幕。具體DrawerLayout使用可參考 Android 之 DrawerLayout 詳解 及DrawerLayout滑動范圍的設(shè)置
- 結(jié)合ViewDragHelper類實現(xiàn):ViewDragHelper類提供了一系列用于用戶拖動子view的輔助方法和相關(guān)狀態(tài)記錄的工具類,如DrawerLayout等內(nèi)部均使用ViewDragHelper來處理滑動相關(guān)操作。那么我們就采用ViewDragHelper來為一個自定義view添加滑動處理,來作為當(dāng)前實現(xiàn)方案。
采用ViewDragHelper實現(xiàn)測滑
在使用ViewDragHelper實現(xiàn)具體功能之前,讓我們首先來學(xué)習(xí)一下ViewDragHelper:
基本用法:
在自定義View構(gòu)造方法中調(diào)用ViewDragHelper的靜態(tài)工廠方法create()創(chuàng)建ViewDragHelper實例;
-
實現(xiàn)ViewDragHelper.Callback接口,具體方法解析如下:
void onViewDragStateChanged(int state)
拖動狀態(tài)改變時會調(diào)用此方法,狀態(tài)state有STATE_IDLE、STATE_DRAGGING、STATE_SETTLING三種取值。void onViewPositionChanged(View changedView, int left, int top, int dx, int dy)
正在被拖動的View或者自動滾動的View的位置改變時會調(diào)用此方法。void onViewCaptured(View capturedChild, int activePointerId)
tryCaptureViewForDrag()成功捕獲到子View時會調(diào)用此方法。void onViewReleased(View releasedChild, float xvel, float yvel)
拖動View松手時(processTouchEvent()的ACTION_UP)或被父View攔截事件時(processTouchEvent()的ACTION_CANCEL)會調(diào)用此方法。void onEdgeTouched(int edgeFlags, int pointerId)
ACTION_DOWN或ACTION_POINTER_DOWN事件發(fā)生時如果觸摸到監(jiān)聽的邊緣會調(diào)用此方法。edgeFlags的取值為EDGE_LEFT、EDGE_TOP、EDGE_RIGHT、EDGE_BOTTOM的組合。boolean onEdgeLock(int edgeFlags)
返回true表示鎖定edgeFlags對應(yīng)的邊緣,鎖定后的那些邊緣就不會在onEdgeDragStarted()被通知了,默認(rèn)返回false不鎖定給定的邊緣,edgeFlags的取值為EDGE_LEFT、EDGE_TOP、EDGE_RIGHT、EDGE_BOTTOM其中之一。void onEdgeDragStarted(int edgeFlags, int pointerId)
ACTION_MOVE事件發(fā)生時,檢測到開始在某些邊緣有拖動的手勢,也沒有鎖定邊緣,會調(diào)用此方法。edgeFlags取值為EDGE_LEFT、EDGE_TOP、EDGE_RIGHT、EDGE_BOTTOM的組合??稍诖耸謩诱{(diào)用captureChildView()觸發(fā)從邊緣拖動子View的效果。int getOrderedChildIndex(int index)
在尋找當(dāng)前觸摸點下的子View時會調(diào)用此方法,尋找到的View會提供給tryCaptureViewForDrag()來嘗試捕獲。如果需要改變子View的遍歷查詢順序可改寫此方法,例如讓下層的View優(yōu)先于上層的View被選中。int getViewHorizontalDragRange(View child)、int getViewVerticalDragRange(View child)
返回給定的child在相應(yīng)的方向上可以被拖動的最遠(yuǎn)距離,默認(rèn)返回0。ACTION_DOWN發(fā)生時,若觸摸點處的child消費了事件,并且想要在某個方向上可以被拖動,就要在對應(yīng)方法里返回大于0的數(shù)。
被調(diào)用的地方有三處:- 在checkTouchSlop()中被調(diào)用,返回值大于0才會去檢查mTouchSlop。在ACTION_MOVE里調(diào)用tryCaptureViewForDrag()之前會調(diào)用checkTouchSlop()。如果checkTouchSlop()失敗,就不會去捕獲View了。
- 如果ACTION_DOWN發(fā)生時,觸摸點處有子View消費事件,在shouldInterceptTouchEvent()的ACTION_MOVE里會被調(diào)用。如果兩個方向上的range都是0(兩個方法都返回0),就不會去捕獲View了。
- 在調(diào)用smoothSlideViewTo()時被調(diào)用,用于計算自動滾動要滾動多長時間,這個時間計算出來后,如果超過最大值,最終時間就取最大值,所以不用擔(dān)心在getView[Horizontal|Vertical]DragRange里返回了不合適的數(shù)導(dǎo)致計算的時間有問題,只要返回大于0的數(shù)就行了。
boolean tryCaptureView(View child, int pointerId)
在tryCaptureViewForDrag()中被調(diào)用,返回true表示捕獲給定的child。tryCaptureViewForDrag()被調(diào)用的地方有- shouldInterceptTouchEvent()的ACTION_DOWN里
- shouldInterceptTouchEvent()的ACTION_MOVE里
- processTouchEvent()的ACTION_MOVE里
int clampViewPositionHorizontal(View child, int left, int dx)、int clampViewPositionVertical(View child, int top, int dy)
child在某方向上被拖動時會調(diào)用對應(yīng)方法,返回值是child移動過后的坐標(biāo)位置,clampViewPositionHorizontal()返回child移動過后的left值,clampViewPositionVertical()返回child移動過后的top值。
兩個方法被調(diào)用的地方有兩處:- 在dragTo()中被調(diào)用,dragTo()在processTouchEvent()的ACTION_MOVE里被調(diào)用。用來獲取被拖動的View要移動到的位置。
- 如果ACTION_DOWN發(fā)生時,觸摸點處有子View消費事件,在shouldInterceptTouchEvent()的ACTION_MOVE里會被調(diào)用。如果兩個方向上返回的還是原來的left和top值,就不會去捕獲View了。
在onInterceptTouchEvent()方法里調(diào)用并返回ViewDragHelper的shouldInterceptTouchEvent()方法
在onTouchEvent()方法里調(diào)用ViewDragHelper()的processTouchEvent()方法。ACTION_DOWN事件發(fā)生時,如果當(dāng)前觸摸點下要拖動的子View沒有消費事件,此時應(yīng)該在onTouchEvent()返回true,否則將收不到后續(xù)事件,不會產(chǎn)生拖動。
上面幾個步驟已經(jīng)實現(xiàn)了子View拖動的效果,如果還想要實現(xiàn)fling效果(滑動時松手后以一定速率繼續(xù)自動滑動下去并逐漸停止,類似于扔?xùn)|西)或者松手后自動滑動到指定位置,需要實現(xiàn)自定義ViewGroup的computeScroll()方法,方法實現(xiàn)如下:
@Override
public void computeScroll() {
if (mDragHelper.continueSettling(true)) {
postInvalidate();
}
}
并在ViewDragHelper.Callback的onViewReleased()方法里調(diào)用以下三個方法中任意一個:
- settleCapturedViewAt(int finalLeft, int finalTop)
以松手前的滑動速度為初速動,讓捕獲到的View自動滾動到指定位置。只能在Callback的onViewReleased()中調(diào)用。 - flingCapturedView(int minLeft, int minTop, int maxLeft, int maxTop)
以松手前的滑動速度為初速動,讓捕獲到的View在指定范圍內(nèi)fling。只能在Callback的onViewReleased()中調(diào)用。 - smoothSlideViewTo(View child, int finalLeft, int finalTop)
指定某個View自動滾動到指定的位置,初速度為0,可在任何地方調(diào)用。
如果要實現(xiàn)邊緣拖動的效果,需要調(diào)用ViewDragHelper的setEdgeTrackingEnabled()方法,注冊想要監(jiān)聽的邊緣。然后實現(xiàn)ViewDragHelper.Callback里的onEdgeDragStarted()方法,在此手動調(diào)用captureChildView()傳遞要拖動的子View。
以上為ViewDragHelper的基本使用方法,更加詳細(xì)的ViewDragHelper源碼分析,請參考 Android ViewDragHelper源碼解析
而通過上述對ViewDragHelper的了解,我們可以實現(xiàn)各個方向上的測滑退出(左、上、右、下),而僅通過調(diào)用以下方法即可:
/**
* Enable edge tracking for the selected edges of the parent view.
* The callback's {@link Callback#onEdgeTouched(int, int)} and
* {@link Callback#onEdgeDragStarted(int, int)} methods will only be invoked
* for edges for which edge tracking has been enabled.
*
* @param edgeFlags Combination of edge flags describing the edges to watch
* @see #EDGE_LEFT
* @see #EDGE_TOP
* @see #EDGE_RIGHT
* @see #EDGE_BOTTOM
*/
public void setEdgeTrackingEnabled(int edgeFlags) {
mTrackingEdges = edgeFlags;
}
具體實現(xiàn)
下面將給出基于ViewDragHelper實現(xiàn)的支持滑動的自定義ViewGroup: SwipeBackLayout.java ,具體代碼為:
public class SwipeBackLayout extends FrameLayout {
private static String TAG = SwipeBackLayout.class.getSimpleName();
private float mScrollThreshold = DEFAULT_SCROLL_THRESHOLD;
private WeakReference<View> mContentViewRef;
//mInsets保存當(dāng)前內(nèi)部contentView的margin
private Rect mInsets = new Rect();
private ViewDragHelper mDragHelper;
private SwipeSlideCallback mSlideCallback;
private int mContentLeft;
private int mContentTop;
private boolean mInLayout;
private int mFlingVelocity = FLING_VELOCITY;
private int mEdgeFlag = ViewDragHelper.EDGE_LEFT;
private int mEdgeMode = SwipeConstantUtils.EDGEMODE_FULLSCREEN;
//children中有需要滾動的view
private View mScrollChildView;
public SwipeBackLayout(Context context) {
super(context);
//初始化viewDragHelper
mDragHelper = ViewDragHelper.create(this, 1f, new ViewDragCallback());
}
@TargetApi(Build.VERSION_CODES.KITKAT_WATCH)
@Override
public WindowInsets onApplyWindowInsets(WindowInsets insets) {
int top = insets.getSystemWindowInsetTop();
View contentView = getContentView();
if(contentView != null) {
if (contentView.getLayoutParams() instanceof MarginLayoutParams) {
MarginLayoutParams params = (MarginLayoutParams)contentView.getLayoutParams();
mInsets.set(params.leftMargin, params.topMargin + top, params.rightMargin, params.bottomMargin);
}
}
return super.onApplyWindowInsets(insets);
}
@Override
public boolean onInterceptTouchEvent(MotionEvent event) {
try {
//交給viewDragHelper來處理
return mDragHelper.shouldInterceptTouchEvent(event);
} catch (Exception e) {
e.printStackTrace();
return false;
}
}
@Override
public boolean onTouchEvent(MotionEvent event) {
//交給viewDragHelper來處理
mDragHelper.processTouchEvent(event);
return true;
}
@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
mInLayout = true;
View contentView = getContentView();
if(contentView != null) {
int cleft = mContentLeft;
int ctop = mContentTop;
ViewGroup.LayoutParams params = contentView.getLayoutParams();
if (params instanceof MarginLayoutParams) {
cleft += ((MarginLayoutParams) params).leftMargin;
ctop += ((MarginLayoutParams) params).topMargin;
}
contentView.layout(cleft, ctop,
cleft + contentView.getMeasuredWidth(),
ctop + contentView.getMeasuredHeight());
}
mInLayout = false;
}
@Override
public void requestLayout() {
if (!mInLayout) {
super.requestLayout();
}
}
@Override
public void computeScroll() {
//實現(xiàn)fling效果
if (mDragHelper.continueSettling(true)) {
ViewCompat.postInvalidateOnAnimation(this);
}
}
public void setContentView(View view) {
if(mContentViewRef != null && mContentViewRef.get() != null) {
mContentViewRef.clear();
}
mContentViewRef = new WeakReference<>(view);
}
public void setSlideCallback(SwipeSlideCallback slideCallback) {
mSlideCallback = slideCallback;
}
@Override
protected void onDetachedFromWindow() {
super.onDetachedFromWindow();
recycle();
}
public void recycle() {
if(mContentViewRef != null && mContentViewRef.get() != null) {
mContentViewRef.clear();
}
mSlideCallback = null;
removeAllViews();
}
public int getEdgeFlag() {
return mEdgeFlag;
}
private boolean canChildScrollUp() {
return mScrollChildView != null && ViewCompat.canScrollVertically(mScrollChildView, -1);
}
private boolean canChildScrollDown() {
return mScrollChildView != null && ViewCompat.canScrollVertically(mScrollChildView, 1);
}
private boolean canChildScrollRight() {
return mScrollChildView != null && ViewCompat.canScrollHorizontally(mScrollChildView, 1);
}
private boolean canChildScrollLeft() {
return mScrollChildView != null && ViewCompat.canScrollHorizontally(mScrollChildView, -1);
}
/**
* 設(shè)置滑動方向,left、right、top、bottom
*
* @param edgeFlag the edge flag
*/
@TargetApi(Build.VERSION_CODES.JELLY_BEAN)
public void setEdgeFlag(@EdgeFlag int edgeFlag) {
mEdgeFlag = edgeFlag;
mDragHelper.setEdgeTrackingEnabled(edgeFlag);
}
public void setEdgeMode(int mEdgeMode) {
this.mEdgeMode = mEdgeMode;
}
/**
* 解決滑動沖突,如果當(dāng)前布局中有其他子view需要獲取滑動時間,如viewPager等,則需設(shè)置scrollView.
* @param scrollView : child scroll view
*/
public void setChildScrollView(View scrollView) {
this.mScrollChildView = scrollView;
}
private View getContentView() {
if(mContentViewRef != null && mContentViewRef.get() != null) {
return mContentViewRef.get();
}
Loger.e(TAG,"exception !!! content view ref is null !!!");
return null;
}
private class ViewDragCallback extends ViewDragHelper.Callback {
private float mScrollPercent;
@Override
public boolean tryCaptureView(View view, int pointerId) {
// edgeMode == fullScreen表示全屏滑動,ret直接返回true,邊緣滑動時,根據(jù)isEdgeTouched來做判斷.
boolean ret = mEdgeMode == SwipeConstantUtils.EDGEMODE_FULLSCREEN || (mEdgeMode == SwipeConstantUtils.EDGEMODE_EDGE &&
mDragHelper.isEdgeTouched(mEdgeFlag, pointerId));
boolean directionCheck = false;
if (mEdgeFlag == ViewDragHelper.EDGE_LEFT || mEdgeFlag == ViewDragHelper.EDGE_RIGHT) {
directionCheck = !mDragHelper.checkTouchSlop(ViewDragHelper.DIRECTION_VERTICAL, pointerId);
} else if (mEdgeFlag == ViewDragHelper.EDGE_BOTTOM || mEdgeFlag == ViewDragHelper.EDGE_TOP) {
directionCheck = !mDragHelper
.checkTouchSlop(ViewDragHelper.DIRECTION_HORIZONTAL, pointerId);
}
return ret && (view == getContentView()) && directionCheck;
}
@Override
public int getViewHorizontalDragRange(View child) {
return mEdgeFlag & (ViewDragHelper.EDGE_LEFT | ViewDragHelper.EDGE_RIGHT);
}
@Override
public int getViewVerticalDragRange(View child) {
return mEdgeFlag & (ViewDragHelper.EDGE_BOTTOM | ViewDragHelper.EDGE_TOP);
}
@Override
public void onViewPositionChanged(View changedView, int left, int top, int dx, int dy) {
super.onViewPositionChanged(changedView, left, top, dx, dy);
View contentView = getContentView();
if(contentView != null) {
if ((mEdgeFlag & ViewDragHelper.EDGE_LEFT) != 0) {
mScrollPercent = Math.abs((float)(left - mInsets.left)
/ contentView.getWidth());
}
if ((mEdgeFlag & ViewDragHelper.EDGE_RIGHT) != 0) {
mScrollPercent = Math.abs((float)(left - mInsets.left)
/ contentView.getWidth());
}
if ((mEdgeFlag & ViewDragHelper.EDGE_BOTTOM) != 0) {
mScrollPercent = Math.abs((float)(top - mInsets.top)
/ contentView.getHeight());
}
if ((mEdgeFlag & ViewDragHelper.EDGE_TOP) != 0) {
mScrollPercent = Math.abs((float)top
/ contentView.getHeight());
}
mContentLeft = left;
mContentTop = top;
invalidate();
if (mSlideCallback != null && mScrollPercent < SLIDE_MAX_PERCENT) {
mSlideCallback.onPositionChanged(mScrollPercent);
}
// SCROLLER_MAX_PERCENT = 0.99f
if (mScrollPercent >= SCROLLER_MAX_PERCENT) {
// todo: 添加退出Activity操作,執(zhí)行Activity的onBackPressed()方法
if (mSlideCallback != null) {
mSlideCallback.onSwipeFinished();
}
}
}
}
@Override
public void onViewReleased(View releasedChild, float xvel, float yvel) {
final int childWidth = releasedChild.getWidth();
final int childHeight = releasedChild.getHeight();
boolean fling = false;
int left = mInsets.left, top = 0;
if ((mEdgeFlag & ViewDragHelper.EDGE_LEFT) != 0) {
if (Math.abs(xvel) > mFlingVelocity) {
fling = true;
}
left = xvel >= 0 && (fling || mScrollPercent > mScrollThreshold)
? childWidth + mInsets.left : mInsets.left;
}
if ((mEdgeFlag & ViewDragHelper.EDGE_RIGHT) != 0) {
if (Math.abs(xvel) > mFlingVelocity) {
fling = true;
}
left = xvel <= 0 && (fling || mScrollPercent > mScrollThreshold)
? -childWidth + mInsets.left : mInsets.left;
}
if ((mEdgeFlag & ViewDragHelper.EDGE_TOP) != 0) {
if (Math.abs(yvel) > mFlingVelocity) {
fling = true;
}
top = yvel >= 0 && (fling || mScrollPercent > mScrollThreshold)
? childHeight : 0;
}
if ((mEdgeFlag & ViewDragHelper.EDGE_BOTTOM) != 0) {
if (Math.abs(yvel) > mFlingVelocity) {
fling = true;
}
top = yvel <= 0 && (fling || mScrollPercent > mScrollThreshold)
? -childHeight + mInsets.top : 0;
}
mDragHelper.settleCapturedViewAt(left, top);
invalidate();
}
@Override
public void onViewDragStateChanged(int state) {
super.onViewDragStateChanged(state);
if (mSlideCallback != null) {
mSlideCallback.onStateChanged(state);
}
}
@Override
public int clampViewPositionHorizontal(View child, int left, int dx) {
int ret = mInsets.left;
if (!canChildScrollLeft() && (mEdgeFlag & ViewDragHelper.EDGE_LEFT) != 0) {
ret = Math.min(child.getWidth(), Math.max(left, 0));
} else if (!canChildScrollRight() && (mEdgeFlag & ViewDragHelper.EDGE_RIGHT) != 0) {
ret = Math.min(mInsets.left, Math.max(left, -child.getWidth()));
}
return ret;
}
@Override
public int clampViewPositionVertical(View child, int top, int dy) {
View contentView = getContentView();
if(contentView != null) {
int ret = contentView.getTop();
if (!canChildScrollDown() && (mEdgeFlag & ViewDragHelper.EDGE_BOTTOM) != 0) {
ret = Math.min(0, Math.max(top, -child.getHeight()));
} else if (!canChildScrollUp() && (mEdgeFlag & ViewDragHelper.EDGE_TOP) != 0) {
ret = Math.min(child.getHeight(), Math.max(top, 0));
}
return ret;
}
return 0;
}
}
}
關(guān)鍵代碼都有注釋,并且ViewDragHelper.Callback中每個方法具體用途也已經(jīng)解釋過,不再贅述。
三、顯示上一級Activity的View
如何顯示上一級Activity的view也有兩種普遍做法:
-
當(dāng)前Activity背景設(shè)置為透明:
Activity設(shè)置透明主題,最簡單便捷的一種方式為直接在manifest中對activity設(shè)置一個透明的theme,如:
<resources> <!-- Application theme. --> <style name="AppTheme" parent="@style/Theme.AppCompat.Light.NoActionBar"> <item name="android:windowIsTranslucent">true</item> </style> </resources>然后直接設(shè)置為application或者對應(yīng)activity的theme即可?;蛘咧苯釉O(shè)置activity的
background="@android:color/transparent"也可以達(dá)到同樣效果。但以設(shè)置theme的方式來對activity進(jìn)行統(tǒng)一設(shè)置,往往會改動比較大,比如我們自己實現(xiàn)了一個測滑退出的庫,要接入已經(jīng)上線的工程代碼中,這樣的修改會導(dǎo)致全部activity都跟著修改theme,接入成本略高,所以我們可以采用下面
TranslucentUtils類的方式來對activity做修改,具體代碼如下:public class TranslucentUtils { /** * Convert a translucent themed Activity * {@link android.R.attr#windowIsTranslucent} to a fullscreen opaque * Activity. * <p> * Call this whenever the background of a translucent Activity has changed * to become opaque. Doing so will allow the {@link android.view.Surface} of * the Activity behind to be released. * <p> * This call has no effect on non-translucent activities or on activities * with the {@link android.R.attr#windowIsFloating} attribute. */ public static void convertActivityFromTranslucent(Activity activity) { try { @SuppressLint("PrivateApi") Method method = Activity.class.getDeclaredMethod("convertFromTranslucent"); method.setAccessible(true); method.invoke(activity); } catch (Throwable t) { t.printStackTrace(); } } /** * Convert a translucent themed Activity * {@link android.R.attr#windowIsTranslucent} back from opaque to * translucent following a call to * {@link #convertActivityFromTranslucent(android.app.Activity)} . * <p> * Calling this allows the Activity behind this one to be seen again. Once * all such Activities have been redrawn * <p> * This call has no effect on non-translucent activities or on activities * with the {@link android.R.attr#windowIsFloating} attribute. */ public static void convertActivityToTranslucent(Activity activity) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { convertActivityToTranslucentAfterL(activity); } else { convertActivityToTranslucentBeforeL(activity); } } public static boolean isCanSetActivityToTranslucent() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { return checkActivityToTranslucentAfterL(); } else { return checkActivityToTranslucentBeforeL(); } } @SuppressLint("PrivateApi") private static boolean checkActivityToTranslucentBeforeL() { try { Class<?>[] classes = Activity.class.getDeclaredClasses(); Class<?> translucentConversionListenerClazz = null; for (Class clazz : classes) { if (clazz.getSimpleName().contains("TranslucentConversionListener")) { translucentConversionListenerClazz = clazz; } } Activity.class.getDeclaredMethod(CONVERT_TO_TRANSLUCENT, translucentConversionListenerClazz); return true; } catch (Throwable t) { t.printStackTrace(); return false; } } @SuppressLint("PrivateApi") private static boolean checkActivityToTranslucentAfterL() { try { Activity.class.getDeclaredMethod(GET_ACTIVITY_OPTIONS); Class<?>[] classes = Activity.class.getDeclaredClasses(); Class<?> translucentConversionListenerClazz = null; for (Class clazz : classes) { if (clazz.getSimpleName().contains("TranslucentConversionListener")) { translucentConversionListenerClazz = clazz; } } Activity.class.getDeclaredMethod(CONVERT_TO_TRANSLUCENT, translucentConversionListenerClazz, ActivityOptions.class); return true; } catch (Throwable t) { t.printStackTrace(); return false; } } /** * Calling the convertToTranslucent method on platforms before Android 5.0 */ private static void convertActivityToTranslucentBeforeL(Activity activity) { try { Class<?>[] classes = Activity.class.getDeclaredClasses(); Class<?> translucentConversionListenerClazz = null; for (Class clazz : classes) { if (clazz.getSimpleName().contains("TranslucentConversionListener")) { translucentConversionListenerClazz = clazz; } } @SuppressLint("PrivateApi") Method method = Activity.class.getDeclaredMethod(CONVERT_TO_TRANSLUCENT, translucentConversionListenerClazz); method.setAccessible(true); method.invoke(activity, new Object[] { null }); } catch (Throwable t) { t.printStackTrace(); } } /** * Calling the convertToTranslucent method on platforms after Android 5.0 */ private static void convertActivityToTranslucentAfterL(Activity activity) { try { @SuppressLint("PrivateApi") Method getActivityOptions = Activity.class.getDeclaredMethod(GET_ACTIVITY_OPTIONS); getActivityOptions.setAccessible(true); Object options = getActivityOptions.invoke(activity); Class<?>[] classes = Activity.class.getDeclaredClasses(); Class<?> translucentConversionListenerClazz = null; for (Class clazz : classes) { if (clazz.getSimpleName().contains("TranslucentConversionListener")) { translucentConversionListenerClazz = clazz; } } @SuppressLint("PrivateApi") Method convertToTranslucent = Activity.class.getDeclaredMethod(CONVERT_TO_TRANSLUCENT, translucentConversionListenerClazz, ActivityOptions.class); convertToTranslucent.setAccessible(true); convertToTranslucent.invoke(activity, null, options); } catch (Throwable t) { t.printStackTrace(); } } }我們只需要在需要通過設(shè)置“透明”方式來實現(xiàn)測滑的方案中,針對接入測滑功能的activity,調(diào)用
TranslucentUtils.isCanSetActivityToTranslucent()統(tǒng)一判斷當(dāng)前設(shè)備是否支持反射的方式設(shè)置透明,繼而使用TranslucentUtils.convertActivityToTranslucent(activity)來完成activity透明的設(shè)置。這樣既不影響現(xiàn)有activity的theme設(shè)置,也達(dá)到了我們的目的。雖然我們可以通過TranslucentUtils來做到便捷修改activity為透明,但采用“當(dāng)前Activity背景設(shè)置為透明”的這種方式來完成“測滑退出”的過程2,將會帶來一些意想不到的問題,因為activity設(shè)置了透明背景,那么在啟動當(dāng)前activity后,前一個activity的生命周期將發(fā)生變化,PreActivity將不會執(zhí)行onStop,而是僅執(zhí)行onPause方法。如果你的應(yīng)用在不同activity的生命周期(主要是onPause和onStop)做了一些回收及狀態(tài)處理操作,那這種實現(xiàn)方式無疑是比較蛋疼的,所以這種方式實現(xiàn)過程2雖然簡單,但后續(xù)坑很多 ...
-
在當(dāng)前Activity中繪制前置Activity的DecorView的方式
讓我們回過頭來看文章開頭“頭條”的測滑退出效果,可以發(fā)現(xiàn)其前一個Activity會根據(jù)當(dāng)前頁面滑動的范圍做一個scale動畫,如果我們要實現(xiàn)類似的效果,很顯然采用上面“透明方案”無法做到。既然涉及到對View做動畫,肯定需要首先從當(dāng)前Activity拿到前置Activity的view才可以,我們都知道在Android中Activity并不負(fù)責(zé)控制視圖,真正控制視圖的是Window,而Window作為視圖的承載器,其內(nèi)部持有一個DecorView,這個DecorView才是 view 的根布局(更多Window相關(guān)資料請自行學(xué)習(xí))。所以我們只需要在當(dāng)前Activity中拿到前置Activity的DecorView,在過程1處理滑動時,再對其做各種動畫即可,獲取activity的decorView:
public static View getActivityDecorView(Activity activity) { return activity != null ? activity.getWindow().getDecorView() : null; }這樣我們只需要將過程1與過程2進(jìn)行結(jié)合,就能實現(xiàn)類似的效果,下面將描述最終解決方案。
三、最終方案
通過上述分別講述滑動測滑的兩個過程,我采用的是“ViewDragHelper實現(xiàn)滑動” + “獲取前置Activity DecorView”的方式來實現(xiàn)測滑退出。為了降低已有工程的接入成本,我們可以考慮實現(xiàn)Application.ActivityLifecycleCallbacks接口,在各個activity的生命周期中來接入測滑退出。
首先我們需要在Callback的onActivityCreated方法中將每個已啟動activity保存到:Stack<SoftReference<Activity>> mActivityStack中,這樣便于后續(xù)我們查找前置Activity,然后在需要接入測滑退出功能的activity(可通過注解/基類回調(diào)來判斷當(dāng)前activity是否需要接入)onActivityCreated方法中完成接入操作,其大致流程為:

獲取前置Activity:
private Activity findPreActivity() {
Activity preActivity = null;
Stack<SoftReference<Activity>> activityStack = ActivityManager.getInstance().getActivityStack();
if(!CollectionUtils.isEmpty(activityStack) && activityStack.size() > 1) {
int reciprocalIndex = 2;
SoftReference<Activity> softRA = activityStack.get(activityStack.size() - reciprocalIndex);
while(softRA != null && softRA.get() != null) {
preActivity = softRA.get();
//preActivity是否已經(jīng)finish
if(preActivity.isFinishing()) {
reciprocalIndex++;
if(activityStack.size() < reciprocalIndex) { //無法獲取到當(dāng)前已經(jīng)finish掉之前的activity了,置空.
return null;
} else {
softRA = activityStack.get(activityStack.size() - reciprocalIndex);
}
} else {
return preActivity;
}
}
}
return preActivity;
}
如果前置Activity為空,那么可以考慮取消接入當(dāng)前Activity的測滑退出,或者添加一個默認(rèn)preView。下面我們來看有了前置Activity的DecorView后,如何構(gòu)造當(dāng)前Activity的布局:

從圖中我們可以看到,我們可以將過程1和過程2結(jié)合,并且構(gòu)造一個FrameLayout作為當(dāng)前Activity新的decorView,而將原有decorView添加到包含滑動處理的SwipeLayout中,將前置Activity的decorView添加到maskViewLayout,作為previewContainer,同時還可在maskViewLayout中添加蒙層并且可對整個maskLayout做各種動畫操作。
MaskViewLayout.java 大致代碼如下:
public class MaskViewLayout extends FrameLayout {
private WeakReference<View> mPreContentViewRef;
private float mDragOffset;
private IMaskTransform iMaskTransform;
public MaskViewLayout(@NonNull Context context) {
super(context);
init();
}
public MaskViewLayout(Context context, AttributeSet attributeSet) {
super(context, attributeSet);
init();
}
public MaskViewLayout(Context context, AttributeSet attributeSet, int i) {
super(context, attributeSet, i);
init();
}
private void init() {
setBackgroundColor(Color.WHITE);
}
public void setPreContentView(View view, int backupContentRes) {
if(view == null && backupContentRes != 0) {
//構(gòu)造一個默認(rèn)preView
View backupView = SwipeConstantUtils.inflateView(getContext(),backupContentRes,this);
if(backupView != null) {
backupView.setId(R.id.mask_backup_preview);
backupView.setVisibility(View.GONE);
addView(backupView);
}
return;
}
if (mPreContentViewRef == null || mPreContentViewRef.get() == null || mPreContentViewRef.get() != view) {
if (!(mPreContentViewRef == null || mPreContentViewRef.get() == null)) {
mPreContentViewRef.clear();
}
mPreContentViewRef = new WeakReference(view);
}
}
//設(shè)置滑動距離
public void setDragOffset(float f) {
Object obj = null;
if (mDragOffset < 0.01f && f >= 0.01f) {
obj = 1;
}
this.mDragOffset = f;
if (obj != null) {
setBackupPreviewVisible();
invalidate();
}
}
private void setBackupPreviewVisible() {
View backupView = findViewById(R.id.mask_backup_preview);
if(backupView != null && backupView.getVisibility() != View.VISIBLE) {
backupView.setVisibility(View.VISIBLE);
}
}
@Override
public void draw(Canvas canvas) {
super.draw(canvas);
if (this.mDragOffset <= 0) {
return;
}
View view = getCloneView();
if (view != null) {
drawContentView(view, canvas);
drawMaskView(canvas);
return;
}
drawMaskView(canvas);
return;
}
private void drawContentView(View view, Canvas canvas) {
if (view != null && canvas != null) {
// 對前置contentView做動畫
iMaskTransform.animateContentView(canvas,getWidth(),getHeight(),mDragOffset);
canvas.translate(0.0f, (float) (getHeight() - view.getHeight()));
view.draw(canvas);
invalidate();
}
}
private void drawMaskView(Canvas canvas) {
if (iMaskTransform.isDrawMask()) {
//繪制蒙層
iMaskTransform.drawMask(this,canvas,getWidth(),getHeight(),mDragOffset);
invalidate();
}
}
protected void onDetachedFromWindow() {
super.onDetachedFromWindow();
recycleContentView();
}
private void recycleContentView() {
if (mPreContentViewRef != null && mPreContentViewRef.get() != null) {
mPreContentViewRef.clear();
}
}
public View getCloneView() {
if (mPreContentViewRef == null || mPreContentViewRef.get() == null) {
return null;
}
return mPreContentViewRef.get();
}
public void setMaskTransform(IMaskTransform transform) {
this.iMaskTransform = transform;
}
public void setSlidingVideoHandler(SlidingVideoHandler slidingVideoHandler) {
this.slidingVideoHandler = slidingVideoHandler;
}
}
在SwipebackLayout.java中添加如下方法:
/**
* attach activity to container
*
* @param activity the activity
* @param viewContainer the parent
* @param swipeImplMode swipe implement mode : preview or transparent
*/
public void attachActivityToContainer(Activity activity, ViewGroup viewContainer) {
if(mSwipeActivityRef != null && mSwipeActivityRef.get() != null) {
mSwipeActivityRef.clear();
}
mSwipeActivityRef = new WeakReference<>(activity);
if(mSwipeActivityRef.get() != null) {
ViewGroup decor = (ViewGroup)SwipeConstantUtils.getActivityDecorView(mSwipeActivityRef.get());
if(decor != null) {
ViewGroup decorChild = (ViewGroup)decor.getChildAt(0);
if(decorChild != null) {
decor.removeView(decorChild);
addView(decorChild, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT);
setContentView(decorChild);
if (viewContainer != null) {
viewContainer.addView(this);
decor.addView(viewContainer);
//set id when container add this success.
setId(R.id.swipe_layout);
}
}
}
}
}
并在onViewPositionChanged()方法中添加:
@Override
public void onViewPositionChanged(View changedView, int left, int top, int dx, int dy) {
super.onViewPositionChanged(changedView, left, top, dx, dy);
//此處代碼文章上述已添加
...
// 滑動位置改變時回調(diào)到外部
if (mSlideCallback != null && mScrollPercent < SLIDE_MAX_PERCENT) {
mSlideCallback.onPositionChanged(mScrollPercent);
}
if (mScrollPercent >= SCROLLER_MAX_PERCENT) {
if (mSwipeActivityRef != null && mSwipeActivityRef.get() != null && !mSwipeActivityRef.get()
.isFinishing()) {
Activity activity = mSwipeActivityRef.get();
activity.onBackPressed();
//in case of any activity override onBackPressed,do not finished activity cause exception.
activity.finish();
activity.overridePendingTransition(0, 0);
if (mSlideCallback != null) {
mSlideCallback.onSwipeFinished();
}
}
}
}
}
接下來就是構(gòu)建當(dāng)前Activity接入測滑退出,我們可以使用前置Activity獲取其DecorView后,構(gòu)造MaskViewLayout,并添加到外層FrameLayout中,接著構(gòu)造SwipeSlideCallback回調(diào):
SwipeSlideCallback swipeSlideCallback = new SwipeSlideCallback() {
@Override
public void onStateChanged(int state) {
}
@Override
public void onPositionChanged(float percent) {
maskViewLayout.setDragOffset(percent);
}
@Override
public void onSwipeFinished() {
maskViewLayout.onSwipeFinished();
}
};
最終構(gòu)造SwipeBackLayout,并將其添加到外層FrameLayout中:
backLayout.attachActivityToContainer(activity, frameLayout);
backLayout.setSlideCallback(swipeSlideCallback);
至此,我們就完成了對當(dāng)前Activity接入測滑退出的整體操作。至于如何在onActivityCreated()方法中判斷當(dāng)前activity是否需要接入測滑退出,可通過添加特殊annotation或者基類Activity實現(xiàn)鉤子的方式進(jìn)行判斷,不再贅述。
按照當(dāng)前方案來實現(xiàn)可以有效避免采用“透明背景”方案中影響Activity生命周期的情況,而且可以做到接入成本更低,美中不足就是會增加現(xiàn)有View層級,當(dāng)然滑動處理部分,我們可以采用DrawerLayout或者自定義onTouchEvent的方式(見上文),但仍不可避免增加View層級的問題。如果你有更好的實現(xiàn)方式,歡迎指教~
在最初實現(xiàn)此方案時,同樣遇到了幾個問題比較典型,在此列出:
-
獲取前置Activity的DecorView后,在當(dāng)前頁面繪制時,如果PreDecorView中有用到Fresco庫中GenericDraweeView組件加載圖片時,在7.0以上的設(shè)備中會出現(xiàn)無法正常顯示圖片的情況(使用系統(tǒng)自帶ImageView沒有此問題),該問題原因為:當(dāng) api >= 24(android 7.0以上)時,在
ViewGroup.attachToParent()方法中調(diào)用了ViewGroup.dispatchVisibilityAggregated()方法,而dispatchVisibilityAggregated ()方法最終會調(diào)用各個 子view 的以下方法:onVisibilityAggregated,而系統(tǒng)的ImageView內(nèi)部實現(xiàn)為:public void onVisibilityAggregated(boolean isVisible) { super.onVisibilityAggregated(isVisible); // Only do this for new apps post-Nougat if (mDrawable != null && !sCompatDrawableVisibilityDispatch) { mDrawable.setVisible(isVisible, false); } }也就是設(shè)置了對應(yīng)的 drawable 的
visible = false,但默認(rèn)的 Drawable 在 draw 的時候,不管 visible 的值是 true 還是 false,都會進(jìn)行繪制。而Fresco的GenericDraweeView在實現(xiàn)時,與其綁定的Drawable指定為RootDrawable,RootDrawable在繪制時,會對 isVisible 進(jìn)行判斷,如果 isVisible=false ,那么就不進(jìn)行繪制。所以導(dǎo)致只有 fresco 的圖片會出現(xiàn)此問題! 并且只是在 7.0 以上設(shè)備會出現(xiàn)(因為 7.0 以下不會在 ViewGroup 中回調(diào)到 onVisibilityAggregated 方法)
解決辦法: 重寫DenericDraweeView中onVisibilityAggregated方法如:
@Override public void onVisibilityAggregated(boolean isVisible) { super.onVisibilityAggregated(isVisible); if (getDrawable() != null) { getDrawable().setVisible(true, false); } } -
當(dāng)我們設(shè)置滑動模式為全屏均可滑動時(并非僅邊緣可測滑退出),如果當(dāng)前 Activity 的 contentView 視圖中存在與滑動方向一致的可滑動組件(如 ScrollView, ViewPager, RecyclerView等),內(nèi)部可滑動的View將會無法滾動。出現(xiàn)此問題的原因為 SwipeBackLayout 在處理 touchEvent 時,全部都交給ViewDragHelper 來統(tǒng)一處理,而 ViewDragHelper 沒有檢測其內(nèi)部是否存在可滑動組件,所以導(dǎo)致出現(xiàn)此問題。之前有看到網(wǎng)上有些解法說可以遍歷當(dāng)前 Activity 內(nèi)部的 contentView ,直到找到第一個可滑動組件,但這種解法當(dāng) contentView 內(nèi)部包含多個可滑動組件時,仍會出現(xiàn)問題,而且遍歷查找過程可以通過 Activity 內(nèi)部設(shè)置 childScrollView 的方式來避免。
解決辦法:SwipebackLayout 新增
setChildScrollView(View scrollView)接口,有需要的 Activity 可查找對應(yīng) SwipebackLayout 后,設(shè)置對應(yīng)childScrollView。并且在 ViewDragHelper.Callback 接口 clampViewPositionHorizontal 及 clampViewPositionVertical 中對 childScrollView 是否可滑動添加判斷,如:@Override public int clampViewPositionHorizontal(View child, int left, int dx) { int ret = mInsets.left; if (!canChildScrollLeft() && (mEdgeFlag & ViewDragHelper.EDGE_LEFT) != 0) { ret = Math.min(child.getWidth(), Math.max(left, 0)); } else if (!canChildScrollRight() && (mEdgeFlag & ViewDragHelper.EDGE_RIGHT) != 0) { ret = Math.min(mInsets.left, Math.max(left, -child.getWidth())); } return ret; }詳見上述
SwipeBackLayout.java。 前置 Activity 中如果包含播放組件相關(guān)View,如 SurfaceView 或 TextureView 時,要注意 Player 的播放狀態(tài)控制,避免出現(xiàn)在 onPause() 中暫停,在 onResume 中再次馬上恢復(fù)暫停且立即展示 SurfaceView,可能會出現(xiàn) SurfaceView 位置錯亂問題(具體原因尚不明確)。推薦在 onResume 時暫停播放并且隱藏播放的 SurfaceView,在
SwipeSlideCallback.onSwipeFinished()回調(diào)中采用 Handler.postDelayed(action, 300) 方式延遲展示 SurfaceView ,可以解決此問題。如果有遇到類似問題的朋友有更好的解決辦法,可以聯(lián)系我,多謝~
四、總結(jié)
本文介紹及簡要分析了常見的實現(xiàn)Android測滑退出的幾種常見方案,并且給出了筆者認(rèn)為相對靠譜的一種實現(xiàn)方案,但該方案仍有一定局限性(支持滑動的頁面內(nèi),如果存在與子View沖突的滑動view,僅能支持一個子View,即childScrollView),分析及實現(xiàn)過程難免存在一定錯誤,如有發(fā)現(xiàn),煩請不吝賜教。
鳴謝及參考:
關(guān)于Android實現(xiàn)滑動返回的幾種方法總結(jié)