Android版與微信Activity側滑後退效果完全相同的SwipeBackLayout

本文緣起

因爲我做的app裏使用了SwipeBackHelper的開源庫來實現Activity的側滑後退,本來使用起來一直沒什麼問題,但在新版本中接入了騰訊x5內核的WebView後就出現了一個小問題。看下圖:


圖1

圖2

圖2中兩條黑線之間就是圖1中所展示的視頻播放的區域,但圖2中顯示的不是視頻內容,而是當前的WebActivity下層的MainActivity的部分視圖。因爲當進入網頁播放頁面點擊視頻播放按鈕後,視頻播放區域會突然變成透明的,直到視頻加載出來之後纔會開始顯示視頻內容,該過程持續1秒到數秒不等。本來如果只是閃現一下就消失也沒什麼大問題,但有的網頁中的視頻加載過慢,導致這個透明現象出現的時間過長,所以app運營渠道提出需要解決該問題。

問題分析

經測試,該問題出現是因爲滿足了兩個條件:
1.Activity的主題style中滿足屬性:<item name="android:windowIsTranslucent">true</item> (這也是使用SwipeBackHelper的必要條件);
2.使用x5內核的WebView播放視頻。
對於我們的項目來說,x5是不能放棄的,但側滑退出的效果在三個版本之前就加入了,現在要針對某些頁面去掉,也讓我覺得很不爽。此時當然是參考微信的效果嘍,結果微信給我的結果是這樣的:


微信x5內核WebView播放視頻效果

微信同樣是使用x5內核,同樣具有側滑退出得效果,當播放相同視頻時,本該顯示透明的區域卻顯示的是黑色的背景。微信究竟是如何解決的呢?
我嘗試了給WebView增加背景色,給WebView增加父容器後再增加背景色,給Activity的Window和DecorView設置背景色,但沒有作用。只要Activity的主題style中設置了窗體透明,該問題無論如何都會出現。

問題解決

無奈之下,我嘗試解決這個問題,雖然說是個小問題,着實花了一番功夫。下面我會從三個方面來說明我在尋求解決方案的過程中學習和總結到的一些東西。因爲這個問題遇到的人不多,而且我只是在SwipeBackHelper的源碼基礎上做了一些修改,所以就不上傳代碼到github了,但我會詳細說明我修改的過程和原理,相信讀完本文,你會對SwipeBackHelper的工作原理有更多地瞭解,也會瞭解到通過反編譯成熟apk尋找解決方案的學習方法。

一. SwipeBackHelper的實現原理

其實我搜索了很久找其他實現側滑後退的方案,但發現不管什麼方案,設置<item name="android:windowIsTranslucent">true</item>這一條件都被聲明爲必要條件,否則就會出現側滑時出現下層背景爲黑的bug。所以最終我只有閱讀一下源碼來看看側滑後退的原理究竟是什麼。大家搜索時會發現github上有一個star數量更多的相關項目SwipeBackLayout,我看了兩個項目各自的代碼,從github分支推送的時間來看,SwipeBackLayout是最先出現的。兩者的代碼80%的代碼是相似的,SwipeBackHelper只是在SwipeBackLayout的基礎上對其中的主要控件進行了解耦,提取出來了一個SwipeBackHelper和SwipeBackPage兩個管理類,使用法更加清晰明瞭,同時實現了當前Activity側滑關閉時與下層Activity的聯動效果,跟微信已經99%相似了(是的,我要解決的就是那1%的問題)。因爲我項目用的是SwipeBackHelper項目,所以我也是在它的源碼基礎上進行修改的。


SwipeBackHelper源碼文件

源碼並不複雜,具體用法我就不解釋了,項目github上說得很詳細。我簡單說下每個類的主要功能:

  1. SwipeBackLayout,是一個繼承自FrameLayout的ViewGroup,我們側滑後退時滑動的就是這個ViewGroup,需要側滑的Activity執行onCreate時,需要設置setSwipeBackEnable(true),這句代碼執行時會調用SwipeBackLayout的attachToActivity,如下所示,該方法會找到Activity的Window界面的最頂層View,即DecorView,並找到DecorView的直接子view將它替換爲SwipeBackLayout,同時將原來的子view添加到SwipeBackLayout中。這樣一來,SwipeBackLayout就會在Activity的所有佈局(我們自己寫得xml所生成的佈局)之上了),當我們滑動Activity時,如果是在側邊(一般是屏幕左側)可以觸發側滑後退動作的區域內,SwipeBackLayout就會攔截觸摸事件,自己進行處理,執行被拖動或滑動退出的UI效果;

    public void attachToActivity(Activity activity) {
         if (getParent() != null) {
             return;
         }
         mActivity = activity;
         TypedArray a = activity.getTheme().obtainStyledAttributes(new int[]{
                 android.R.attr.windowBackground
         });
         int background = a.getResourceId(0, 0);
         a.recycle();
    
         ViewGroup decor = (ViewGroup) activity.getWindow().getDecorView();
         View decorChild = decor.findViewById(android.R.id.content);
         while (decorChild.getParent() != decor) {
             decorChild = (View) decorChild.getParent();
         }
         decorChild.setBackgroundResource(background);
         decor.removeView(decorChild);
         addView(decorChild);
         setContentView(decorChild);
         decor.addView(this);
     }
  2. ViewDragHelper,實現滑動和拖動的輔助類,其實就是在Android原生的ViewDragHelper上進行了小小的修改,ViewDragHelper是一個非常強大的類,簡單的調用就可以幫我們實現View的滑動和拖動效果,SwipeBackLayout的onInterceptTouchEvent和onTouchEvent的處理都是交給ViewDragHelper來做的,所以要深入理解側滑的實現機制,需要知道ViewDragHelper是如何工作的,感興趣的同學可以直接讀下面兩篇博客,讀完應該就理解得差不多了:
    Android ViewDragHelper完全解析 自定義ViewGroup神器
    Android ViewDragHelper源碼解析

  3. SwipeBackPage,每個滑動頁面的管理類,該類持有當前Activity、與Activity關聯的SwipeBackLayout和一個RelateSlider的引用,並提供一系列鏈式調用的方法設置SwipeBackLayout的相關屬性;

  4. SwipeBackHelper,滑動的全局管理類,也是提供給我們在Activity中開啓側滑退出功能的工具類。在Activity的onCreate中調用SwipeBackHelper的onCreate方法時,其內部會創建一個與該Activity關聯的SwipeBackPage,並通過一個Stack集合記錄管理所有關聯過Activity的SwipeBackPage,需要下層Activity聯動時就可以通過該類的getPrePage獲取到下層Activity相關聯的SwipeBackPage類;

    private static final Stack<SwipeBackPage> mPageStack = new Stack<>();
    ……
    
     public static void onCreate(Activity activity) {
         SwipeBackPage page;
         if ((page = findHelperByActivity(activity)) == null){
             page = mPageStack.push(new SwipeBackPage(activity));
         }
         page.onCreate();
     }
  5. SwipeListener,簡單的接口,提供了觸摸和滑動SwipeBackLayout時的三個回調方法;

  6. RelateSlider,有下層Activity聯動時需要用到的一個類,它實現了SwipeListener接口,在上層Activity的SwipeBackLayout被滑動時,會回調到它實現的onScroll和onScrollToClose方法,從而實現下層Activity的SwipeBackLayout位置的改變,達到聯動的效果。

  7. Utils 最不起眼的一個類,在這個項目中都沒用到好伐。不過正是這個類,纔是我解決問題的關鍵,這個類的源碼不太對,後面我會貼出修改後的代碼。

二. 反編譯微信apk尋找靈感

雖然瞭解了SwipeBackHelper的實現原理,但剛開始我還是想不通微信是如何處理我開頭提出的問題。我Google了大半天都找不出有人有類似的問題,索性直接反編譯微信apk,看看能不能找到一些端倪,沒想到,還真被我找到了。


微信的SwipeBackLayout

在反編譯後的java代碼中,我找到了一個SwipeBackLayout的類,很明顯,微信側滑後退的實現方式跟上面開源庫的差不多,只不過人家自己做了整合和優化。我一眼看到"convertToTranslucent",就知道這個肯定跟處理透明問題有關,後來我才發現原來同時出現在SwipeBackHelperSwipeBackLayout項目中的Utils中寫的正是反射調用Activity的"convertToTranslucent"方法,而且在SwipeBackLayout中的Utils是被使用過的,使用時機是在SwipeBackLayout的onEdgeTouch回掉中,也就是在側滑動作觸發之前。而這個"convertToTranslucent"方法的作用正是讓不透明的Activity轉爲透明。
5.0及其以上版本的Activity中的convertToTranslucent方法:

  /**
     * Convert a translucent themed Activity {@link android.R.attr#windowIsTranslucent} back from
     * opaque to translucent following a call to {@link #convertFromTranslucent()}.
     * <p>
     * Calling this allows the Activity behind this one to be seen again. Once all such Activities
     * have been redrawn {@link TranslucentConversionListener#onTranslucentConversionComplete} will
     * be called indicating that it is safe to make this activity translucent again. Until
     * {@link TranslucentConversionListener#onTranslucentConversionComplete} is called the image
     * behind the frontmost Activity will be indeterminate.
     * <p>
     * This call has no effect on non-translucent activities or on activities with the
     * {@link android.R.attr#windowIsFloating} attribute.
     *
     * @param callback the method to call when all visible Activities behind this one have been
     * drawn and it is safe to make this Activity translucent again.
     * @param options activity options delivered to the activity below this one. The options
     * are retrieved using {@link #getActivityOptions}.
     * @return <code>true</code> if Window was opaque and will become translucent or
     * <code>false</code> if window was translucent and no change needed to be made.
     *
     * @see #convertFromTranslucent()
     * @see TranslucentConversionListener
     *
     * @hide
     */
    @SystemApi
    public boolean convertToTranslucent(TranslucentConversionListener callback,
            ActivityOptions options) {
        boolean drawComplete;
        try {
            mTranslucentCallback = callback;
            mChangeCanvasToTranslucent =
                    ActivityManagerNative.getDefault().convertToTranslucent(mToken, options);
            WindowManagerGlobal.getInstance().changeCanvasOpacity(mToken, false);
            drawComplete = true;
        } catch (RemoteException e) {
            // Make callback return as though it timed out.
            mChangeCanvasToTranslucent = false;
            drawComplete = false;
        }
        if (!mChangeCanvasToTranslucent && mTranslucentCallback != null) {
            // Window is already translucent.
            mTranslucentCallback.onTranslucentConversionComplete(drawComplete);
        }
        return mChangeCanvasToTranslucent;
    }

5.0以下版本的Activity中的convertToTranslucent方法:

/**
     * 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.
     *
     * @see #convertToTranslucent(android.app.Activity.TranslucentConversionListener,
     * ActivityOptions)
     * @see TranslucentConversionListener
     *
     * @hide
     */
    @SystemApi
    public void convertFromTranslucent() {
        try {
            mTranslucentCallback = null;
            if (ActivityManagerNative.getDefault().convertFromTranslucent(mToken)) {
                WindowManagerGlobal.getInstance().changeCanvasOpacity(mToken, true);
            }
        } catch (RemoteException e) {
            // pass
        }
    }

既然如此,那麼我將我的WebActivity主題的android:windowIsTranslucent設置爲false,然後在側滑被觸發之前調用convertToTranslucent不就好了。
事實證明的確是可以的,但有兩個明顯不好的地方在於:

  1. 反射調用convertToTranslucent方法會使相關聯的Activity重繪,測試發現這個過程需要100ms的時間,所以如果側滑動作很快,就會出現黑邊閃現,體驗不太好;
    2.如果側滑動作進行一半,用戶又滑回去了選擇暫時不關閉Activity,其實Activity已經轉換成透明瞭,再播放視頻的話透明現象還會出現。對於這個問題,我本來覺得可以在它滑回的時候調用Utils中的convertActivityFromTranslucent再將Activity轉爲不透明,但測試發現,這樣反轉一下後,視頻播放區域就直接全黑了,再也不出現視頻內容了。

對於問題2,我在微信上進行了嘗試,不得不說我機智地發現微信並沒有處理這種情況:



