Android 事件分发实例之右滑结束Activity(一)

前言

以前就想着做一系列实际项目中用到关于事件分发的例子,以前研究过壁纸,有一种壁纸是音乐锁屏壁纸,音乐锁屏就需要用到右滑结束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的根布局颜色。设置样式如下:

<!--<item name="android:windowFullscreen">true</item>-->
    <style name="SlideTheme" parent="@style/AppTheme">
        <!--Required-->
        <item name="android:windowBackground">@android:color/transparent</item>
        <item name="android:windowIsTranslucent">true</item>
        <item name="android:windowNoTitle">true</item>
        <item name="android:windowAnimationStyle">@style/SlideAnimation</item>
    </style>

说明: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(),目前为止测试了如下图所示的情况:

适配侧滑的各种视图.png

如上图所示,通过上图所有测试,发现两个不适配,倒数第二个是前阵子做的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

推荐阅读更多精彩内容