背景
最近在做一個手表項目, Android 7.1.1系統(tǒng), 系統(tǒng)中有個全局從左向右滑動退出當前Activity功能, 本以為是哪位同事添加的功能, 后來看了下代碼才發(fā)現(xiàn)是Android系統(tǒng)本身就有的功能(Android 5.0加入的), 使用也非常方便, 下面就來講一下這個功能如何啟用和基本原理.
右滑退出原理
右滑退出基本原理很簡單, 在某個ViewGroup中, 攔截onTouch事件(onInterceptTouchEvent()
), 根據(jù)滑動手勢改變View或者Window的偏移量, 在達到某個閾值后, 判定當前手勢為退出, 調(diào)用Activity退出方法(finish() onBackPressed()
)即可.
但是如果你只是這樣操作的話,會發(fā)現(xiàn)滑動過程中的背景是黑的, 而不是顯示當前Activity后面的Activity內(nèi)容, 這是因為, Activity執(zhí)行onStop()
后, 處于一種不可見狀態(tài), 要想讓當前Activity后面的Activity被繪制出來, 需要用到Activity的兩個函數(shù): convertFromTranslucent() 和 convertToTranslucent()
我們來看下對應(yīng)的函數(shù)解釋:
frameworks/base/core/java/android/app/Activity.java
/**
* 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() {
......
}
/**
* 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) {
......
}
簡單解釋就是調(diào)用當前Activity的convertToTranslucent()
, 會導(dǎo)致其后面的Activity變?yōu)榭梢? 這正是我們想要的效果, convertFromTranslucent()
則相反, 讓后面Activity不可見.
知道這些內(nèi)容以后, 我們就可以在滑動開始的時候調(diào)用convertToTranslucent()
來讓后面的Activity可見. 基本原理了解后,下面看下具體代碼實現(xiàn).
代碼實現(xiàn)
frameworks/base/core/java/com/android/internal/widget/SwipeDismissLayout.java
首先在SwipeDismissLayout.java中攔截觸摸事件:
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
switch (ev.getActionMasked()) {
case MotionEvent.ACTION_DOWN:
resetMembers();
mDownX = ev.getRawX();
mDownY = ev.getRawY();
mActiveTouchId = ev.getPointerId(0);
mVelocityTracker = VelocityTracker.obtain();
mVelocityTracker.addMovement(ev);
break;
//部分代碼省略...
case MotionEvent.ACTION_POINTER_UP:
actionIndex = ev.getActionIndex();
int pointerId = ev.getPointerId(actionIndex);
if (pointerId == mActiveTouchId) {
// This was our active pointer going up. Choose a new active pointer.
int newActionIndex = actionIndex == 0 ? 1 : 0;
mActiveTouchId = ev.getPointerId(newActionIndex);
}
break;
case MotionEvent.ACTION_MOVE:
//部分代碼省略...
float dx = ev.getRawX() - mDownX;
float x = ev.getX(pointerIndex);
float y = ev.getY(pointerIndex);
if (dx != 0 && canScroll(this, false, dx, x, y)) {
mDiscardIntercept = true;
break;
}
updateSwiping(ev);
break;
}
return !mDiscardIntercept && mSwiping;
}
這里面就是一些滑動邏輯判斷, 主要判斷是否是右滑, 如果是就攔截當前事件, 這樣后續(xù)事件的onTouchEvent()
就不會傳到子View中, 而是在當前View中的onTouchEvent()
中進行處理.
onTouchEvent():
@Override
public boolean onTouchEvent(MotionEvent ev) {
// 部分代碼省略...
ev.offsetLocation(mTranslationX, 0);
switch (ev.getActionMasked()) {
case MotionEvent.ACTION_UP:
updateDismiss(ev);
//判斷當前動作是取消右滑還是結(jié)束Activity
if (mDismissed) {
dismiss();
} else if (mSwiping) {
cancel();
}
resetMembers();
break;
// 部分代碼省略....
case MotionEvent.ACTION_MOVE:
mVelocityTracker.addMovement(ev);
mLastX = ev.getRawX();
updateSwiping(ev);
if (mSwiping) {
if (mUseDynamicTranslucency && getContext() instanceof Activity) {
//如果是右滑并且是Activity, 調(diào)用convertToTranslucent() 讓后面Activity可見
((Activity) getContext()).convertToTranslucent(null, null);
}
//此處會調(diào)用到PhoneWindow.java中, 來讓W(xué)indow偏移
setProgress(ev.getRawX() - mDownX);
break;
}
}
return true;
}
此部分代碼主要包括右滑動作,取消右滑(cancel()
)以及右滑手勢完成后結(jié)束Activity(dismiss()
). 同時, 如果開始右滑, 則調(diào)用 convertToTranslucent()
, 讓后面Activity可見, 這樣當前Activity向右偏移后, 才能正常看到后面的Activity內(nèi)容. 滑動過程中Activity的偏移, 結(jié)束, 是否啟用右滑等代碼的實現(xiàn)在PhoneWindow.java中,下面繼續(xù)看源碼.
啟用/禁用右滑退出功能
SwipeDismissLayout
是在什么時候被加載的呢? 這部分是在調(diào)用setContentView()
之后的流程中來實現(xiàn)的. Activity的setContentView()
最終會調(diào)用到PhoneWindow中, 在PhoneWindow中的generateLayout()
函數(shù)中, 會根據(jù)一些條件, 來決定加載那個布局, 代碼如下:
frameworks/base/core/java/com/android/internal/policy/PhoneWindow.java
protected ViewGroup generateLayout(DecorView decor) {
//部分代碼省略...
//如果主題中windowSwipeToDismiss為true, 添加FEATURE_SWIPE_TO_DISMISS
if (a.getBoolean(R.styleable.Window_windowSwipeToDismiss, false)) {
requestFeature(FEATURE_SWIPE_TO_DISMISS);
}
//部分代碼省略...
int layoutResource;
int features = getLocalFeatures();
// System.out.println("Features: 0x" + Integer.toHexString(features));
if ((features & (1 << FEATURE_SWIPE_TO_DISMISS)) != 0) {
// 如果包含 FEATURE_SWIPE_TO_DISMISS,則加載的布局是screen_swipe_dismiss.xml
layoutResource = R.layout.screen_swipe_dismiss;
} else if ((features & ((1 << FEATURE_LEFT_ICON) | (1 << FEATURE_RIGHT_ICON))) != 0) {
if (mIsFloating) {
TypedValue res = new TypedValue();
getContext().getTheme().resolveAttribute(
R.attr.dialogTitleIconsDecorLayout, res, true);
layoutResource = res.resourceId;
} else {
layoutResource = R.layout.screen_title_icons;
}
// XXX Remove this once action bar supports these features.
removeFeature(FEATURE_ACTION_BAR);
// System.out.println("Title Icons!");
}
//部分代碼省略...
if ((features & (1 << FEATURE_SWIPE_TO_DISMISS)) != 0) {
//注冊滑動相關(guān)回調(diào)
registerSwipeCallbacks();
}
//部分代碼省略...
return contentParent;
}
這個函數(shù)內(nèi)容很多, 我只挑了關(guān)鍵代碼, 可以看到, 關(guān)鍵點即加載對應(yīng)的layout文件:
layoutResource = R.layout.screen_swipe_dismiss;
screen_swipe_dismiss.xml的路徑為:
frameworks/base/core/res/res/layout/screen_swipe_dismiss.xml
內(nèi)容就一個SwipeDismissLayout布局:
<com.android.internal.widget.SwipeDismissLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@android:id/content"
android:fitsSystemWindows="true"
android:layout_width="match_parent"
android:layout_height="match_parent"
/>
使用這個布局后, setContentView的內(nèi)容就會加入到此布局之中.
因此,啟用右滑功能可以通過兩種方式實現(xiàn):
- 調(diào)用函數(shù)方式(Activity中) :
getWindow().requestFeature(Window.FEATURE_SWIPE_TO_DISMISS);
, 必須在setContentView()
之前進行設(shè)置 - 通過主題配置, 在主題樣式中加入:
<item name="android:windowSwipeToDismiss">true</item>
加入這個Feature后, PhoneWindow.java中就會加載screen_swipe_dismiss.xml
這個布局, APP布局的"content"就會成為SwipeDismissLayout的子View, 從而達到攔截事件以及實現(xiàn)右滑功能.
另外我們可以看到, 滑動的回調(diào)也在PhoneWindow中實現(xiàn),代碼如下:
private void registerSwipeCallbacks() {
SwipeDismissLayout swipeDismiss =
(SwipeDismissLayout) findViewById(R.id.content);
swipeDismiss.setOnDismissedListener(new SwipeDismissLayout.OnDismissedListener() {
@Override
public void onDismissed(SwipeDismissLayout layout) {
//此處最終會調(diào)到Activity的onBackPressed(), 從而結(jié)束當前Activity
dispatchOnWindowDismissed(false /*finishTask*/);
}
});
swipeDismiss.setOnSwipeProgressChangedListener(
new SwipeDismissLayout.OnSwipeProgressChangedListener() {
private static final float ALPHA_DECREASE = 0.5f;
private boolean mIsTranslucent = false;
@Override
public void onSwipeProgressChanged(
SwipeDismissLayout layout, float progress, float translate) {
//通過設(shè)置WindowManager.LayoutParams來實現(xiàn)滑動偏移效果
WindowManager.LayoutParams newParams = getAttributes();
newParams.x = (int) translate;
newParams.alpha = 1 - (progress * ALPHA_DECREASE);
setAttributes(newParams);
//部分代碼省略...
}
@Override
public void onSwipeCancelled(SwipeDismissLayout layout) {
//取消滑動后重置相關(guān)參數(shù)
WindowManager.LayoutParams newParams = getAttributes();
newParams.x = 0;
newParams.alpha = 1;
setAttributes(newParams);
setFlags(FLAG_FULLSCREEN, FLAG_FULLSCREEN | FLAG_LAYOUT_NO_LIMITS);
}
});
}
邏輯也比較簡單, 即Activity的關(guān)閉, 滑動偏移效果和取消滑動這三個關(guān)鍵邏輯,都是在這里實現(xiàn)的.
實際測試
既然是Android 5.0就加入的功能, 想必一般廠商不會沒事去掉這個功能,我拿我手上的SONY Xperia Z5(Android 7.1.1)試了下, 隨便寫一個Activity進行測試:
在setContentView()
之前調(diào)用 getWindow().requestFeature(Window.FEATURE_SWIPE_TO_DISMISS);
或者在主題中加入 <item name="android:windowSwipeToDismiss">true</item>
都能實現(xiàn)右滑退出功.
但是由于本身實現(xiàn)邏輯問題, 取消滑動默認會進入全屏狀態(tài), 如果做系統(tǒng)開發(fā)的, 需要用這個功能的話, 可以根據(jù)需求進行修改.
總結(jié)
關(guān)于右滑退出這個系統(tǒng)功能, 關(guān)鍵點如下:
- 通過
convertFromTranslucent() 和 convertToTranslucent()
來實現(xiàn)讓背后的Activity可見和不可見 - 在ViewGroup中攔截onTouchEvent事件, 通過手勢實現(xiàn)右滑.
- 給Window添加Feature "FEATURE_SWIPE_TO_DISMISS", 會讓系統(tǒng)加載SwipeDismissLayout來作為App布局的父View.
- 右滑偏移效果, 取消右滑, 關(guān)閉Activity都在PhoneWindow中進行處理
存在的問題:
從上面代碼中可以看到, 攔截onTouch事件是判讀是不是向右滑動了,并且會判斷字View是否可以滑動, 如果不可以滑動, 右滑事件就會被攔截, 因此當App中有右滑的需求, 就會產(chǎn)生手勢沖突, App的右滑事件會被攔截, 所以如果實際要用這個功能, 還需進行優(yōu)化, 比如只在邊緣向右滑動的時候才攔截事件, 這樣就不會產(chǎn)生手勢沖突了, 或者App自己處理這種類型的沖突, 調(diào)用requestDisallowInterceptTouchEvent(boolean disallowIntercept)根據(jù)需求禁用攔截.