Android側(cè)滑退出靠譜方案

滑動返回是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)易.gif

今日頭條:

頭條.gif

上面是網(wǎng)易新聞和今日頭條中實現(xiàn)的滑動返回樣式,從gif圖中我們看到,網(wǎng)易新聞在測滑時底部蒙層有一個透明度的變化,而底部Activity樣式并沒發(fā)生變化。而頭條中實現(xiàn)的樣式中,底部Activity(前一個Activity)有一個漸變的動畫。通過這兩種不同的樣式,我們可以將“測滑退出”分為兩個過程:

  1. 實現(xiàn)當(dāng)前Activity跟隨手指進(jìn)行滑動;
  2. 展現(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:

基本用法:
  1. 在自定義View構(gòu)造方法中調(diào)用ViewDragHelper的靜態(tài)工廠方法create()創(chuàng)建ViewDragHelper實例;

  2. 實現(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了。
  3. 在onInterceptTouchEvent()方法里調(diào)用并返回ViewDragHelper的shouldInterceptTouchEvent()方法

  4. 在onTouchEvent()方法里調(diào)用ViewDragHelper()的processTouchEvent()方法。ACTION_DOWN事件發(fā)生時,如果當(dāng)前觸摸點下要拖動的子View沒有消費事件,此時應(yīng)該在onTouchEvent()返回true,否則將收不到后續(xù)事件,不會產(chǎn)生拖動。

  5. 上面幾個步驟已經(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也有兩種普遍做法:

  1. 當(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ù)坑很多 ...

  2. 在當(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方法中完成接入操作,其大致流程為:

測滑流程.png

獲取前置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的布局:

測滑退出-圖.png

從圖中我們可以看到,我們可以將過程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)此方案時,同樣遇到了幾個問題比較典型,在此列出:

  1. 獲取前置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);
        }
    }
    
  2. 當(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

  3. 前置 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),煩請不吝賜教。

鳴謝及參考:

Android ViewDragHelper源碼解析

DrawerLayout滑動范圍的設(shè)置

關(guān)于Android實現(xiàn)滑動返回的幾種方法總結(jié)

bingoogolapple/BGASwipeBackLayout-Android

anzewei/ParallaxBackLayout

ikew0ng/SwipeBackLayout

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

  • 是否好好愛過 by songqiao 對這個題目,我有一點抵觸。愛的純粹讓人不想給予評價,何謂好好愛過,何謂無心輕...
    songqiao姚松喬閱讀 328評論 0 0

友情鏈接更多精彩內(nèi)容