滑动返回是ios设备中默认支持的一种滑动退出效果,由于IPhone设备没有返回键,所以滑动退出使用起来十分方便。而如今随着手机屏幕越来越大,而单手使用手机的情况愈发频繁,所以在Android端添加测滑返回也是各大app的一项趋势,今天我们就通过分析下实现测滑退出的几种方式,来实现一套自己的“测滑退出”方案。
滑动返回是ios设备中默认支持的一种滑动退出效果,由于IPhone设备没有返回键,所以滑动退出使用起来十分方便。而如今随着手机屏幕越来越大,而单手使用手机的情况愈发频繁,所以在Android端添加测滑返回也是各大app的一项趋势,今天我们就通过分析下实现测滑退出的几种方式,来实现一套自己的“测滑退出”方案。
一、滑动返回案例
网易新闻:
今日头条:
上面是网易新闻和今日头条中实现的滑动返回样式,从gif图中我们看到,网易新闻在测滑时底部蒙层有一个透明度的变化,而底部Activity样式并没发生变化。而头条中实现的样式中,底部Activity(前一个Activity)有一个渐变的动画。通过这两种不同的样式,我们可以将“测滑退出”分为两个过程:
- 实现当前Activity跟随手指进行滑动;
- 展现底部(上一级)Activity的view,并对其进行相应操作(各种动画);
而常见的实现方案有两种,其中一种为:“透明主题样式方案”,另一种为:“视觉差方案”。这两种方案针对上述过程1并无差别,主要差别在过程2。下面将分别从“测滑退出”的两个过程来进行具体分析。
二、实现当前Activity跟随手指进行滑动
而实现此效果可以有以下几种方式:
- 重写View的dispatchTouchEvent()方法及onTouchEvent():参考 swipeback
- 结合GestureDetector类实现:对于GestureDetector这个类,不了解的可以参考官方文档GestureDetector
- DrawerLayout/SlidingPaneLayout:采用DrawerLayout实现时,需要修改DrawerLayout的滑动范围,可以采用反射的方式修改其私有属性mEdgeSize,将DrawLayout修改为划出后为全屏幕。具体DrawerLayout使用可参考 Android 之 DrawerLayout 详解 及DrawerLayout滑动范围的设置
- 结合ViewDragHelper类实现:ViewDragHelper类提供了一系列用于用户拖动子view的辅助方法和相关状态记录的工具类,如DrawerLayout等内部均使用ViewDragHelper来处理滑动相关操作。那么我们就采用ViewDragHelper来为一个自定义view添加滑动处理,来作为当前实现方案。
采用ViewDragHelper实现测滑
在使用ViewDragHelper实现具体功能之前,让我们首先来学习一下ViewDragHelper:
基本用法:
在自定义View构造方法中调用ViewDragHelper的静态工厂方法create()创建ViewDragHelper实例;
-
实现ViewDragHelper.Callback接口,具体方法解析如下:
void onViewDragStateChanged(int state)
拖动状态改变时会调用此方法,状态state有STATE_IDLE、STATE_DRAGGING、STATE_SETTLING三种取值。void onViewPositionChanged(View changedView, int left, int top, int dx, int dy)
正在被拖动的View或者自动滚动的View的位置改变时会调用此方法。void onViewCaptured(View capturedChild, int activePointerId)
tryCaptureViewForDrag()成功捕获到子View时会调用此方法。void onViewReleased(View releasedChild, float xvel, float yvel)
拖动View松手时(processTouchEvent()的ACTION_UP)或被父View拦截事件时(processTouchEvent()的ACTION_CANCEL)会调用此方法。void onEdgeTouched(int edgeFlags, int pointerId)
ACTION_DOWN或ACTION_POINTER_DOWN事件发生时如果触摸到监听的边缘会调用此方法。edgeFlags的取值为EDGE_LEFT、EDGE_TOP、EDGE_RIGHT、EDGE_BOTTOM的组合。boolean onEdgeLock(int edgeFlags)
返回true表示锁定edgeFlags对应的边缘,锁定后的那些边缘就不会在onEdgeDragStarted()被通知了,默认返回false不锁定给定的边缘,edgeFlags的取值为EDGE_LEFT、EDGE_TOP、EDGE_RIGHT、EDGE_BOTTOM其中之一。void onEdgeDragStarted(int edgeFlags, int pointerId)
ACTION_MOVE事件发生时,检测到开始在某些边缘有拖动的手势,也没有锁定边缘,会调用此方法。edgeFlags取值为EDGE_LEFT、EDGE_TOP、EDGE_RIGHT、EDGE_BOTTOM的组合。可在此手动调用captureChildView()触发从边缘拖动子View的效果。int getOrderedChildIndex(int index)
在寻找当前触摸点下的子View时会调用此方法,寻找到的View会提供给tryCaptureViewForDrag()来尝试捕获。如果需要改变子View的遍历查询顺序可改写此方法,例如让下层的View优先于上层的View被选中。int getViewHorizontalDragRange(View child)、int getViewVerticalDragRange(View child)
返回给定的child在相应的方向上可以被拖动的最远距离,默认返回0。ACTION_DOWN发生时,若触摸点处的child消费了事件,并且想要在某个方向上可以被拖动,就要在对应方法里返回大于0的数。
被调用的地方有三处:- 在checkTouchSlop()中被调用,返回值大于0才会去检查mTouchSlop。在ACTION_MOVE里调用tryCaptureViewForDrag()之前会调用checkTouchSlop()。如果checkTouchSlop()失败,就不会去捕获View了。
- 如果ACTION_DOWN发生时,触摸点处有子View消费事件,在shouldInterceptTouchEvent()的ACTION_MOVE里会被调用。如果两个方向上的range都是0(两个方法都返回0),就不会去捕获View了。
- 在调用smoothSlideViewTo()时被调用,用于计算自动滚动要滚动多长时间,这个时间计算出来后,如果超过最大值,最终时间就取最大值,所以不用担心在getView[Horizontal|Vertical]DragRange里返回了不合适的数导致计算的时间有问题,只要返回大于0的数就行了。
boolean tryCaptureView(View child, int pointerId)
在tryCaptureViewForDrag()中被调用,返回true表示捕获给定的child。tryCaptureViewForDrag()被调用的地方有- shouldInterceptTouchEvent()的ACTION_DOWN里
- shouldInterceptTouchEvent()的ACTION_MOVE里
- processTouchEvent()的ACTION_MOVE里
int clampViewPositionHorizontal(View child, int left, int dx)、int clampViewPositionVertical(View child, int top, int dy)
child在某方向上被拖动时会调用对应方法,返回值是child移动过后的坐标位置,clampViewPositionHorizontal()返回child移动过后的left值,clampViewPositionVertical()返回child移动过后的top值。
两个方法被调用的地方有两处:- 在dragTo()中被调用,dragTo()在processTouchEvent()的ACTION_MOVE里被调用。用来获取被拖动的View要移动到的位置。
- 如果ACTION_DOWN发生时,触摸点处有子View消费事件,在shouldInterceptTouchEvent()的ACTION_MOVE里会被调用。如果两个方向上返回的还是原来的left和top值,就不会去捕获View了。
在onInterceptTouchEvent()方法里调用并返回ViewDragHelper的shouldInterceptTouchEvent()方法
在onTouchEvent()方法里调用ViewDragHelper()的processTouchEvent()方法。ACTION_DOWN事件发生时,如果当前触摸点下要拖动的子View没有消费事件,此时应该在onTouchEvent()返回true,否则将收不到后续事件,不会产生拖动。
上面几个步骤已经实现了子View拖动的效果,如果还想要实现fling效果(滑动时松手后以一定速率继续自动滑动下去并逐渐停止,类似于扔东西)或者松手后自动滑动到指定位置,需要实现自定义ViewGroup的computeScroll()方法,方法实现如下:
@Override
public void computeScroll() {
if (mDragHelper.continueSettling(true)) {
postInvalidate();
}
}
并在ViewDragHelper.Callback的onViewReleased()方法里调用以下三个方法中任意一个:
- settleCapturedViewAt(int finalLeft, int finalTop)
以松手前的滑动速度为初速动,让捕获到的View自动滚动到指定位置。只能在Callback的onViewReleased()中调用。 - flingCapturedView(int minLeft, int minTop, int maxLeft, int maxTop)
以松手前的滑动速度为初速动,让捕获到的View在指定范围内fling。只能在Callback的onViewReleased()中调用。 - smoothSlideViewTo(View child, int finalLeft, int finalTop)
指定某个View自动滚动到指定的位置,初速度为0,可在任何地方调用。
如果要实现边缘拖动的效果,需要调用ViewDragHelper的setEdgeTrackingEnabled()方法,注册想要监听的边缘。然后实现ViewDragHelper.Callback里的onEdgeDragStarted()方法,在此手动调用captureChildView()传递要拖动的子View。
以上为ViewDragHelper的基本使用方法,更加详细的ViewDragHelper源码分析,请参考 Android ViewDragHelper源码解析
而通过上述对ViewDragHelper的了解,我们可以实现各个方向上的测滑退出(左、上、右、下),而仅通过调用以下方法即可:
/**
* Enable edge tracking for the selected edges of the parent view.
* The callback's {@link Callback#onEdgeTouched(int, int)} and
* {@link Callback#onEdgeDragStarted(int, int)} methods will only be invoked
* for edges for which edge tracking has been enabled.
*
* @param edgeFlags Combination of edge flags describing the edges to watch
* @see #EDGE_LEFT
* @see #EDGE_TOP
* @see #EDGE_RIGHT
* @see #EDGE_BOTTOM
*/
public void setEdgeTrackingEnabled(int edgeFlags) {
mTrackingEdges = edgeFlags;
}
具体实现
下面将给出基于ViewDragHelper实现的支持滑动的自定义ViewGroup: SwipeBackLayout.java ,具体代码为:
public class SwipeBackLayout extends FrameLayout {
private static String TAG = SwipeBackLayout.class.getSimpleName();
private float mScrollThreshold = DEFAULT_SCROLL_THRESHOLD;
private WeakReference<View> mContentViewRef;
//mInsets保存当前内部contentView的margin
private Rect mInsets = new Rect();
private ViewDragHelper mDragHelper;
private SwipeSlideCallback mSlideCallback;
private int mContentLeft;
private int mContentTop;
private boolean mInLayout;
private int mFlingVelocity = FLING_VELOCITY;
private int mEdgeFlag = ViewDragHelper.EDGE_LEFT;
private int mEdgeMode = SwipeConstantUtils.EDGEMODE_FULLSCREEN;
//children中有需要滚动的view
private View mScrollChildView;
public SwipeBackLayout(Context context) {
super(context);
//初始化viewDragHelper
mDragHelper = ViewDragHelper.create(this, 1f, new ViewDragCallback());
}
@TargetApi(Build.VERSION_CODES.KITKAT_WATCH)
@Override
public WindowInsets onApplyWindowInsets(WindowInsets insets) {
int top = insets.getSystemWindowInsetTop();
View contentView = getContentView();
if(contentView != null) {
if (contentView.getLayoutParams() instanceof MarginLayoutParams) {
MarginLayoutParams params = (MarginLayoutParams)contentView.getLayoutParams();
mInsets.set(params.leftMargin, params.topMargin + top, params.rightMargin, params.bottomMargin);
}
}
return super.onApplyWindowInsets(insets);
}
@Override
public boolean onInterceptTouchEvent(MotionEvent event) {
try {
//交给viewDragHelper来处理
return mDragHelper.shouldInterceptTouchEvent(event);
} catch (Exception e) {
e.printStackTrace();
return false;
}
}
@Override
public boolean onTouchEvent(MotionEvent event) {
//交给viewDragHelper来处理
mDragHelper.processTouchEvent(event);
return true;
}
@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
mInLayout = true;
View contentView = getContentView();
if(contentView != null) {
int cleft = mContentLeft;
int ctop = mContentTop;
ViewGroup.LayoutParams params = contentView.getLayoutParams();
if (params instanceof MarginLayoutParams) {
cleft += ((MarginLayoutParams) params).leftMargin;
ctop += ((MarginLayoutParams) params).topMargin;
}
contentView.layout(cleft, ctop,
cleft + contentView.getMeasuredWidth(),
ctop + contentView.getMeasuredHeight());
}
mInLayout = false;
}
@Override
public void requestLayout() {
if (!mInLayout) {
super.requestLayout();
}
}
@Override
public void computeScroll() {
//实现fling效果
if (mDragHelper.continueSettling(true)) {
ViewCompat.postInvalidateOnAnimation(this);
}
}
public void setContentView(View view) {
if(mContentViewRef != null && mContentViewRef.get() != null) {
mContentViewRef.clear();
}
mContentViewRef = new WeakReference<>(view);
}
public void setSlideCallback(SwipeSlideCallback slideCallback) {
mSlideCallback = slideCallback;
}
@Override
protected void onDetachedFromWindow() {
super.onDetachedFromWindow();
recycle();
}
public void recycle() {
if(mContentViewRef != null && mContentViewRef.get() != null) {
mContentViewRef.clear();
}
mSlideCallback = null;
removeAllViews();
}
public int getEdgeFlag() {
return mEdgeFlag;
}
private boolean canChildScrollUp() {
return mScrollChildView != null && ViewCompat.canScrollVertically(mScrollChildView, -1);
}
private boolean canChildScrollDown() {
return mScrollChildView != null && ViewCompat.canScrollVertically(mScrollChildView, 1);
}
private boolean canChildScrollRight() {
return mScrollChildView != null && ViewCompat.canScrollHorizontally(mScrollChildView, 1);
}
private boolean canChildScrollLeft() {
return mScrollChildView != null && ViewCompat.canScrollHorizontally(mScrollChildView, -1);
}
/**
* 设置滑动方向,left、right、top、bottom
*
* @param edgeFlag the edge flag
*/
@TargetApi(Build.VERSION_CODES.JELLY_BEAN)
public void setEdgeFlag(@EdgeFlag int edgeFlag) {
mEdgeFlag = edgeFlag;
mDragHelper.setEdgeTrackingEnabled(edgeFlag);
}
public void setEdgeMode(int mEdgeMode) {
this.mEdgeMode = mEdgeMode;
}
/**
* 解决滑动冲突,如果当前布局中有其他子view需要获取滑动时间,如viewPager等,则需设置scrollView.
* @param scrollView : child scroll view
*/
public void setChildScrollView(View scrollView) {
this.mScrollChildView = scrollView;
}
private View getContentView() {
if(mContentViewRef != null && mContentViewRef.get() != null) {
return mContentViewRef.get();
}
Loger.e(TAG,"exception !!! content view ref is null !!!");
return null;
}
private class ViewDragCallback extends ViewDragHelper.Callback {
private float mScrollPercent;
@Override
public boolean tryCaptureView(View view, int pointerId) {
// edgeMode == fullScreen表示全屏滑动,ret直接返回true,边缘滑动时,根据isEdgeTouched来做判断.
boolean ret = mEdgeMode == SwipeConstantUtils.EDGEMODE_FULLSCREEN || (mEdgeMode == SwipeConstantUtils.EDGEMODE_EDGE &&
mDragHelper.isEdgeTouched(mEdgeFlag, pointerId));
boolean directionCheck = false;
if (mEdgeFlag == ViewDragHelper.EDGE_LEFT || mEdgeFlag == ViewDragHelper.EDGE_RIGHT) {
directionCheck = !mDragHelper.checkTouchSlop(ViewDragHelper.DIRECTION_VERTICAL, pointerId);
} else if (mEdgeFlag == ViewDragHelper.EDGE_BOTTOM || mEdgeFlag == ViewDragHelper.EDGE_TOP) {
directionCheck = !mDragHelper
.checkTouchSlop(ViewDragHelper.DIRECTION_HORIZONTAL, pointerId);
}
return ret && (view == getContentView()) && directionCheck;
}
@Override
public int getViewHorizontalDragRange(View child) {
return mEdgeFlag & (ViewDragHelper.EDGE_LEFT | ViewDragHelper.EDGE_RIGHT);
}
@Override
public int getViewVerticalDragRange(View child) {
return mEdgeFlag & (ViewDragHelper.EDGE_BOTTOM | ViewDragHelper.EDGE_TOP);
}
@Override
public void onViewPositionChanged(View changedView, int left, int top, int dx, int dy) {
super.onViewPositionChanged(changedView, left, top, dx, dy);
View contentView = getContentView();
if(contentView != null) {
if ((mEdgeFlag & ViewDragHelper.EDGE_LEFT) != 0) {
mScrollPercent = Math.abs((float)(left - mInsets.left)
/ contentView.getWidth());
}
if ((mEdgeFlag & ViewDragHelper.EDGE_RIGHT) != 0) {
mScrollPercent = Math.abs((float)(left - mInsets.left)
/ contentView.getWidth());
}
if ((mEdgeFlag & ViewDragHelper.EDGE_BOTTOM) != 0) {
mScrollPercent = Math.abs((float)(top - mInsets.top)
/ contentView.getHeight());
}
if ((mEdgeFlag & ViewDragHelper.EDGE_TOP) != 0) {
mScrollPercent = Math.abs((float)top
/ contentView.getHeight());
}
mContentLeft = left;
mContentTop = top;
invalidate();
if (mSlideCallback != null && mScrollPercent < SLIDE_MAX_PERCENT) {
mSlideCallback.onPositionChanged(mScrollPercent);
}
// SCROLLER_MAX_PERCENT = 0.99f
if (mScrollPercent >= SCROLLER_MAX_PERCENT) {
// todo: 添加退出Activity操作,执行Activity的onBackPressed()方法
if (mSlideCallback != null) {
mSlideCallback.onSwipeFinished();
}
}
}
}
@Override
public void onViewReleased(View releasedChild, float xvel, float yvel) {
final int childWidth = releasedChild.getWidth();
final int childHeight = releasedChild.getHeight();
boolean fling = false;
int left = mInsets.left, top = 0;
if ((mEdgeFlag & ViewDragHelper.EDGE_LEFT) != 0) {
if (Math.abs(xvel) > mFlingVelocity) {
fling = true;
}
left = xvel >= 0 && (fling || mScrollPercent > mScrollThreshold)
? childWidth + mInsets.left : mInsets.left;
}
if ((mEdgeFlag & ViewDragHelper.EDGE_RIGHT) != 0) {
if (Math.abs(xvel) > mFlingVelocity) {
fling = true;
}
left = xvel <= 0 && (fling || mScrollPercent > mScrollThreshold)
? -childWidth + mInsets.left : mInsets.left;
}
if ((mEdgeFlag & ViewDragHelper.EDGE_TOP) != 0) {
if (Math.abs(yvel) > mFlingVelocity) {
fling = true;
}
top = yvel >= 0 && (fling || mScrollPercent > mScrollThreshold)
? childHeight : 0;
}
if ((mEdgeFlag & ViewDragHelper.EDGE_BOTTOM) != 0) {
if (Math.abs(yvel) > mFlingVelocity) {
fling = true;
}
top = yvel <= 0 && (fling || mScrollPercent > mScrollThreshold)
? -childHeight + mInsets.top : 0;
}
mDragHelper.settleCapturedViewAt(left, top);
invalidate();
}
@Override
public void onViewDragStateChanged(int state) {
super.onViewDragStateChanged(state);
if (mSlideCallback != null) {
mSlideCallback.onStateChanged(state);
}
}
@Override
public int clampViewPositionHorizontal(View child, int left, int dx) {
int ret = mInsets.left;
if (!canChildScrollLeft() && (mEdgeFlag & ViewDragHelper.EDGE_LEFT) != 0) {
ret = Math.min(child.getWidth(), Math.max(left, 0));
} else if (!canChildScrollRight() && (mEdgeFlag & ViewDragHelper.EDGE_RIGHT) != 0) {
ret = Math.min(mInsets.left, Math.max(left, -child.getWidth()));
}
return ret;
}
@Override
public int clampViewPositionVertical(View child, int top, int dy) {
View contentView = getContentView();
if(contentView != null) {
int ret = contentView.getTop();
if (!canChildScrollDown() && (mEdgeFlag & ViewDragHelper.EDGE_BOTTOM) != 0) {
ret = Math.min(0, Math.max(top, -child.getHeight()));
} else if (!canChildScrollUp() && (mEdgeFlag & ViewDragHelper.EDGE_TOP) != 0) {
ret = Math.min(child.getHeight(), Math.max(top, 0));
}
return ret;
}
return 0;
}
}
}
关键代码都有注释,并且ViewDragHelper.Callback中每个方法具体用途也已经解释过,不再赘述。
三、显示上一级Activity的View
如何显示上一级Activity的view也有两种普遍做法:
-
当前Activity背景设置为透明:
Activity设置透明主题,最简单便捷的一种方式为直接在manifest中对activity设置一个透明的theme,如:
<resources> <!-- Application theme. --> <style name="AppTheme" parent="@style/Theme.AppCompat.Light.NoActionBar"> <item name="android:windowIsTranslucent">true</item> </style> </resources>
然后直接设置为application或者对应activity的theme即可。或者直接设置activity的
background="@android:color/transparent"
也可以达到同样效果。但以设置theme的方式来对activity进行统一设置,往往会改动比较大,比如我们自己实现了一个测滑退出的库,要接入已经上线的工程代码中,这样的修改会导致全部activity都跟着修改theme,接入成本略高,所以我们可以采用下面
TranslucentUtils
类的方式来对activity做修改,具体代码如下:public class TranslucentUtils { /** * 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. */ public static void convertActivityFromTranslucent(Activity activity) { try { @SuppressLint("PrivateApi") Method method = Activity.class.getDeclaredMethod("convertFromTranslucent"); method.setAccessible(true); method.invoke(activity); } catch (Throwable t) { t.printStackTrace(); } } /** * Convert a translucent themed Activity * {@link android.R.attr#windowIsTranslucent} back from opaque to * translucent following a call to * {@link #convertActivityFromTranslucent(android.app.Activity)} . * <p> * Calling this allows the Activity behind this one to be seen again. Once * all such Activities have been redrawn * <p> * This call has no effect on non-translucent activities or on activities * with the {@link android.R.attr#windowIsFloating} attribute. */ public static void convertActivityToTranslucent(Activity activity) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { convertActivityToTranslucentAfterL(activity); } else { convertActivityToTranslucentBeforeL(activity); } } public static boolean isCanSetActivityToTranslucent() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { return checkActivityToTranslucentAfterL(); } else { return checkActivityToTranslucentBeforeL(); } } @SuppressLint("PrivateApi") private static boolean checkActivityToTranslucentBeforeL() { try { Class<?>[] classes = Activity.class.getDeclaredClasses(); Class<?> translucentConversionListenerClazz = null; for (Class clazz : classes) { if (clazz.getSimpleName().contains("TranslucentConversionListener")) { translucentConversionListenerClazz = clazz; } } Activity.class.getDeclaredMethod(CONVERT_TO_TRANSLUCENT, translucentConversionListenerClazz); return true; } catch (Throwable t) { t.printStackTrace(); return false; } } @SuppressLint("PrivateApi") private static boolean checkActivityToTranslucentAfterL() { try { Activity.class.getDeclaredMethod(GET_ACTIVITY_OPTIONS); Class<?>[] classes = Activity.class.getDeclaredClasses(); Class<?> translucentConversionListenerClazz = null; for (Class clazz : classes) { if (clazz.getSimpleName().contains("TranslucentConversionListener")) { translucentConversionListenerClazz = clazz; } } Activity.class.getDeclaredMethod(CONVERT_TO_TRANSLUCENT, translucentConversionListenerClazz, ActivityOptions.class); return true; } catch (Throwable t) { t.printStackTrace(); return false; } } /** * Calling the convertToTranslucent method on platforms before Android 5.0 */ private static void convertActivityToTranslucentBeforeL(Activity activity) { try { Class<?>[] classes = Activity.class.getDeclaredClasses(); Class<?> translucentConversionListenerClazz = null; for (Class clazz : classes) { if (clazz.getSimpleName().contains("TranslucentConversionListener")) { translucentConversionListenerClazz = clazz; } } @SuppressLint("PrivateApi") Method method = Activity.class.getDeclaredMethod(CONVERT_TO_TRANSLUCENT, translucentConversionListenerClazz); method.setAccessible(true); method.invoke(activity, new Object[] { null }); } catch (Throwable t) { t.printStackTrace(); } } /** * Calling the convertToTranslucent method on platforms after Android 5.0 */ private static void convertActivityToTranslucentAfterL(Activity activity) { try { @SuppressLint("PrivateApi") Method getActivityOptions = Activity.class.getDeclaredMethod(GET_ACTIVITY_OPTIONS); getActivityOptions.setAccessible(true); Object options = getActivityOptions.invoke(activity); Class<?>[] classes = Activity.class.getDeclaredClasses(); Class<?> translucentConversionListenerClazz = null; for (Class clazz : classes) { if (clazz.getSimpleName().contains("TranslucentConversionListener")) { translucentConversionListenerClazz = clazz; } } @SuppressLint("PrivateApi") Method convertToTranslucent = Activity.class.getDeclaredMethod(CONVERT_TO_TRANSLUCENT, translucentConversionListenerClazz, ActivityOptions.class); convertToTranslucent.setAccessible(true); convertToTranslucent.invoke(activity, null, options); } catch (Throwable t) { t.printStackTrace(); } } }
我们只需要在需要通过设置“透明”方式来实现测滑的方案中,针对接入测滑功能的activity,调用
TranslucentUtils.isCanSetActivityToTranslucent()
统一判断当前设备是否支持反射的方式设置透明,继而使用TranslucentUtils.convertActivityToTranslucent(activity)
来完成activity透明的设置。这样既不影响现有activity的theme设置,也达到了我们的目的。虽然我们可以通过TranslucentUtils来做到便捷修改activity为透明,但采用“当前Activity背景设置为透明”的这种方式来完成“测滑退出”的过程2,将会带来一些意想不到的问题,因为activity设置了透明背景,那么在启动当前activity后,前一个activity的生命周期将发生变化,PreActivity将不会执行onStop,而是仅执行onPause方法。如果你的应用在不同activity的生命周期(主要是onPause和onStop)做了一些回收及状态处理操作,那这种实现方式无疑是比较蛋疼的,所以这种方式实现过程2虽然简单,但后续坑很多 ...
-
在当前Activity中绘制前置Activity的DecorView的方式
让我们回过头来看文章开头“头条”的测滑退出效果,可以发现其前一个Activity会根据当前页面滑动的范围做一个scale动画,如果我们要实现类似的效果,很显然采用上面“透明方案”无法做到。既然涉及到对View做动画,肯定需要首先从当前Activity拿到前置Activity的view才可以,我们都知道在Android中Activity并不负责控制视图,真正控制视图的是Window,而Window作为视图的承载器,其内部持有一个DecorView,这个DecorView才是 view 的根布局(更多Window相关资料请自行学习)。所以我们只需要在当前Activity中拿到前置Activity的DecorView,在过程1处理滑动时,再对其做各种动画即可,获取activity的decorView:
public static View getActivityDecorView(Activity activity) { return activity != null ? activity.getWindow().getDecorView() : null; }
这样我们只需要将过程1与过程2进行结合,就能实现类似的效果,下面将描述最终解决方案。
三、最终方案
通过上述分别讲述滑动测滑的两个过程,我采用的是“ViewDragHelper实现滑动” + “获取前置Activity DecorView”的方式来实现测滑退出。为了降低已有工程的接入成本,我们可以考虑实现Application.ActivityLifecycleCallbacks
接口,在各个activity的生命周期中来接入测滑退出。
首先我们需要在Callback的onActivityCreated方法中将每个已启动activity保存到:Stack<SoftReference<Activity>> mActivityStack
中,这样便于后续我们查找前置Activity,然后在需要接入测滑退出功能的activity(可通过注解/基类回调来判断当前activity是否需要接入)onActivityCreated方法中完成接入操作,其大致流程为:
获取前置Activity:
private Activity findPreActivity() {
Activity preActivity = null;
Stack<SoftReference<Activity>> activityStack = ActivityManager.getInstance().getActivityStack();
if(!CollectionUtils.isEmpty(activityStack) && activityStack.size() > 1) {
int reciprocalIndex = 2;
SoftReference<Activity> softRA = activityStack.get(activityStack.size() - reciprocalIndex);
while(softRA != null && softRA.get() != null) {
preActivity = softRA.get();
//preActivity是否已经finish
if(preActivity.isFinishing()) {
reciprocalIndex++;
if(activityStack.size() < reciprocalIndex) { //无法获取到当前已经finish掉之前的activity了,置空.
return null;
} else {
softRA = activityStack.get(activityStack.size() - reciprocalIndex);
}
} else {
return preActivity;
}
}
}
return preActivity;
}
如果前置Activity为空,那么可以考虑取消接入当前Activity的测滑退出,或者添加一个默认preView。下面我们来看有了前置Activity的DecorView后,如何构造当前Activity的布局:
从图中我们可以看到,我们可以将过程1和过程2结合,并且构造一个FrameLayout作为当前Activity新的decorView,而将原有decorView添加到包含滑动处理的SwipeLayout中,将前置Activity的decorView添加到maskViewLayout,作为previewContainer,同时还可在maskViewLayout中添加蒙层并且可对整个maskLayout做各种动画操作。
MaskViewLayout.java
大致代码如下:
public class MaskViewLayout extends FrameLayout {
private WeakReference<View> mPreContentViewRef;
private float mDragOffset;
private IMaskTransform iMaskTransform;
public MaskViewLayout(@NonNull Context context) {
super(context);
init();
}
public MaskViewLayout(Context context, AttributeSet attributeSet) {
super(context, attributeSet);
init();
}
public MaskViewLayout(Context context, AttributeSet attributeSet, int i) {
super(context, attributeSet, i);
init();
}
private void init() {
setBackgroundColor(Color.WHITE);
}
public void setPreContentView(View view, int backupContentRes) {
if(view == null && backupContentRes != 0) {
//构造一个默认preView
View backupView = SwipeConstantUtils.inflateView(getContext(),backupContentRes,this);
if(backupView != null) {
backupView.setId(R.id.mask_backup_preview);
backupView.setVisibility(View.GONE);
addView(backupView);
}
return;
}
if (mPreContentViewRef == null || mPreContentViewRef.get() == null || mPreContentViewRef.get() != view) {
if (!(mPreContentViewRef == null || mPreContentViewRef.get() == null)) {
mPreContentViewRef.clear();
}
mPreContentViewRef = new WeakReference(view);
}
}
//设置滑动距离
public void setDragOffset(float f) {
Object obj = null;
if (mDragOffset < 0.01f && f >= 0.01f) {
obj = 1;
}
this.mDragOffset = f;
if (obj != null) {
setBackupPreviewVisible();
invalidate();
}
}
private void setBackupPreviewVisible() {
View backupView = findViewById(R.id.mask_backup_preview);
if(backupView != null && backupView.getVisibility() != View.VISIBLE) {
backupView.setVisibility(View.VISIBLE);
}
}
@Override
public void draw(Canvas canvas) {
super.draw(canvas);
if (this.mDragOffset <= 0) {
return;
}
View view = getCloneView();
if (view != null) {
drawContentView(view, canvas);
drawMaskView(canvas);
return;
}
drawMaskView(canvas);
return;
}
private void drawContentView(View view, Canvas canvas) {
if (view != null && canvas != null) {
// 对前置contentView做动画
iMaskTransform.animateContentView(canvas,getWidth(),getHeight(),mDragOffset);
canvas.translate(0.0f, (float) (getHeight() - view.getHeight()));
view.draw(canvas);
invalidate();
}
}
private void drawMaskView(Canvas canvas) {
if (iMaskTransform.isDrawMask()) {
//绘制蒙层
iMaskTransform.drawMask(this,canvas,getWidth(),getHeight(),mDragOffset);
invalidate();
}
}
protected void onDetachedFromWindow() {
super.onDetachedFromWindow();
recycleContentView();
}
private void recycleContentView() {
if (mPreContentViewRef != null && mPreContentViewRef.get() != null) {
mPreContentViewRef.clear();
}
}
public View getCloneView() {
if (mPreContentViewRef == null || mPreContentViewRef.get() == null) {
return null;
}
return mPreContentViewRef.get();
}
public void setMaskTransform(IMaskTransform transform) {
this.iMaskTransform = transform;
}
public void setSlidingVideoHandler(SlidingVideoHandler slidingVideoHandler) {
this.slidingVideoHandler = slidingVideoHandler;
}
}
在SwipebackLayout.java
中添加如下方法:
/**
* attach activity to container
*
* @param activity the activity
* @param viewContainer the parent
* @param swipeImplMode swipe implement mode : preview or transparent
*/
public void attachActivityToContainer(Activity activity, ViewGroup viewContainer) {
if(mSwipeActivityRef != null && mSwipeActivityRef.get() != null) {
mSwipeActivityRef.clear();
}
mSwipeActivityRef = new WeakReference<>(activity);
if(mSwipeActivityRef.get() != null) {
ViewGroup decor = (ViewGroup)SwipeConstantUtils.getActivityDecorView(mSwipeActivityRef.get());
if(decor != null) {
ViewGroup decorChild = (ViewGroup)decor.getChildAt(0);
if(decorChild != null) {
decor.removeView(decorChild);
addView(decorChild, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT);
setContentView(decorChild);
if (viewContainer != null) {
viewContainer.addView(this);
decor.addView(viewContainer);
//set id when container add this success.
setId(R.id.swipe_layout);
}
}
}
}
}
并在onViewPositionChanged()
方法中添加:
@Override
public void onViewPositionChanged(View changedView, int left, int top, int dx, int dy) {
super.onViewPositionChanged(changedView, left, top, dx, dy);
//此处代码文章上述已添加
...
// 滑动位置改变时回调到外部
if (mSlideCallback != null && mScrollPercent < SLIDE_MAX_PERCENT) {
mSlideCallback.onPositionChanged(mScrollPercent);
}
if (mScrollPercent >= SCROLLER_MAX_PERCENT) {
if (mSwipeActivityRef != null && mSwipeActivityRef.get() != null && !mSwipeActivityRef.get()
.isFinishing()) {
Activity activity = mSwipeActivityRef.get();
activity.onBackPressed();
//in case of any activity override onBackPressed,do not finished activity cause exception.
activity.finish();
activity.overridePendingTransition(0, 0);
if (mSlideCallback != null) {
mSlideCallback.onSwipeFinished();
}
}
}
}
}
接下来就是构建当前Activity接入测滑退出,我们可以使用前置Activity获取其DecorView后,构造MaskViewLayout,并添加到外层FrameLayout中,接着构造SwipeSlideCallback
回调:
SwipeSlideCallback swipeSlideCallback = new SwipeSlideCallback() {
@Override
public void onStateChanged(int state) {
}
@Override
public void onPositionChanged(float percent) {
maskViewLayout.setDragOffset(percent);
}
@Override
public void onSwipeFinished() {
maskViewLayout.onSwipeFinished();
}
};
最终构造SwipeBackLayout,并将其添加到外层FrameLayout中:
backLayout.attachActivityToContainer(activity, frameLayout);
backLayout.setSlideCallback(swipeSlideCallback);
至此,我们就完成了对当前Activity接入测滑退出的整体操作。至于如何在onActivityCreated()方法中判断当前activity是否需要接入测滑退出,可通过添加特殊annotation或者基类Activity实现钩子的方式进行判断,不再赘述。
按照当前方案来实现可以有效避免采用“透明背景”方案中影响Activity生命周期的情况,而且可以做到接入成本更低,美中不足就是会增加现有View层级,当然滑动处理部分,我们可以采用DrawerLayout或者自定义onTouchEvent的方式(见上文),但仍不可避免增加View层级的问题。如果你有更好的实现方式,欢迎指教~
在最初实现此方案时,同样遇到了几个问题比较典型,在此列出:
-
获取前置Activity的DecorView后,在当前页面绘制时,如果PreDecorView中有用到Fresco库中GenericDraweeView组件加载图片时,在7.0以上的设备中会出现无法正常显示图片的情况(使用系统自带ImageView没有此问题),该问题原因为:当 api >= 24(android 7.0以上)时,在
ViewGroup.attachToParent()
方法中调用了ViewGroup.dispatchVisibilityAggregated()
方法,而dispatchVisibilityAggregated ()
方法最终会调用各个 子view 的以下方法:onVisibilityAggregated,而系统的ImageView内部实现为:public void onVisibilityAggregated(boolean isVisible) { super.onVisibilityAggregated(isVisible); // Only do this for new apps post-Nougat if (mDrawable != null && !sCompatDrawableVisibilityDispatch) { mDrawable.setVisible(isVisible, false); } }
也就是设置了对应的 drawable 的
visible = false
,但默认的 Drawable 在 draw 的时候,不管 visible 的值是 true 还是 false,都会进行绘制。而Fresco的GenericDraweeView在实现时,与其绑定的Drawable指定为RootDrawable,RootDrawable在绘制时,会对 isVisible 进行判断,如果 isVisible=false ,那么就不进行绘制。所以导致只有 fresco 的图片会出现此问题! 并且只是在 7.0 以上设备会出现(因为 7.0 以下不会在 ViewGroup 中回调到 onVisibilityAggregated 方法)
解决办法: 重写DenericDraweeView中onVisibilityAggregated方法如:
@Override public void onVisibilityAggregated(boolean isVisible) { super.onVisibilityAggregated(isVisible); if (getDrawable() != null) { getDrawable().setVisible(true, false); } }
-
当我们设置滑动模式为全屏均可滑动时(并非仅边缘可测滑退出),如果当前 Activity 的 contentView 视图中存在与滑动方向一致的可滑动组件(如 ScrollView, ViewPager, RecyclerView等),内部可滑动的View将会无法滚动。出现此问题的原因为 SwipeBackLayout 在处理 touchEvent 时,全部都交给ViewDragHelper 来统一处理,而 ViewDragHelper 没有检测其内部是否存在可滑动组件,所以导致出现此问题。之前有看到网上有些解法说可以遍历当前 Activity 内部的 contentView ,直到找到第一个可滑动组件,但这种解法当 contentView 内部包含多个可滑动组件时,仍会出现问题,而且遍历查找过程可以通过 Activity 内部设置 childScrollView 的方式来避免。
解决办法:SwipebackLayout 新增
setChildScrollView(View scrollView)
接口,有需要的 Activity 可查找对应 SwipebackLayout 后,设置对应childScrollView。并且在 ViewDragHelper.Callback 接口 clampViewPositionHorizontal 及 clampViewPositionVertical 中对 childScrollView 是否可滑动添加判断,如:@Override public int clampViewPositionHorizontal(View child, int left, int dx) { int ret = mInsets.left; if (!canChildScrollLeft() && (mEdgeFlag & ViewDragHelper.EDGE_LEFT) != 0) { ret = Math.min(child.getWidth(), Math.max(left, 0)); } else if (!canChildScrollRight() && (mEdgeFlag & ViewDragHelper.EDGE_RIGHT) != 0) { ret = Math.min(mInsets.left, Math.max(left, -child.getWidth())); } return ret; }
详见上述
SwipeBackLayout.java
。 前置 Activity 中如果包含播放组件相关View,如 SurfaceView 或 TextureView 时,要注意 Player 的播放状态控制,避免出现在 onPause() 中暂停,在 onResume 中再次马上恢复暂停且立即展示 SurfaceView,可能会出现 SurfaceView 位置错乱问题(具体原因尚不明确)。推荐在 onResume 时暂停播放并且隐藏播放的 SurfaceView,在
SwipeSlideCallback.onSwipeFinished()
回调中采用 Handler.postDelayed(action, 300) 方式延迟展示 SurfaceView ,可以解决此问题。如果有遇到类似问题的朋友有更好的解决办法,可以联系我,多谢~
四、总结
本文介绍及简要分析了常见的实现Android测滑退出的几种常见方案,并且给出了笔者认为相对靠谱的一种实现方案,但该方案仍有一定局限性(支持滑动的页面内,如果存在与子View冲突的滑动view,仅能支持一个子View,即childScrollView),分析及实现过程难免存在一定错误,如有发现,烦请不吝赐教。
鸣谢及参考: