專案地址:SLWidget/SwipeBack
Demo體驗:SLWidget(1.5MB)
側滑 | 螢幕旋轉 | 視窗模式 |
---|---|---|
廢話
不久前淘汰了用了三年多的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
方法中實現。
首先,在dispatchTouchEvent
的ACTION_DOWN
事件中判斷按壓區域是否為側邊,並進行標記。
然後,在dispatchTouchEvent
的ACTION_MOVE
事件中判斷移動方向,並標記。如果是橫向滑動,則對ContentView的父容器呼叫setTranslationX
設定偏移值,讓介面動起來。為什麼是ContentView的父容器呢?因為ContentView不包含ActionBar,雖然不推薦使用ActionBar。。。
最後,在dispatchTouchEvent
的ACTION_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 | 仿微信滑動返回,實現背景聯動(一、原理)