前言
以前就想着做一系列实际项目中用到关于事件分发的例子,以前研究过壁纸,有一种壁纸是音乐锁屏壁纸,音乐锁屏就需要用到右滑结束activity,遂想着研究事件分发不错的例子,当时是使用ViewDraghelper实现的,不过部分事件分发没处理好,此次正好处理一下。其实五一之前就做好一部分了,某一天清桌面误删文件,导致现在做的是重新做的,还有另一种方式放下一篇叙述。
分析阶段
- 一:需要一个通用的ViewGroup随着手势右滑滚动
- 二:在Move事件结束之后,如果超过阈值就关闭当前ViewGroup,否者回归到默认位置
- 三:滑动结束之后通过所处位置来判定当前activity的是否结束状态
- 四:需要当前ViewGroup包裹activity的布局一起滑动
- 五:滑动结束之后需要符合系统默认动画,平滑过渡
- 六:滑动过程中需要下层activity显示
- 七:右滑事件处理和事件冲突处理
通过以上七点分析,因此可以指定步骤,按照步骤一步一步解决即可。需要平滑滚动,选择Scroller开实现平滑滚动效果,第四点的话,需要把自定义的ViewGroup添加到DecorView第一个位置,这样即可实现包裹activity的布局。具体步骤如下:
具体步骤
事件分发与消费
@Override
public boolean onInterceptTouchEvent(MotionEvent event) {
boolean interceptd = false;
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
interceptd = false;
mDownX = event.getX();
mDownY = event.getY();
break;
case MotionEvent.ACTION_MOVE:
//计算移动距离 判定是否滑动
float dx = event.getX() - mDownX;
float dy = event.getY() - mDownY;
if (dx > minTouchSlop && dx - Math.abs(dy) > minTouchSlop ) {
interceptd = true;
} else {
interceptd = false;
}
break;
case MotionEvent.ACTION_UP:
interceptd = false;
break;
}
return interceptd;
}
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_MOVE:
float dx = event.getX() - mDownX;
if (getScrollX() - dx >= 0) {
scrollTo(0, 0);
} else {
scrollBy((int) -dx, 0);
}
mDownX = event.getX();
break;
case MotionEvent.ACTION_UP:
// 根据手指释放时的位置决定回弹还是关闭
int scrollX = getScrollX();
if (-scrollX < getWidth() * mSlideFinishRadio) {
smoothScrollX(scrollX, -scrollX, smoothscrollTime, mScroller);
} else {
smoothScrollX(scrollX, -scrollX - getWidth(), smoothscrollTime, mScroller);
}
break;
}
return true;
}
/**
* 平滑的滚动到某个位置
*
* @param startX 开始位置
* @param endX 结束位置
* @param duration 时间
* @param mScroller
*/
private void smoothScrollX(int startX, int endX, int duration, Scroller mScroller) {
mScroller.startScroll(startX, 0, endX, 0, duration);
invalidate();
}
说明:1、dx > minTouchSlop:这个条件判定右滑,并且超过系统能检测到的最小滑动距离
2、dx - Math.abs(dy) > minTouchSlop:右滑优先级高于上下滑动,(亦可dx - Math.abs(dy) > 0)
3、getScrollX() - dx >= 0:判定滑动是否超越左边界,超过的话滚动到默认位置(0,0)
4、scrollBy((int) -dx, 0):通过不断修改当前的位置去滚到相应的位置
5、-scrollX < getWidth() * mSlideFinishRadio:判断当前的滚动的位置与设置的阈值大小来断定最终位置
6、mScroller.startScroll(startX, 0, endX, 0, duration):开启松手之后滚动到指定位置(具体参数意思已经注释)
下面方法主要是处理Scroller平滑滚动过程,
@Override
public void computeScroll() {
if (mScroller.computeScrollOffset()) {
scrollTo(mScroller.getCurrX(), 0);
postInvalidate();
} else if (-getScrollX() >= getWidth()) {
mActivity.finish();
}
}
说明:mScroller.computeScrollOffset() :只要scrollTo()的过程没完成,此方法的回调一直为true,通过不断的调用scrollTo()和postInvalidate()(线程安全的方法)去刷新界面,如果滑动结束,并且左边界滑动的最右边的时候结束activity
绑定activity
最终我们的效果是实现右滑到一定阈值结束当前的activity,因此需要把当前的viewgroup加入当前activity所在的DecorView 中.
/**
* 绑定Activity
*/
public void attachActivity(Activity activity) {
mActivity = activity;
ViewGroup decorView = (ViewGroup) mActivity.getWindow().getDecorView();
View child = decorView.getChildAt(0);
child.setBackgroundResource(android.R.color.white);
decorView.removeView(child);
addView(child);
decorView.addView(this);
}
说明: child.setBackgroundResource(android.R.color.white),为什么要设置背景色?暂且留着后面会说因为说明原因
设置样式
通过以上步骤已经可以实现滑动结束activity过程,但是滑动过程中,背景一直为白色,结束之后并且会有突兀的动画效果。为了实现滑动过程中有渐变的效果,遂设置当前window背景透明色,但是如果这样设置的话,当前activity在不滑动过程中也是透明状态,因此需要给activity布局设置一个背景色,上面attachActivity()设置白色就是为了统一设置,避免每次都去设置activity的根布局颜色。设置样式如下:
说明:1、windowAnimationStyle的作用是滑动超过阈值之后结束activity的动画与设置的滑动效果一致
windowIsTranslucent
这里单独说一下这个属性windowIsTranslucent,先说这个属性的作用,如果windowIsTranslucent为false的话,无论windowBackground设置的是什么颜色,此时window背景都不可能为透明色,因此两者要搭配使用才有效果。windowBackground最终设置方法在源码DecorView的里面,也就是当前activity的最底层的背景色。下面是设置DecorView背景色在源码中的方法。
设置背景色
public void setWindowBackground(Drawable drawable) {
if (getBackground() != drawable) {
setBackgroundDrawable(drawable);
if (drawable != null) {
mResizingBackgroundDrawable = enforceNonTranslucentBackground(drawable,
mWindow.isTranslucent() || mWindow.isShowingWallpaper());
} else {
mResizingBackgroundDrawable = getResizingBackgroundDrawable(
getContext(), 0, mWindow.mBackgroundFallbackResource,
mWindow.isTranslucent() || mWindow.isShowingWallpaper());
}
if (mResizingBackgroundDrawable != null) {
mResizingBackgroundDrawable.getPadding(mBackgroundPadding);
} else {
mBackgroundPadding.setEmpty();
}
drawableChanged();
}
}
/**
* Enforces a drawable to be non-translucent to act as a background if needed, i.e. if the
* window is not translucent.
*/
private static Drawable enforceNonTranslucentBackground(Drawable drawable,
boolean windowTranslucent) {
if (!windowTranslucent && drawable instanceof ColorDrawable) {
ColorDrawable colorDrawable = (ColorDrawable) drawable;
int color = colorDrawable.getColor();
if (Color.alpha(color) != 255) {
ColorDrawable copy = (ColorDrawable) colorDrawable.getConstantState().newDrawable()
.mutate();
copy.setColor(
Color.argb(255, Color.red(color), Color.green(color), Color.blue(color)));
return copy;
}
}
return drawable;
}
事件冲突处理
上述步骤已经可以实现右滑结束activity效果了,但是未提及分析过程中的第七条。主要是因为事件滑动冲突问题处理是Android系统事件分发中最难处理的一块,因此放在最后处理,即嵌套滑动冲突问题。关于嵌套滑动,大家应该都不默认,刚接触那会估计都被ScrollView里面嵌套ListView或者RecyclerView困扰过,因为两者都是可以滚动的,到底滑动事件分发应该怎么做呢,理想情况下是列表滚动到头部或者尾部再把事件交给ScrollView处理,但是实际情况是,要么是两者同时滚动要么是列表只显示一部分。这是因为ScrollView源码里也是使用Scroller来实现滑动,因此ScrollView的第一层子类只能有一个,通过遍历,测量所有子View的宽和高,而ListView和RecyclerView内部都是使用缓存复用机制,因此ScrollView并不能一次性测量到所有的ListView或RecyclerView的item。网上有很多关于解决ScrollView嵌套ListView或RecyclerView的方案,其核心思想还是测量出所有ListView或RecyclerView的子类的宽高,因此导致ListView或RecyclerView缓存复用机制无效,谷歌也是不建议这样做,因此最好不要做嵌套。回归主题,如果侧滑的activity里面有ViewPager会怎么样?没错,因为自定义的拦截条件约束在:
dx > minTouchSlop && dx - Math.abs(dy) > minTouchSlop
假如现在ViewPager不是处于第一项,自定义的侧滑ViewGroup如果满足上面判定拦截条件,ViewPager的滚动机制一定会被拦截掉,一直响应侧滑。但是理想情况下,希望ViewPager右滑的过程中滑动到左边第一项的时候再被拦截。因为ViewPager内部肯定是处理了滑动事件,因此可以参ViewPager内部怎么处理方式。ViewPager内部也是通过Scroller来处理滑动过程的,查看ViewPager源码有没有检测滑动的方法,如下所示:
protected boolean canScroll(View v, boolean checkV, int dx, int x, int y) {
if (v instanceof ViewGroup) {
final ViewGroup group = (ViewGroup) v;
final int scrollX = v.getScrollX();
final int scrollY = v.getScrollY();
final int count = group.getChildCount();
// Count backwards - let topmost views consume scroll distance first.
for (int i = count - 1; i >= 0; i--) {
// TODO: Add versioned support here for transformed views.
// This will not work for transformed views in Honeycomb+
final View child = group.getChildAt(i);
if (x + scrollX >= child.getLeft() && x + scrollX < child.getRight()
&& y + scrollY >= child.getTop() && y + scrollY < child.getBottom()
&& canScroll(child, true, dx, x + scrollX - child.getLeft(),
y + scrollY - child.getTop())) {
return true;
}
}
}
return checkV && v.canScrollHorizontally(-dx);
}
ViewPager内部也是通过遍历所有子View的滚动方向,然后调用v.canScrollHorizontally(-dx)来判定水平方向上是否有可以滚动的子View。主要研究v.canScrollHorizontally(-dx),此方法是View的可重写方法。
/**
* Check if this view can be scrolled horizontally in a certain direction.
*
* @param direction Negative to check scrolling left, positive to check scrolling right.
* @return true if this view can be scrolled in the specified direction, false otherwise.
*/
public boolean canScrollHorizontally(int direction) {
final int offset = computeHorizontalScrollOffset();
final int range = computeHorizontalScrollRange() - computeHorizontalScrollExtent();
if (range == 0) return false;
if (direction < 0) {
return offset > 0;
} else {
return offset < range - 1;
}
}
说明:1、参数direction的意思是:检查向左滚动为负,检查向右滚动为正。(左滚动是从左到右,scrollx为负值)
2、返回值的意思:如果这个视图可以在指定的方向上滚动,则返回true,否则返回false。
3、由于computeHorizontalScrollRange() 与computeHorizontalScrollExtent()方法的返回值调用同一个方法,因此方法返回值默认为false,后面会用到。
因为之前研究刷新控件知道的这个方法,当然如果activity里面只是ViewPager,通过调ViewPager重写的canScrollHorizontally(int direction) 即可实现想要的效果,但是activity里面也肯存在其他列表(ListView、RecyclerView、ScrollView等)),因此需要遍历activity里面View树,只要有一个View或者ViewGroup可以在从左到右的方向上滚动,就不去拦截子类的右滑事件。所有的View或者ViewGroup的返回值都为false才把右滑事件交给SlideLayout处理。因此采用递归方式遍历所有的View树结构:
/**
* 是否左右可以滚动
*
* @param direction
* @param view
*/
private boolean canScrollHorizontally(int direction, View view) {
if (view.canScrollHorizontally(direction)) {
return true;
} else {
if (view instanceof ViewGroup) {
ViewGroup viewParent = (ViewGroup) view;
int childCount = viewParent.getChildCount();
for (int i = 0; i < childCount; i++) {
View chideView = viewParent.getChildAt(i);
boolean childCanScroll = canScrollHorizontally(direction, chideView);
if (childCanScroll) {
return true;
}
}
}
return false;
}
}
说明:因为要遍历所有的View树,并且canScrollHorizontally()方法默认返回值是false,因此必须重写。(记得要再加个判空)
测试适配
完成上述步骤,遂做各种情况的适配,Android系统可以滚动的列表基本都实现了canScrollHorizontally(),目前为止测试了如下图所示的情况:
如上图所示,通过上图所有测试,发现两个不适配,倒数第二个是前阵子做的Android 事件分发实例之可拖动的ViewGroup,因为之前没想到做这个侧滑适配,因此不匹配,适配方案如下:
下面是onTouchEvent()中的Down事件,如果处于右边缘,canScrollHorizontally()方法右滑返回值为false,如果处于左边缘,
case MotionEvent.ACTION_DOWN:
float rightX = mParentWidth - getWidth();
float x = getX();
canScrollH = x != rightX;
canScrollH2 = x != 0;
break;
目前已经更新,可适配。另一个不适配的是最后一个DrawerLayout,这个较为特殊,内部维持了一个WindowInsets集合,不同的View可以通过注入的方式添加到DrawerLayout里面,这个了解的不多,这个只支持SDK21以上版本,WindowInsetsCompat是其兼容版本,有空研究一下这个。接着说DrawerLayout是通过ViewDragHelper处理事件分发。
@SuppressWarnings("ShortCircuitBoolean")
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
final int action = ev.getActionMasked();
// "|" used deliberately here; both methods should be invoked.
final boolean interceptForDrag = mLeftDragger.shouldInterceptTouchEvent(ev)
| mRightDragger.shouldInterceptTouchEvent(ev);
boolean interceptForTap = false;
switch (action) {
case MotionEvent.ACTION_DOWN: {
final float x = ev.getX();
final float y = ev.getY();
mInitialMotionX = x;
mInitialMotionY = y;
if (mScrimOpacity > 0) {
final View child = mLeftDragger.findTopChildUnder((int) x, (int) y);
if (child != null && isContentView(child)) {
interceptForTap = true;
}
}
mDisallowInterceptRequested = false;
mChildrenCanceledTouch = false;
break;
}
case MotionEvent.ACTION_MOVE: {
// If we cross the touch slop, don't perform the delayed peek for an edge touch.
if (mLeftDragger.checkTouchSlop(ViewDragHelper.DIRECTION_ALL)) {
mLeftCallback.removeCallbacks();
mRightCallback.removeCallbacks();
}
break;
}
case MotionEvent.ACTION_CANCEL:
case MotionEvent.ACTION_UP: {
closeDrawers(true);
mDisallowInterceptRequested = false;
mChildrenCanceledTouch = false;
}
}
return interceptForDrag || interceptForTap || hasPeekingDrawer() || mChildrenCanceledTouch;
}
@Override
public boolean onTouchEvent(MotionEvent ev) {
mLeftDragger.processTouchEvent(ev);
mRightDragger.processTouchEvent(ev);
final int action = ev.getAction();
boolean wantTouchEvents = true;
switch (action & MotionEvent.ACTION_MASK) {
case MotionEvent.ACTION_DOWN: {
final float x = ev.getX();
final float y = ev.getY();
mInitialMotionX = x;
mInitialMotionY = y;
mDisallowInterceptRequested = false;
mChildrenCanceledTouch = false;
break;
}
case MotionEvent.ACTION_UP: {
final float x = ev.getX();
final float y = ev.getY();
boolean peekingOnly = true;
final View touchedView = mLeftDragger.findTopChildUnder((int) x, (int) y);
if (touchedView != null && isContentView(touchedView)) {
final float dx = x - mInitialMotionX;
final float dy = y - mInitialMotionY;
final int slop = mLeftDragger.getTouchSlop();
if (dx * dx + dy * dy < slop * slop) {
// Taps close a dimmed open drawer but only if it isn't locked open.
final View openDrawer = findOpenDrawer();
if (openDrawer != null) {
peekingOnly = getDrawerLockMode(openDrawer) == LOCK_MODE_LOCKED_OPEN;
}
}
}
closeDrawers(peekingOnly);
mDisallowInterceptRequested = false;
break;
}
case MotionEvent.ACTION_CANCEL: {
closeDrawers(true);
mDisallowInterceptRequested = false;
mChildrenCanceledTouch = false;
break;
}
}
return wantTouchEvents;
}
@Override
public void requestDisallowInterceptTouchEvent(boolean disallowIntercept) {
if (CHILDREN_DISALLOW_INTERCEPT
|| (!mLeftDragger.isEdgeTouched(ViewDragHelper.EDGE_LEFT)
&& !mRightDragger.isEdgeTouched(ViewDragHelper.EDGE_RIGHT))) {
// If we have an edge touch we want to skip this and track it for later instead.
super.requestDisallowInterceptTouchEvent(disallowIntercept);
}
mDisallowInterceptRequested = disallowIntercept;
if (disallowIntercept) {
closeDrawers(true);
}
}
因此DrawerLayout并不需要处理canScrollHorizontally(direction)这个方法,为了兼容,需要自行处理如下:
自定义LeftDrawerLayout继承DrawerLayout,在分发事件处理。
@Override
public boolean dispatchTouchEvent(MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
mDownX = event.getX();
if (mDownX <= mEdgeSize) {
canScrollH = !isDrawerOpen(GravityCompat.START);
} else {
canScrollH = false;
}
break;
}
return super.dispatchTouchEvent(event);
}
疑惑:1、为什么在dispatchTouchEvent()方法里面处理,而不是onInterceptTouchEvent()方法里处理
2、mDownX <= mEdgeSize为什么有这个判断
解答疑惑:首先说明一个问题,一个事件总是包含三个Down、Move、Up的,偶尔还有Cancle。只要有一个Down流向某一块,其他Move、Up也会流向到某个,举个例子:客厅的灯是一条电路线,这条线中包含火线、零线、地线,客厅的灯不需要地线,但是地线也是跟着火线、零线绑定在一起流向客厅的灯的。此时来解释第一个问题,为什么判断条件放在dispatchTouchEvent()里面,那是因为onInterceptTouchEvent()交给ViewDragHelper处理,因此重写onInterceptTouchEvent()是无法监听到Move事件的,也就不难作出判断。
解决第二个疑问,mEdgeSize是ViewDragHelper检测边缘的固定值(20dp),isDrawerOpen(GravityCompat.START)这个方法是检测DrawerLayout的抽屉视图是否打开状态,按下位置在左边缘的话有两种情况,一种是:如果抽屉视图是打开状态,则交给侧滑,第二种是:如果关闭状态则交给ViewDragHelper处理。
特此说明:代码中暂时只处理左边抽屉视图情况,右边抽屉视图情况同理解决。
总结
关于本篇实现右滑结束activity的方式,重点有四点,第一点:实现拦截, 第二点实现平滑滚动,第三点:递归遍历子View是否可以右滑,第四点:样式处理。具体情况还需参考代码。其他细节,如侧滑过程中,右边缘和背景的绘制等暂时没做特殊处理,现在还在做进一步的封装,后续会持续更新最新代码。
右滑结束Activity