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

SimonLeeeeeeeee發表於2018-08-06

專案地址:SLWidget/SwipeBack
Demo體驗:SLWidget(1.5MB)

側滑 螢幕旋轉 視窗模式
Android側滑返回分析和實現(不高仿微信)
Android側滑返回分析和實現(不高仿微信)
Android側滑返回分析和實現(不高仿微信)

廢話

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

分析

搜一下“Android側滑返回”,現在有很多很多的開源庫作為選擇。我幾乎把每一種型別都嘗試了一遍,發現了很多很多坑。按照實現方式的不同,我把它們大致歸位兩大類:

  • 不透明方案

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

    • 不透明方案分支一

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

    • 不透明方案分支二

      在頂層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真容,又不會和螢幕旋轉衝突,也不會影響到動畫的使用。原理很簡單,下面開始一步步實現。

  • 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 | 仿微信滑動返回,實現背景聯動(一、原理)

相關文章