背景
最近在做一个手表项目, Android 7.1.1系统, 系统中有个全局从左向右滑动退出当前Activity功能, 本以为是哪位同事添加的功能, 后来看了下代码才发现是Android系统本身就有的功能(Android 5.0加入的), 使用也非常方便, 下面就来讲一下这个功能如何启用和基本原理.
右滑退出原理
右滑退出基本原理很简单, 在某个ViewGroup中, 拦截onTouch事件(onInterceptTouchEvent()
), 根据滑动手势改变View或者Window的偏移量, 在达到某个阈值后, 判定当前手势为退出, 调用Activity退出方法(finish() onBackPressed()
)即可.
但是如果你只是这样操作的话,会发现滑动过程中的背景是黑的, 而不是显示当前Activity后面的Activity内容, 这是因为, Activity执行onStop()
后, 处于一种不可见状态, 要想让当前Activity后面的Activity被绘制出来, 需要用到Activity的两个函数: convertFromTranslucent() 和 convertToTranslucent()
我们来看下对应的函数解释:
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) {
......
}
简单解释就是调用当前Activity的convertToTranslucent()
, 会导致其后面的Activity变为可见, 这正是我们想要的效果, convertFromTranslucent()
则相反, 让后面Activity不可见.
知道这些内容以后, 我们就可以在滑动开始的时候调用convertToTranslucent()
来让后面的Activity可见. 基本原理了解后,下面看下具体代码实现.
代码实现
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;
}
这里面就是一些滑动逻辑判断, 主要判断是否是右滑, 如果是就拦截当前事件, 这样后续事件的onTouchEvent()
就不会传到子View中, 而是在当前View中的onTouchEvent()
中进行处理.
onTouchEvent():
@Override
public boolean onTouchEvent(MotionEvent ev) {
// 部分代码省略...
ev.offsetLocation(mTranslationX, 0);
switch (ev.getActionMasked()) {
case MotionEvent.ACTION_UP:
updateDismiss(ev);
//判断当前动作是取消右滑还是结束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, 调用convertToTranslucent() 让后面Activity可见
((Activity) getContext()).convertToTranslucent(null, null);
}
//此处会调用到PhoneWindow.java中, 来让Window偏移
setProgress(ev.getRawX() - mDownX);
break;
}
}
return true;
}
此部分代码主要包括右滑动作,取消右滑(cancel()
)以及右滑手势完成后结束Activity(dismiss()
). 同时, 如果开始右滑, 则调用 convertToTranslucent()
, 让后面Activity可见, 这样当前Activity向右偏移后, 才能正常看到后面的Activity内容. 滑动过程中Activity的偏移, 结束, 是否启用右滑等代码的实现在PhoneWindow.java中,下面继续看源码.
启用/禁用右滑退出功能
SwipeDismissLayout
是在什么时候被加载的呢? 这部分是在调用setContentView()
之后的流程中来实现的. Activity的setContentView()
最终会调用到PhoneWindow中, 在PhoneWindow中的generateLayout()
函数中, 会根据一些条件, 来决定加载那个布局, 代码如下:
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) {
//注册滑动相关回调
registerSwipeCallbacks();
}
//部分代码省略...
return contentParent;
}
这个函数内容很多, 我只挑了关键代码, 可以看到, 关键点即加载对应的layout文件:
layoutResource = R.layout.screen_swipe_dismiss;
screen_swipe_dismiss.xml的路径为:
frameworks/base/core/res/res/layout/screen_swipe_dismiss.xml
内容就一个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的内容就会加入到此布局之中.
因此,启用右滑功能可以通过两种方式实现:
- 调用函数方式(Activity中) :
getWindow().requestFeature(Window.FEATURE_SWIPE_TO_DISMISS);
, 必须在setContentView()
之前进行设置 - 通过主题配置, 在主题样式中加入:
<item name="android:windowSwipeToDismiss">true</item>
加入这个Feature后, PhoneWindow.java中就会加载screen_swipe_dismiss.xml
这个布局, APP布局的"content"就会成为SwipeDismissLayout的子View, 从而达到拦截事件以及实现右滑功能.
另外我们可以看到, 滑动的回调也在PhoneWindow中实现,代码如下:
private void registerSwipeCallbacks() {
SwipeDismissLayout swipeDismiss =
(SwipeDismissLayout) findViewById(R.id.content);
swipeDismiss.setOnDismissedListener(new SwipeDismissLayout.OnDismissedListener() {
@Override
public void onDismissed(SwipeDismissLayout layout) {
//此处最终会调到Activity的onBackPressed(), 从而结束当前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) {
//通过设置WindowManager.LayoutParams来实现滑动偏移效果
WindowManager.LayoutParams newParams = getAttributes();
newParams.x = (int) translate;
newParams.alpha = 1 - (progress * ALPHA_DECREASE);
setAttributes(newParams);
//部分代码省略...
}
@Override
public void onSwipeCancelled(SwipeDismissLayout layout) {
//取消滑动后重置相关参数
WindowManager.LayoutParams newParams = getAttributes();
newParams.x = 0;
newParams.alpha = 1;
setAttributes(newParams);
setFlags(FLAG_FULLSCREEN, FLAG_FULLSCREEN | FLAG_LAYOUT_NO_LIMITS);
}
});
}
逻辑也比较简单, 即Activity的关闭, 滑动偏移效果和取消滑动这三个关键逻辑,都是在这里实现的.
实际测试
既然是Android 5.0就加入的功能, 想必一般厂商不会没事去掉这个功能,我拿我手上的SONY Xperia Z5(Android 7.1.1)试了下, 随便写一个Activity进行测试:
在setContentView()
之前调用 getWindow().requestFeature(Window.FEATURE_SWIPE_TO_DISMISS);
或者在主题中加入 <item name="android:windowSwipeToDismiss">true</item>
都能实现右滑退出功.
但是由于本身实现逻辑问题, 取消滑动默认会进入全屏状态, 如果做系统开发的, 需要用这个功能的话, 可以根据需求进行修改.
总结
关于右滑退出这个系统功能, 关键点如下:
- 通过
convertFromTranslucent() 和 convertToTranslucent()
来实现让背后的Activity可见和不可见 - 在ViewGroup中拦截onTouchEvent事件, 通过手势实现右滑.
- 给Window添加Feature "FEATURE_SWIPE_TO_DISMISS", 会让系统加载SwipeDismissLayout来作为App布局的父View.
- 右滑偏移效果, 取消右滑, 关闭Activity都在PhoneWindow中进行处理
存在的问题:
从上面代码中可以看到, 拦截onTouch事件是判读是不是向右滑动了,并且会判断字View是否可以滑动, 如果不可以滑动, 右滑事件就会被拦截, 因此当App中有右滑的需求, 就会产生手势冲突, App的右滑事件会被拦截, 所以如果实际要用这个功能, 还需进行优化, 比如只在边缘向右滑动的时候才拦截事件, 这样就不会产生手势冲突了, 或者App自己处理这种类型的冲突, 调用requestDisallowInterceptTouchEvent(boolean disallowIntercept)根据需求禁用拦截.