上圖中視頻區域顯示的是下層Activity的內容(我的聊天窗口)。
一方面這個問題確實難以解決,另一方面用戶進行問題2所述操作的概率並不會很高,所以這種問題暫時就參考微信,不去解決了。
真正讓我鬱悶的還是問題1,看到微信怎麼滑都不會有黑邊的效果,我還是決定嘗試將它徹底解決。

三. 解決問題的終極姿勢

快速滑動出現黑邊問題的根本原因是convertToTranslucent是需要100ms左右的時間的,而且這個事件不固定跟手機的硬件配置有關,所以思路是先等待convertToTranslucent成功的回調,然後再觸發Activity的側滑。

 /**
     * Calling the convertToTranslucent method on platforms after Android 5.0
     */
    private static void convertActivityToTranslucentAfterL(Activity activity) {
        try {
            Method getActivityOptions = Activity.class.getDeclaredMethod("getActivityOptions");
            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;
                }
            }
            Method convertToTranslucent = Activity.class.getDeclaredMethod("convertToTranslucent",
                    translucentConversionListenerClazz, ActivityOptions.class);
            convertToTranslucent.setAccessible(true);
            convertToTranslucent.invoke(activity, null, options);
        } catch (Throwable t) {
        }
    }

然而調用Activity的convertToTranslucent方法本來就是通過反射的方式,無法直接傳入回調接口。這樣一來只有通過動態代理的方式了。我的這個想法在我重新看微信反編譯代碼時得到了印證:


微信也是通過動態代理獲取convertToTranslucent成功的回調

首先在Utils中增加一個繼承自InvocationHandler的類:

    public interface PageTranslucentListener {
        void onPageTranslucent();
    }

    static class MyInvocationHandler implements InvocationHandler {
        private static final String TAG = "MyInvocationHandler";
        private WeakReference<PageTranslucentListener> listener;

        public MyInvocationHandler(WeakReference<PageTranslucentListener> listener) {
            this.listener = listener;
        }

        @Override
        public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
            Log.d(TAG, "invoke: end time: " + System.currentTimeMillis());
            Log.d(TAG, "invoke: 被回調了");
            try {
                boolean success = (boolean) args[0];
                if (success && listener.get() != null) {
                    listener.get().onPageTranslucent();
                }
            } catch (Exception e) {
                e.printStackTrace();
            }
            return null;
        }
    }

然後改造一下原來的convertActivityToTranslucentAfterL方法,convertActivityToTranslucentBeforeL同理:

    private static void convertActivityToTranslucentAfterL(Activity activity, PageTranslucentListener listener) {
        try {
            Method getActivityOptions = Activity.class.getDeclaredMethod("getActivityOptions");
            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;
                }
            }


            MyInvocationHandler myInvocationHandler = new MyInvocationHandler(new WeakReference<PageTranslucentListener>(listener));
            Object obj = Proxy.newProxyInstance(Activity.class.getClassLoader(), new Class[]{translucentConversionListenerClazz}, myInvocationHandler);

            Method convertToTranslucent = Activity.class.getDeclaredMethod("convertToTranslucent",
                    translucentConversionListenerClazz, ActivityOptions.class);
            convertToTranslucent.setAccessible(true);
            Log.d("MyInvocationHandler", "start time: " + System.currentTimeMillis());
            convertToTranslucent.invoke(activity, obj, options);
        } catch (Throwable t) {
        }
    }

原來調用convertToTranslucent的時機是在onEdgeTouch回調中,但這樣會導致只要觸摸到屏幕左側就會執行convertToTranslucent而且觸摸事件會不止一次回調。所以這裏調用時機改到ViewDragHelper.Callback的onEdgeDragStarted回調中,只有當SwipeBackLayout開始動了才調用,並且只會調用一次:

        @Override
        public void onEdgeDragStarted(int edgeFlags, int pointerId) {
            super.onEdgeDragStarted(edgeFlags, pointerId);
            Log.d("translucentTest", "onEdgeDragStarted");
            Utils.convertActivityToTranslucent(mActivity, new Utils.PageTranslucentListener() {
                @Override
                public void onPageTranslucent() {
                    setPageTranslucent(true);
                    Log.d("translucentTest", "onPageTranslucent: ");
                }
            });
        }

