項目地址: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
方法中實現。
首先,在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
轉換的不透明】轉爲透明,不能將本來就不透明的窗口轉爲透明。
絮叨一通,全是大段文字。限於我的能力有限,不免存在些許疏忽失誤,歡迎指正。若有更好的思路也請不吝賜教,此文權當拋磚引玉。
項目地址:SLWidget/SwipeBack(含依賴使用方法及說明,歡迎Star,歡迎Fork)
Demo體驗:SLWidget(1.5MB)
最後感謝如下博文,讓我受益不淺(有所疏漏,敬請諒解)
永遠即等待 | Android滑動返回(SlideBack for Android)
HolenZhou | Android版與微信Activity側滑後退效果徹底相同的SwipeBackLayout
Ziv_xiao | Android右滑退出+沉浸式(透明)狀態欄
掛雲帆love | 仿微信滑動返回,實現背景聯動(1、原理)