項目地址:SLWidget/SwipeBack
Demo體驗:SLWidget(1.5MB)
側滑 | 屏幕旋轉 | 窗口模式 |
---|---|---|
GIF1.gif
|
GIF2.gif
|
GIF3.gif
|
廢話
不久前淘汰了用了三年多的iPhone6Plus,換了部三星S9+。流暢的吃雞體驗,絲滑的屏幕,超高的性價比(港行還另打了9折),真喜歡的不行。不過從IOS切換到Android,還是不太適應,首當其沖就是 沒!有!側!滑!返!回! 每天螞蟻森林偷個能量要點無數遍返回鍵,簡直崩潰!于是,熱(喜)愛(歡)工(裝)作(逼)的我,決定在自己的項目中一定要有愛的不行的側滑功能。
分析
搜一下“Android側滑返回”,現在有很多很多的開源庫作為選擇。我?guī)缀醢衙恳环N類型都嘗試了一遍,發(fā)現了很多很多坑。按照實現方式的不同,我把它們大致歸位兩大類:
-
不透明方案
不透明方案通過注冊
ActivityLifecycleCallbacks
回調來管理Activity棧,以獲取下層Activity的ContentView,然后在上層Activity進行繪制。-
不透明方案分支一
在頂層Activity的DecorView中插入一個Layout。監(jiān)聽側滑事件,移動頂層Activity的ContentView同時,在該Layout的onDraw中調用
View.draw(Canvas canvas)
繪制下層Activity的ContentView。造成側滑透視到下層Activity的假象。
存在問題:當布局變化或數據更新,如橫豎屏切換、導航欄隱藏、窗口模式、分屏模式等,該假象始終如一不會有對應改變。 -
不透明方案分支二
在頂層Activity的DecorView中插入一個Layout。將下層Activity的ContentView移除,并添加到該Layout中。監(jiān)聽側滑事件,移動頂層Activity的ContentView,亦可造成側滑透視到下層Activity的假象。此方案比方案一好在:可以適應部分布局變化。
存在問題:下層Activity有數據改變,無對應更新。當頂層Activity重建時(旋轉屏幕、切換窗口模式等),會丟失ContentView中綁定的數據。旋轉屏幕時,若下層Activity有對應兩套布局,該假象露餡。
-
-
透明方案
通過設置窗口透明,真正透視到下層Activity的界面。
-
透明方案一
在styles中配置如下兩條屬性:
<item name="android:windowBackground">@android:color/transparent</item> <item name="android:windowIsTranslucent">true</item>
然后監(jiān)聽側滑事件,移動頂層Activity的ContentView,即可真正透視到下層Activity的界面。此時無論布局變化、數據更新,都沒問題。BUT!該方案問題多如牛毛。。。
存在問題:windowIsTranslucent
為true會引起一系列的動畫問題,如前后臺切換動畫、Activity回退動畫等。網上有解決方案說設置"android:windowEnterAnimation"
和"android:windowExitAnimation"
,經測試并無卵用。同時,在SDK26(Android8.0)及以上,會與固定屏幕方向沖突造成閃退。同時,下層的Activity只會進入onPause狀態(tài),不會onStop,當頁面開啟過多時,一定會讓你崩潰。 -
透明方案二
如透明方案一,依舊在styles中配置那兩條屬性,在onPause中利用反射將窗口轉為不透明,在onResume再利用反射將窗口轉為透明。似乎醬紫很順利地解決了下層以下的Activity不會onStop導致的性能問題。BUT!該方案問題依舊可怕。。。
存在問題:因頂層Activity透明,旋轉屏幕時下層Activity會重建,然后在onResume中將窗口轉為透明,然后下下層Activity也跟著復活了。。。一系列連鎖反應,簡直可怕!同時,windowIsTranslucent
為true引起一系列的動畫問題依然沒有得到解決。
-
實現
經以上可知,要想側滑時看到的不是假象,窗口必須透明讓下層的Activity接收布局變化和數據更新。但是窗口透明會影響動畫效果,且和屏幕旋轉產生沖突。那么是否可以只在側滑時窗口保持透明?
ofcourse~
我們可以在側滑觸發(fā)時利用反射將窗口轉為透明,在側滑結束時利用反射將窗口轉為不透明。這樣既可以在側滑時一窺下層Activity真容,又不會和屏幕旋轉沖突,也不會影響到動畫的使用。原理很簡單,下面開始一步步實現。
注:有同學問到Android P中禁止了非SDK接口的使用,但是窗口透明轉換的接口均屬于淺灰名單,不受限制。
-
Step.1 狀態(tài)欄透明
既然要實現側滑返回,狀態(tài)欄必然要干掉,實現沉浸式體驗。這里不多BB,直接上代碼。
private boolean setStatusBarTransparent(boolean darkStatusBar) { //SDK大于等于24,需要判斷是否為窗口模式 boolean isInMultiWindowMode = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && mSwipeBackActivity.isInMultiWindowMode(); //窗口模式或者SDK小于19,不設置狀態(tài)欄透明 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支持翻轉狀態(tài)欄顏色 if (darkStatusBar && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { //設置狀態(tài)欄文字&圖標暗色 systemUiVisibility |= View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR; } //去除狀態(tài)欄背景 mDecorView.setSystemUiVisibility(systemUiVisibility); //設置狀態(tài)欄透明 mSwipeBackActivity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS); mSwipeBackActivity.getWindow().addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS); mSwipeBackActivity.getWindow().setStatusBarColor(Color.TRANSPARENT); } //監(jiān)聽DecorView的布局變化 mDecorView.addOnLayoutChangeListener(mPrivateListener); return true; }
這里有幾個要注意的地方。
I. SDK小于19是不支持狀態(tài)欄透明的,SD21及以上的實現方式也有所不同。
II. SD23及以上支持狀態(tài)欄顏色反轉。
III. SD24及以上支持窗口模式,這里要進行判斷,當窗口模式時,不要設置狀態(tài)欄透明。
IV. 狀態(tài)欄設置透明之后,輸入法的adjustResize
會失效。網傳解決方案android:fitsSystemWindows="true"
不推薦使用,因為這會導致無法在狀態(tài)欄之下進行繪制。因此這里對DecorView布局變化進行監(jiān)聽,布局變化時動態(tài)調整子View的高度為DecorView的可見部分。貼一下代碼:public void onLayoutChange(View v, int left, int top, int right, int bottom, int oldLeft, int oldTop, int oldRight, int oldBottom) { //獲取DecorView的可見區(qū)域 Rect visibleDisplayRect = new Rect(); mDecorView.getWindowVisibleDisplayFrame(visibleDisplayRect); /**這里省略一小段代碼,后文提及*/ //狀態(tài)欄透明情況下,輸入法的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 支持側滑
狀態(tài)欄已經透明了,下一步就是讓我們的界面可以滑動起來。這里我們在Activity的
dispatchTouchEvent
方法中實現。
首先,在dispatchTouchEvent
的ACTION_DOWN
事件中判斷按壓區(qū)域是否為側邊,并進行標記。
然后,在dispatchTouchEvent
的ACTION_MOVE
事件中判斷移動方向,并標記。如果是橫向滑動,則對ContentView的父容器調用setTranslationX
設置偏移值,讓界面動起來。為什么是ContentView的父容器呢?因為ContentView不包含ActionBar,雖然不推薦使用ActionBar。。。
最后,在dispatchTouchEvent
的ACTION_UP
事件中進行距離判斷,根據末速度和位移判斷是否finish當前頁面。
讓頁面滑動起來的基本思路就醬紫了。BUT,這其間還涉及到多點觸摸、子View的Touch事件取消、末速度計算、松手后的動畫處理等等。限于這塊代碼有點多也不是重點,這里就不貼出來了。有興趣詳細了解的同學請閱讀源碼 -
Step.3 窗口透明
到了這一步可能很多同學要問了,為毛我滑動之后底下黑黢黢的。別急,因為我們還沒有甩出王炸。前面說了,我們需要在側滑觸發(fā)時利用反射將窗口轉為透明,在側滑結束時利用反射將窗口轉為不透明。上一步已經講解了如何讓頁面滑動起來,剩下的就好辦了。請看王炸代碼:
//將窗口轉為透明 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聯(lián)動的,這里為了點題,咱(其)們(實)就(是)不(懶)支(癌)持(復)了(發(fā))。
注意事項
經以上簡單四步,基本上效果已經很棒了。不過還有一些需要特別注意的地方,以及前面占了兩個坑,現在進行回填。
-
Tips.1
先填掉前面講解DecorView的布局變化監(jiān)聽時占的坑。當布局變化時,我們通過調整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
側滑松手后會出現兩種情況,其一回到左側原點,其二繼續(xù)滑動到右側邊界然后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狀態(tài),如果發(fā)生屏幕旋轉,下層Activity還將會進行重建。當我們將窗口恢復為不透明,下層Activity會重新進入onStop狀態(tài)。因此如果你的Activity代碼邏輯比較混亂,使用之前務必進行邏輯優(yōu)化。
-
Tips.5
當頂層Activity方向與下層Activity方向不一致時側滑會失效(下層方向未鎖定除外),請關閉該層Activity側滑功能。示例場景:豎屏界面點擊視頻,進入橫屏播放。這個很好理解,例如頂層Activity橫屏,下層鎖定豎屏,當側滑時,窗口到底是橫屏還是豎屏?It's a question...
-
Tips.6
因為狀態(tài)欄透明,布局會從屏幕頂端開始繪制,Toolbar需要增加一個狀態(tài)欄高度的paddingTop
//獲取狀態(tài)欄高度 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
如需動態(tài)支持橫豎屏切換(比如APP中有“支持橫屏”開關),屏幕方向需指定為
behind
跟隨棧底Activity方向,同時在onCreate中進行判斷,若不支持橫豎屏切換則鎖定屏幕方向(因為經測試SDK21中behind
無效)。 -
Tips.8
可能有同學會發(fā)現,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右滑退出+沉浸式(透明)狀態(tài)欄
掛云帆love | 仿微信滑動返回,實現背景聯(lián)動(一、原理)