SwipeBackLayout中增加下面的成員pageTranslucent和兩個方法以作設置和標識,pageTranslucent默認值爲true:

    private boolean pageTranslucent = true;

    public void setPageTranslucent(boolean pageTranslucent) {
        this.pageTranslucent = pageTranslucent;
    }

    public boolean isPageTranslucent() {
        return pageTranslucent;
    }

有了上述標識,我們就可以知道當前的Activity是否是透明的。
有兩個地方需要處理:

  1. 在手指嘗試滑動SwipeBackLayout時,判斷pageTranslucent是否爲true,爲true才允許被滑動。而通過分析ViewDragHelper的源碼可知,它的dragTo()方法是唯一觸發拖動行爲的方法。所以在dragTo()方法中加入如下兩處判斷:

     private void dragTo(int left, int top, int dx, int dy) {
         int clampedX = left;
         int clampedY = top;
         final int oldLeft = mCapturedView.getLeft();
         final int oldTop = mCapturedView.getTop();
         if (dx != 0) {
             clampedX = mCallback.clampViewPositionHorizontal(mCapturedView, left, dx);
             Log.d("translucentTest", "dragTo: mCallback.isPageTranslucent()-->" + mCallback.isPageTranslucent());
             //增加是否透明的判斷
             if (mCallback.isPageTranslucent()) {
                 mCapturedView.offsetLeftAndRight(clampedX - oldLeft);
             }
         }
         if (dy != 0) {
             clampedY = mCallback.clampViewPositionVertical(mCapturedView, top, dy);
             mCapturedView.offsetTopAndBottom(clampedY - oldTop);
         }
    
         if (dx != 0 || dy != 0) {
             final int clampedDx = clampedX - oldLeft;
             final int clampedDy = clampedY - oldTop;
             //增加是否透明的判斷
             if (mCallback.isPageTranslucent()) {
                 mCallback.onViewPositionChanged(mCapturedView, clampedX, clampedY, clampedDx, clampedDy);
             }
         }
     }

    在Callback中增加回調方法isPageTranslucent()並在SwipeBackLayout中如下實現即可:

         public boolean isPageTranslucent() {
             return SwipeBackLayout.this.isPageTranslucent();
         }

2.在手指鬆開時,會回調CallBack的onViewReleased()方法,SwipeBackLayout實現了此方法,判斷滑回左邊還是滑到最右邊關閉Activity:

        @Override
        public void onViewReleased(View releasedChild, float xvel, float yvel) {
            final int childWidth = releasedChild.getWidth();

            int left = 0, top = 0;
            //判斷釋放以後是應該滑到最右邊(關閉),還是最左邊(還原)
            left = xvel > 0 || xvel == 0 && mScrollPercent > mScrollThreshold ? childWidth
                    + mShadowLeft.getIntrinsicWidth() + OVERSCROLL_DISTANCE : 0;

            // settleCapturedViewAt中調用了ViewDragHelper內部mScroller的startScroll()方法,然後通過invalidate刷新就可以觸發SwipeBackLayout的自行滾動
            mDragHelper.settleCapturedViewAt(left, top);
            invalidate();
        }

所以在這裏還是要判斷一下,如果當前Activity不透明,那麼手指鬆開後也不進行滑動。
但改完這裏測試時發現了一個問題,就是低於21版本的手機執行convertActivityToTranslucentBeforeL()方法時怎麼也不起作用,經過一番折騰我找到了原因。原來我一直忽略了Activity的convertToTranslucent方法的真正用法,關於這個方法Activity源碼中有註釋說明,高低版本中均有提到:

Convert a translucent themed Activity {@link android.R.attr#windowIsTranslucent} back from opaque to translucent following a call to {@link #convertFromTranslucent()}.
……
This call has no effect on non-translucent activities or on activities with the {@link android.R.attr#windowIsFloating} attribute.

意思是說該方法的作用是,在Activity被convertFromTranslucent方法轉爲不透明之後,將其再從不透明轉爲透明。而且該方法對本來不透明的Activity是沒有作用的。所以我們只有在本身就爲透明的Activity中調用convertFromTranslucent將其轉爲不透明之後纔可以通過convertToTranslucent方法將其再轉爲透明。
雖說如此,但api21以上的手機確實是可以直接將本身主題不透明的Activity轉爲透明的,21一下的就不行。所以爲了兼容,我還是統一將Activity的主題設置爲透明,而針對還有web頁面的Activity,再它的onCreate方法中先調用convertFromTranslucent轉爲不透明,設置其SwipeBackLayout的pageTranslucent爲false,再在側滑開始時調用convertToTranslucent將其轉爲透明.

        //在Activity的onCreate中做如下設置
        //將Activity轉爲不透明,設置成功,則pageTranslucent爲false,否則爲true
        boolean opaque = Utils.convertActivityFromTranslucent(this); 
        SwipeBackHelper.onCreate(this);
        SwipeBackHelper.getCurrentPage(this)
                .setSwipeBackEnable(true)
                .setPageTranslucent(!opaque);

Utils中的convertActivityFromTranslucent我也做了點改動:

      public static boolean convertActivityFromTranslucent(Activity activity) {
        try {
            Method method = Activity.class.getDeclaredMethod("convertFromTranslucent");
            method.setAccessible(true);
            method.invoke(activity);
            return true;
        } catch (Throwable t) {
            return false;
        }
    }

鏈式調用中的setPageTranslucent(!opaque)方法是我新增在SwipeBackPage類中的:

public void setPageTranslucent(boolean pageTranslucent) {
    mSwipeBackLayout.setPageTranslucent(pageTranslucent);
}

還有一點可能有人會注意到,就是既然調用convertToTranslucent後到接受到回調需要100ms的時間(如果本身是透明,又調用convertToTranslucent,只需要2ms),那麼如果我快速的側滑,在100ms之前就鬆開手指了,豈不是側滑無法響應了,這樣就會出現慢速地話可以滑動,快速滑不能滑動的情況。還有,如果convertToTranslucent出現異常了,pageTranslucent始終爲false,豈不是也滑不動了。
確實,這兩個問題也着實讓我頭疼了兩個小時。最終我找到了一個取巧的方式解決了,更巧的事,我發現微信也是這樣整的。先看我的代碼:

        @Override
        public void onViewReleased(View releasedChild, float xvel, float yvel) {
            final int childWidth = releasedChild.getWidth();

            int left = 0, top = 0;
            //判斷釋放以後是應該滑到最右邊(關閉),還是最左邊(還原)
            left = xvel > 0 || xvel == 0 && mScrollPercent > mScrollThreshold ? childWidth
                    + mShadowLeft.getIntrinsicWidth() + OVERSCROLL_DISTANCE : 0;

            if (isPageTranslucent()) {
                // 當前page背景是透明時,釋放手指後纔可以滑動
                mDragHelper.settleCapturedViewAt(left, top);
                invalidate();
            } else {
                if (left > 0 && !mActivity.isFinishing()) {
                    mActivity.finish();
                    mActivity.overridePendingTransition(0, R.anim.slide_out_right);
                }
            }
        }

R.anim.slide_out_right的xml代碼:

<?xml version="1.0" encoding="utf-8"?>
<translate xmlns:android="http://schemas.android.com/apk/res/android"
    android:duration="250"
    android:fromXDelta="0"
    android:interpolator="@android:anim/accelerate_decelerate_interpolator"
    android:toXDelta="100%p" />

爲什麼說取巧呢,因爲我這裏用Activity退出的動畫以假亂真模擬了側滑退出的效果。那憑什麼說微信也是用這種方式呢,請看我的證據:


微信web界面側滑退出的兩種效果

這兩張圖,左邊的是慢速滑動時的效果,右邊是快速滑動時的效果。相信大家已經看出不一致的地方了,那就是滑動層左側的陰影。側滑時是上層Activity的SwipeBackLayout不停改變座標平移產生的效果,而陰影是在SwipeBackLayout不停重繪的過程中畫上去的:

    @Override
    protected boolean drawChild(Canvas canvas, View child, long drawingTime) {
        final boolean drawContent = child == mContentView;

        boolean ret = super.drawChild(canvas, child, drawingTime);
        if (mScrimOpacity > 0 && drawContent
                && mDragHelper.getViewDragState() != ViewDragHelper.STATE_IDLE) {
             // 畫側邊陰影
            drawShadow(canvas, child);
            // 畫覆蓋在可見的下層Activity區域之上的灰色半透明蒙層
            // 將這句代碼註釋掉,就是像微信一樣只要側邊一點陰影的效果
            drawScrim(canvas, child);  
        }
        return ret;
    }

    private void drawScrim(Canvas canvas, View child) {
        final int baseAlpha = (mScrimColor & 0xff000000) >>> 24;
        final int alpha = (int) (baseAlpha * mScrimOpacity);
        final int color = alpha << 24 | (mScrimColor & 0xffffff);
        canvas.clipRect(0, 0, child.getLeft(), getHeight());
        canvas.drawColor(color);
    }

    private void drawShadow(Canvas canvas, View child) {
        final Rect childRect = mTmpRect;
        child.getHitRect(childRect);

        mShadowLeft.setBounds(childRect.left - mShadowLeft.getIntrinsicWidth(), childRect.top,
                childRect.left, childRect.bottom);
        mShadowLeft.setAlpha((int) (mScrimOpacity * FULL_ALPHA));
        mShadowLeft.draw(canvas);
    }

而如果是通過overridePendingTransition設置的Activity退出的動畫的話,是無法繪製出陰影的,因爲這種情況只出現在快速滑動的情況下,所以也很難被看出。大家可以試試微信的側滑,當你快速滑動含web界面的Activity時,明顯可以看出是手指鬆開後,Activiy才動的,而其他不含web界面的Activity就不會如此。還有一點細節,就是微信的側滑一般都是上下Activity聯動的,細心的朋友會發現含web界面的Activity的側滑偏偏沒有聯動,爲什麼呢?就是因爲它快速滑動時使用的通過overridePendingTransition設置的Activity退出動畫,是無法設置聯動的,所以索性把聯動給取消了。
個人覺得微信對這種UI細節的處理真得打磨得特別用心,佩服!
如此,不管是快速滑動還是convertToTranslucent出現異常導致pageTranslucent爲false,都不會讓用戶突然滑不動。

好了,囉哩囉嗦說了這麼多,不知道會不會有人碰到這樣的問題。

最後簡短總結一下吧

解決本文所述問題的終極姿勢是:

  1. 按照我以上所述正確修改SwipeBackHelper的源碼;
  2. 首先將Activity主題style中的window透明屬性設置爲true:
    <item name="android:windowIsTranslucent">true</item>
    這裏還要說明一點,就是在更低版本的手機上或者被定製了UI的手機上,會出現反射獲取方法時根本找不到convertFromTranslucent和convertToTranslucent方法的情況,那麼有兩種處理方案:要麼不處理,convertFromTranslucent沒有調用成功,pageTranslucent會被設置爲true,不影響側滑,webActivity透明問題出現也不用管,畢竟低版本的手機也不是很多了;要麼分版本設置style,低於某個版本(微信是17)的話,就直接設置android:windowIsTranslucent爲false,並且全部禁用側滑退出Activity的功能。
  3. 在Activity的onCreate()中設置透明屬性和側滑功能:
    boolean opaque = Utils.convertActivityFromTranslucent(this);
    SwipeBackHelper.onCreate(this);
    SwipeBackHelper.getCurrentPage(this) 
                   .setSwipeBackEnable(true) 
                   .setSwipeRelateEnable(false)
                   .setPageTranslucent(!opaque);
    4.(12月30日)補充:
    SwipeBackHelper的源碼中定義了統一的當前打開的Activity的進場和退場動畫:
     <style name="SlideRightAnimation" parent="@android:style/Animation.Activity">
         <item name="android:activityOpenEnterAnimation">@anim/slide_in_right</item>
         <item name="android:activityOpenExitAnimation">@null</item>
         <item name="android:activityCloseEnterAnimation">@null</item>
         <item name="android:activityCloseExitAnimation">@anim/slide_out_right</item>
         <item name="android:taskOpenEnterAnimation">@anim/slide_in_right</item>
         <item name="android:taskOpenExitAnimation">@null</item>
         <item name="android:taskCloseEnterAnimation">@null</item>
         <item name="android:taskCloseExitAnimation">@anim/slide_out_right</item>
         <item name="android:taskToFrontEnterAnimation">@anim/slide_in_right</item>
         <item name="android:taskToFrontExitAnimation">@null</item>
         <item name="android:taskToBackEnterAnimation">@null</item>
         <item name="android:taskToBackExitAnimation">@anim/slide_out_right</item>
     </style>
    但不夠完善,可以看到android:activityOpenExitAnimation之類的動畫是沒有定義的,android:activityOpenExitAnimation指定的是當執行打開一個Activity的動畫時,即將退出的那個Activity的退場動畫,比如我當前在ActivityB,要打開ActivityA,那麼當我打開ActivityA的一瞬間會發生兩個動作:一是ActivityA被打開並執行它的進場動畫(slide_in_right),一是ActivityB被關閉並執行它的退場動畫(當前是null)。因爲不同手機的Activity的動畫被進行了不同的定製,有的是左滑退出,有的是直接縮小退出,有的是快速滑向底部退出。提出這個問題是因爲我發現在某些測試機上,當上層Activity執行側滑退出時,下層Activity的頂部連接狀態欄的地方會閃一下,研究半天才明白原來是因爲的下層Activity的退場動畫是系統默認的(刷的一下往下消失),所以會有一條陰影在狀態欄附近快速地閃一下。解決方案就是在上面style的基礎上把android:activityOpenExitAnimation屬性也指定清楚:
     <style name="BaseSlideAnimation" parent="@android:style/Animation.Activity">
         <item name="android:activityOpenEnterAnimation">@anim/slide_in_right</item>
         <item name="android:activityOpenExitAnimation">@anim/slide_out_left</item>
         <item name="android:activityCloseEnterAnimation">@anim/slide_in_left</item>
         <item name="android:activityCloseExitAnimation">@anim/slide_out_right</item>
         <item name="android:taskOpenEnterAnimation">@anim/slide_in_right</item>
         <item name="android:taskOpenExitAnimation">@anim/slide_out_left</item>
         <item name="android:taskCloseEnterAnimation">@anim/slide_in_left</item>
         <item name="android:taskCloseExitAnimation">@anim/slide_out_right</item>
         <item name="android:taskToFrontEnterAnimation">@anim/slide_in_right</item>
         <item name="android:taskToFrontExitAnimation">@anim/slide_out_left</item>
         <item name="android:taskToBackEnterAnimation">@anim/slide_in_left</item>
         <item name="android:taskToBackExitAnimation">@anim/slide_out_right</item>
    slide_in_right.xml:
    <?xml version="1.0" encoding="utf-8"?>
    <translate xmlns:android="http://schemas.android.com/apk/res/android"
     android:duration="250"
     android:fromXDelta="100%p"
     android:interpolator="@android:anim/accelerate_decelerate_interpolator"
     android:toXDelta="0" />
    slide_out_right.xml:
    <?xml version="1.0" encoding="utf-8"?>
    <translate xmlns:android="http://schemas.android.com/apk/res/android"
     android:duration="250"
     android:fromXDelta="0"
     android:interpolator="@android:anim/accelerate_decelerate_interpolator"
     android:toXDelta="100%p" />
    slide_in_left.xml:
    <?xml version="1.0" encoding="utf-8"?>
    <translate xmlns:android="http://schemas.android.com/apk/res/android"
     android:duration="250"
     android:fromXDelta="-30%p"
     android:interpolator="@android:anim/accelerate_decelerate_interpolator"
     android:toXDelta="0" />
    slide_out_left.xml:
    <?xml version="1.0" encoding="utf-8"?>
    <translate xmlns:android="http://schemas.android.com/apk/res/android"
     android:duration="250"
     android:fromXDelta="0"
     android:interpolator="@android:anim/accelerate_decelerate_interpolator"
     android:toXDelta="-30%p" />
    這個style的效果也是跟微信差不多的,目前我項目中就是這樣使用的。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章