Android側滑返回分析和實現(不高仿微信)

項目地址:SLWidget/SwipeBack
Demo體驗:SLWidget(1.5MB)java

側滑 屏幕旋轉 窗口模式

廢話

不久前淘汰了用了三年多的iPhone6Plus,換了部三星S9+。流暢的吃雞體驗,絲滑的屏幕,超高的性價比(港行還另打了9折),真喜歡的不行。不過從IOS切換到Android,仍是不太適應,首當其衝就是 沒!有!側!滑!返!回! 天天螞蟻森林偷個能量要點無數遍返回鍵,簡直崩潰!因而,熱(喜)愛(歡)工(裝)做(逼)的我,決定在本身的項目中必定要有愛的不行的側滑功能。android

分析

搜一下「Android側滑返回」,如今有不少不少的開源庫做爲選擇。我幾乎把每一種類型都嘗試了一遍,發現了不少不少坑。按照實現方式的不一樣,我把它們大體歸位兩大類:git

  • 不透明方案github

    不透明方案經過註冊ActivityLifecycleCallbacks回調來管理Activity棧,以獲取下層Activity的ContentView,而後在上層Activity進行繪製。canvas

    • 不透明方案分支一bash

      在頂層Activity的DecorView中插入一個Layout。監聽側滑事件,移動頂層Activity的ContentView同時,在該Layout的onDraw中調用View.draw(Canvas canvas)繪製下層Activity的ContentView。形成側滑透視到下層Activity的假象。
      存在問題:當佈局變化或數據更新,如橫豎屏切換、導航欄隱藏、窗口模式、分屏模式等,該假象始終如一不會有對應改變。微信

    • 不透明方案分支二ide

      在頂層Activity的DecorView中插入一個Layout。將下層Activity的ContentView移除,並添加到該Layout中。監聽側滑事件,移動頂層Activity的ContentView,亦可形成側滑透視到下層Activity的假象。此方案比方案一好在:能夠適應部分佈局變化。
      存在問題:下層Activity有數據改變,無對應更新。當頂層Activity重建時(旋轉屏幕、切換窗口模式等),會丟失ContentView中綁定的數據。旋轉屏幕時,若下層Activity有對應兩套佈局,該假象露餡。佈局

  • 透明方案性能

    經過設置窗口透明,真正透視到下層Activity的界面。

    • 透明方案一

      在styles中配置以下兩條屬性:

      <item name="android:windowBackground">@android:color/transparent</item>
      <item name="android:windowIsTranslucent">true</item>
      複製代碼

      而後監聽側滑事件,移動頂層Activity的ContentView,便可真正透視到下層Activity的界面。此時不管佈局變化、數據更新,都沒問題。BUT!該方案問題多如牛毛。。。
      存在問題windowIsTranslucent爲true會引發一系列的動畫問題,如先後臺切換動畫、Activity回退動畫等。網上有解決方案說設置"android:windowEnterAnimation""android:windowExitAnimation",經測試並沒有卵用。同時,在SDK26(Android8.0)及以上,會與固定屏幕方向衝突形成閃退。同時,下層的Activity只會進入onPause狀態,不會onStop,當頁面開啓過多時,必定會讓你崩潰。

    • 透明方案二

      如透明方案一,依舊在styles中配置那兩條屬性,在onPause中利用反射將窗口轉爲不透明,在onResume再利用反射將窗口轉爲透明。彷佛醬紫很順利地解決了下層如下的Activity不會onStop致使的性能問題。BUT!該方案問題依舊可怕。。。
      存在問題:因頂層Activity透明,旋轉屏幕時下層Activity會重建,而後在onResume中將窗口轉爲透明,而後下下層Activity也跟着復活了。。。一系列連鎖反應,簡直可怕!同時,windowIsTranslucent爲true引發一系列的動畫問題依然沒有獲得解決。

實現

經以上可知,要想側滑時看到的不是假象,窗口必須透明讓下層的Activity接收佈局變化和數據更新。可是窗口透明會影響動畫效果,且和屏幕旋轉產生衝突。那麼是否能夠只在側滑時窗口保持透明?
ofcourse~
咱們能夠在側滑觸發時利用反射將窗口轉爲透明,在側滑結束時利用反射將窗口轉爲不透明。這樣既能夠在側滑時一窺下層Activity真容,又不會和屏幕旋轉衝突,也不會影響到動畫的使用。原理很簡單,下面開始一步步實現。

注:有同窗問到Android P中禁止了非SDK接口的使用,可是窗口透明轉換的接口均屬於淺灰名單,目前不受限制。

  • Step.1 狀態欄透明

    既然要實現側滑返回,狀態欄必然要幹掉,實現沉浸式體驗。這裏很少BB,直接上代碼。

    private boolean setStatusBarTransparent(boolean darkStatusBar) {
         //SDK大於等於24,須要判斷是否爲窗口模式
        boolean isInMultiWindowMode = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && mSwipeBackActivity.isInMultiWindowMode();
        //窗口模式或者SDK小於19,不設置狀態欄透明
        if (isInMultiWindowMode || Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) {
            return false;
        } else if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
            //SDK小於21
            mSwipeBackActivity.getWindow().addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS);
        } else {
            //SDK大於等於21
            int systemUiVisibility = View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN | View.SYSTEM_UI_FLAG_LAYOUT_STABLE;
             //SDK大於等於23支持翻轉狀態欄顏色
            if (darkStatusBar && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
                //設置狀態欄文字&圖標暗色
                systemUiVisibility |= View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR;
            }
            //去除狀態欄背景
            mDecorView.setSystemUiVisibility(systemUiVisibility);
            //設置狀態欄透明
            mSwipeBackActivity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS);
            mSwipeBackActivity.getWindow().addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS);
            mSwipeBackActivity.getWindow().setStatusBarColor(Color.TRANSPARENT);
        }
        //監聽DecorView的佈局變化
        mDecorView.addOnLayoutChangeListener(mPrivateListener);
        return true;
    }
    複製代碼

    這裏有幾個要注意的地方。
    I. SDK小於19是不支持狀態欄透明的,SD21及以上的實現方式也有所不一樣。
    II. SD23及以上支持狀態欄顏色反轉。
    III. SD24及以上支持窗口模式,這裏要進行判斷,當窗口模式時,不要設置狀態欄透明。
    IV. 狀態欄設置透明以後,輸入法的adjustResize會失效。網傳解決方案android:fitsSystemWindows="true"不推薦使用,由於這會致使沒法在狀態欄之下進行繪製。所以這裏對DecorView佈局變化進行監聽,佈局變化時動態調整子View的高度爲DecorView的可見部分。貼一下代碼:

    public void onLayoutChange(View v, int left, int top, int right, int bottom, int oldLeft, int oldTop, int oldRight, int oldBottom) {
            //獲取DecorView的可見區域
            Rect visibleDisplayRect = new Rect();
            mDecorView.getWindowVisibleDisplayFrame(visibleDisplayRect);
            /**這裏省略一小段代碼,後文說起*/
            //狀態欄透明狀況下,輸入法的adjustResize不會生效,這裏手動調整View的高度以適配
            if (isStatusBarTransparent()) {
                for (int i = 0; i < mDecorView.getChildCount(); i++) {
                    View child = mDecorView.getChildAt(i);
                    if (child instanceof ViewGroup) {
                        //獲取DecorView的子ViewGroup
                        ViewGroup.LayoutParams childLp = child.getLayoutParams();
                        //調整子ViewGroup的paddingBottom
                        int paddingBottom = bottom - visibleDisplayRect.bottom;
                        if (childLp instanceof ViewGroup.MarginLayoutParams) {
                            //此處減去bottomMargin,是考慮到導航欄的高度
                            paddingBottom -= ((ViewGroup.MarginLayoutParams) childLp).bottomMargin;
                        }
                        paddingBottom = Math.max(0, paddingBottom);
                        if (paddingBottom != child.getPaddingBottom()) {
                            //調整子ViewGroup的paddingBottom,以保證整個ViewGroup可見
                            child.setPadding(child.getPaddingLeft(), child.getPaddingTop(), child.getPaddingRight(), paddingBottom);
                        }
                        break;
                    }
                }
            }
        }
    複製代碼

    這裏一樣有兩個小點須要注意:一個是paddingBottom的計算必定要考慮到導航欄高度的計算。還有就是paddingBottom不能爲負值。

  • Step.2 支持側滑

    狀態欄已經透明瞭,下一步就是讓咱們的界面能夠滑動起來。這裏咱們在Activity的dispatchTouchEvent方法中實現。
    首先,在dispatchTouchEventACTION_DOWN事件中判斷按壓區域是否爲側邊,並進行標記。
    而後,在dispatchTouchEventACTION_MOVE事件中判斷移動方向,並標記。若是是橫向滑動,則對ContentView的父容器調用setTranslationX設置偏移值,讓界面動起來。爲何是ContentView的父容器呢?由於ContentView不包含ActionBar,雖然不推薦使用ActionBar。。。
    最後,在dispatchTouchEventACTION_UP事件中進行距離判斷,根據末速度和位移判斷是否finish當前頁面。 讓頁面滑動起來的基本思路就醬紫了。BUT,這其間還涉及到多點觸摸、子View的Touch事件取消、末速度計算、鬆手後的動畫處理等等。限於這塊代碼有點多也不是重點,這裏就不貼出來了。有興趣詳細瞭解的同窗請閱讀源碼

  • Step.3 窗口透明

    到了這一步可能不少同窗要問了,爲毛我滑動以後底下黑黢黢的。別急,由於咱們尚未甩出王炸。前面說了,咱們須要在側滑觸發時利用反射將窗口轉爲透明,在側滑結束時利用反射將窗口轉爲不透明。上一步已經講解了如何讓頁面滑動起來,剩下的就好辦了。請看王炸代碼:

    //將窗口轉爲透明
    private void convertToTranslucent(Activity activity) {
        if (activity.isTaskRoot()) return;//棧底Activity不處理
        isTranslucentComplete = false;//轉換完成標誌
        try {
            //獲取透明轉換回調類的class對象
            if (mTranslucentConversionListenerClass == null) {
                Class[] clazzArray = Activity.class.getDeclaredClasses();
                for (Class clazz : clazzArray) {
                    if (clazz.getSimpleName().contains("TranslucentConversionListener")) {
                        mTranslucentConversionListenerClass = clazz;
                    }
                }
            }
            //代理透明轉換回調
            if (mTranslucentConversionListener == null && mTranslucentConversionListenerClass != null) {
                InvocationHandler invocationHandler = new InvocationHandler() {
                    @Override
                    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
                        isTranslucentComplete = true;
                        return null;
                    }
                };
                mTranslucentConversionListener = Proxy.newProxyInstance(mTranslucentConversionListenerClass.getClassLoader(), new Class[]{mTranslucentConversionListenerClass}, invocationHandler);
            }
            //利用反射將窗口轉爲透明,注意SDK21及以上參數有所不一樣
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
                Object options = null;
                try {
                    Method getActivityOptions = Activity.class.getDeclaredMethod("getActivityOptions");
                    getActivityOptions.setAccessible(true);
                    options = getActivityOptions.invoke(this);
                } catch (Exception ignored) {
                }
                Method convertToTranslucent = Activity.class.getDeclaredMethod("convertToTranslucent", mTranslucentConversionListenerClass, ActivityOptions.class);
                convertToTranslucent.setAccessible(true);
                convertToTranslucent.invoke(activity, mTranslucentConversionListener, options);
            } else {
                Method convertToTranslucent = Activity.class.getDeclaredMethod("convertToTranslucent", mTranslucentConversionListenerClass);
                convertToTranslucent.setAccessible(true);
                convertToTranslucent.invoke(activity, mTranslucentConversionListener);
            }
        } catch (Throwable ignored) {
            isTranslucentComplete = true;
        }
        if (mTranslucentConversionListener == null) {
            isTranslucentComplete = true;
        }
        //去除窗口背景
        mSwipeBackActivity.getWindow().setBackgroundDrawable(null);
    }
    複製代碼
    //將窗口轉爲不透明
    private void convertFromTranslucent(Activity activity) {
        if (activity.isTaskRoot()) return;//棧底Activity不處理
        try {
            Method convertFromTranslucent = Activity.class.getDeclaredMethod("convertFromTranslucent");
            convertFromTranslucent.setAccessible(true);
            convertFromTranslucent.invoke(activity);
        } catch (Throwable t) {
        }
    }
    複製代碼

    代碼有點長,不過很好理解。convertToTranslucent先獲取透明轉換回調類,而後代理透明轉換回調,最後反射將窗口轉爲透明。convertFromTranslucent就很少解釋了。只須要在側滑前調用convertToTranslucent便可將窗口轉爲透明,鬆手後調用convertFromTranslucent便可將窗口還原爲不透明。 你們應該會注意到這裏有個轉換完成的標誌,後面會解釋它的做用。

  • Step.4 底層陰影

    到了這裏,已經基本實現了側滑返回了,就三步走搞定。可是有些同窗可能會以爲沒個陰影很差看啊!這個簡單,咱們自定義一個ShadowView在側滑時跟着調用setTranslationX便可。

    public View getShadowView(ViewGroup swipeBackView) {
        if (mShadowView == null) {
            mShadowView = new ShadowView(mSwipeBackActivity);
            mShadowView.setTranslationX(-swipeBackView.getWidth());
            ((ViewGroup) swipeBackView.getParent()).addView(mShadowView, 0, new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
        }
        return mShadowView;
    }
    複製代碼

    這裏的swipeBackView即上文 Step.2 支持側滑 提到的ContentView的父容器,將ShadowView插入到swipeBackView的父容器中。可能沒有人注意到,這個getShadowView方法是public的,由於我這樣想的,也許有人不喜歡我這個陰影恰恰要在側滑的時候看到個皮卡丘呢?你說是吧。。。
    另外到了這一步就不得不說,但凡有幾我的用的側滑返回庫,都支持微信那樣下層Activity聯動的,這裏爲了點題,咱(其)們(實)就(是)不(懶)支(癌)持(復)了(發)。

注意事項

經以上簡單四步,基本上效果已經很棒了。不過還有一些須要特別注意的地方,以及前面佔了兩個坑,如今進行回填。

  • Tips.1

    先填掉前面講解DecorView的佈局變化監聽時佔的坑。當佈局變化時,咱們經過調整DecorView子View的paddingBottom來達到適配輸入法的adjustResize。這裏就會致使一個問題,輸入法的彈出有一個由下往上的動畫,在動畫這段時間內,這一塊位置會顯示窗口的顏色的—黑黢黢。這對於追求完美的人來講固然不能忍,咱們的解決辦法是new一個View堵住那塊黑黢黢。是否是方法有點土。。。不過很湊效。。。
    貼上前文onLayoutChange代碼塊中遺失的代碼:

    mWindowBackGroundView = getWindowBackGroundView(mDecorView);
            if (mWindowBackGroundView != null) {
                //堵住黑黢黢的那塊
                mWindowBackGroundView.setTranslationY(visibleDisplayRect.bottom);
            }
    複製代碼
  • Tips.2

    在前面窗口透明處理中,也留了個坑:透明轉換完成標誌isTranslucentComplete。爲何要這個呢?由於將窗口轉爲透明須要約100ms左右的時間,若是在轉換完成以前就移動了ContentView,你會看到底下又是一片黑黢黢。。。這固然非吾所願,所以在移動以前判斷若窗口還未轉爲透明,則不進行處理

    private void swipeBackEvent(int translation) {
        if (!isTranslucentComplete) return;
        if (mShadowView.getBackground() != null) {
            int alpha = (int) ((1F - 1F * translation / mShadowView.getWidth()) * 255);
            alpha = Math.max(0, Math.min(255, alpha));
            mShadowView.getBackground().setAlpha(alpha);
        }
        mShadowView.setTranslationX(translation - mShadowView.getWidth());
        mSwipeBackView.setTranslationX(translation);
    }
    複製代碼

    這裏可能有同窗要說了,轉換完成以前不處理,轉換完成以後,這不是會忽然跳一下麼。好比從0忽然跳到100的位置。思路很嚴謹,不過由於窗口轉換100ms左右,除非是手速飛快,否則沒多少距離,基本看不出來。若是手速飛快,變化太快也基本看不清前面究竟是漸變仍是突變。因此這樣處理挺好的。。。

  • Tips.3

    側滑鬆手後會出現兩種狀況,其一回到左側原點,其二繼續滑動到右側邊界而後finish該Activity。前面提到側滑鬆手後須要將窗口轉爲不透明。須要注意的是,若是會finish該Activity,請勿將窗口轉爲不透明。由於下層的Activity此時是透上來的,若是轉爲不透明,而後finish頂層Activity,會閃現一下黑色窗口。 另外finish以後要取消Activity的退出動畫。

    public void onAnimationEnd(Animator animation) {
            if (!isAnimationCancel) {
                //最終移動距離位置超過半寬,結束當前Activity
                if (mShadowView.getWidth() + 2 * mShadowView.getTranslationX() >= 0) {
                    mShadowView.setVisibility(View.GONE);
                    mSwipeBackActivity.finish();
                    mSwipeBackActivity.overridePendingTransition(-1, -1);//取消返回動畫
                } else {
                    mShadowView.setTranslationX(-mShadowView.getWidth());
                    mSwipeBackView.setTranslationX(0);
                    convertFromTranslucent(mSwipeBackActivity);
                }
            }
        }
    複製代碼
  • Tips.4

    側滑的核心原理是利用反射轉換窗口透明,在前面摸索透明方案中有提到,窗口透明會影響下層Activity的生命週期。當咱們將窗口轉爲透明時,下層Activity會被喚醒,進入onStart狀態,若是發生屏幕旋轉,下層Activity還將會進行重建。當咱們將窗口恢復爲不透明,下層Activity會從新進入onStop狀態。所以若是你的Activity代碼邏輯比較混亂,使用以前務必進行邏輯優化。

  • Tips.5

    當頂層Activity方向與下層Activity方向不一致時側滑會失效(下層方向未鎖定除外),請關閉該層Activity側滑功能。示例場景:豎屏界面點擊視頻,進入橫屏播放。這個很好理解,例如頂層Activity橫屏,下層鎖定豎屏,當側滑時,窗口究竟是橫屏仍是豎屏,It's a question...

  • Tips.6

    由於狀態欄透明,佈局會從屏幕頂端開始繪製,Toolbar須要增長一個狀態欄高度的paddingTop

    //獲取狀態欄高度
    public int getStatusBarHeight() {
        int resourceId = getResources().getIdentifier("status_bar_height", "dimen", "android");
        try {
            return getResources().getDimensionPixelSize(resourceId);
        } catch (Resources.NotFoundException e) {
            return 0;
        }
    }
    複製代碼
  • Tips.7

    如需動態支持橫豎屏切換(好比APP中有「支持橫屏」開關),屏幕方向需指定爲behind跟隨棧底Activity方向,同時在onCreate中進行判斷,若不支持橫豎屏切換則鎖定屏幕方向(由於經測試SDK21中behind無效)。

  • Tips.8

    可能有同窗會發現,Styles中的"android:windowBackground"屬性失效了,是由於須要透視到下層Activity因此去掉了這個背景。詳見convertToTranslucent方法的最後一行:

    private void convertToTranslucent(Activity activity) {
        if (activity.isTaskRoot()) return;
        ...
        //去除窗口背景
        mSwipeBackActivity.getWindow().setBackgroundDrawable(null);
    }
    複製代碼

    固然,對棧底Activity及未產生側滑的Activity是不受影響的。
    另外在SDK21(Android5.0)如下必須指定<item name="android:windowIsTranslucent">true</item>,由於在SDK21(Android5.0)如下,反射調用的convertToTranslucent方法只能將【由convertFromTranslucent轉換的不透明】轉爲透明,不能將本來就不透明的窗口轉爲透明。

END

絮叨一通,全是大段文字。限於我的能力有限,不免存在些許疏忽失誤,歡迎指正。若有更好的思路也請不吝賜教,此文權當拋磚引玉。

項目地址:SLWidget/SwipeBack(含依賴使用方法及說明,歡迎Star,歡迎Fork
Demo體驗:SLWidget(1.5MB)

最後感謝如下博文,讓我受益不淺(有所疏漏,敬請諒解)

永遠即等待 | Android滑動返回(SlideBack for Android)
HolenZhou | Android版與微信Activity側滑後退效果徹底相同的SwipeBackLayout
Ziv_xiao | Android右滑退出+沉浸式(透明)狀態欄
掛雲帆love | 仿微信滑動返回,實現背景聯動(1、原理)

相關文章
相關標籤/搜索