Android修炼系列,事件分发从手写一个嵌套滑动框架开始

Posted 码农 小生

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Android修炼系列,事件分发从手写一个嵌套滑动框架开始相关的知识,希望对你有一定的参考价值。

先放了一张效果图,是一个嵌套滑动的效果。

在说代码之前,可以先看下最终的NestedViewGroup XML结构,NestedViewGroup内部包含顶部地图 MapView和滑动布局LinearLayout,而LinearLayout布局的内部即我们常用的滑动控件 RecyclerView,在这里为何还要加层LinearLayout呢?这样做的好处是,我们可以更好的适配不同滑动控件,而不仅仅是将NestedViewGroup与RecyclerView 耦合住。

    <com.blog.a.nested.NestedViewGroup
        android:id="@+id/dd_view_group"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        didi:header_id="@+id/t_map_view"
        didi:target_id="@+id/target_layout"
        didi:inn_id="@+id/inner_rv"
        didi:header_init_top="0"
        didi:target_init_bottom="250">

        <com.tencent.tencentmap.mapsdk.maps.MapView
            android:id="@+id/t_map_view"
            android:layout_width="match_parent"
            android:layout_height="match_parent" />

        <LinearLayout
            android:id="@+id/target_layout"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:orientation="vertical"
            android:background="#fff">

            <androidx.recyclerview.widget.RecyclerView
                android:id="@+id/inner_rv"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"/>

        </LinearLayout>

    </com.mjzuo.views.nested.NestedViewGroup>

实现

在attrs.xml文件下为NestedViewGroup添加自定义属性,其中header_id对应顶部地图 MapView,target_id对应滑动布局LinearLayout,inn_id对应滑动控件RecyclerView。

<resources>
    <declare-styleable name="CompNsViewGroup">
        <attr name="header_id"/>
        <attr name="target_id"/>
        <attr name="inn_id"/>
        <attr name="header_init_top" format="integer"/>
        <attr name="target_init_bottom" format="integer"/>
    </declare-styleable>
</resources>

我们根据attrs.xml中的属性,获取XML中NestedViewGroup中的View ID。

        // 获取配置参数
        final TypedArray array = context.getTheme().obtainStyledAttributes(attrs
                , R.styleable.CompNsViewGroup
                , defStyleAttr, 0);
        mHeaderResId = array.getResourceId
                (R.styleable.CompNsViewGroup_header_id, -1);
        mTargetResId = array.getResourceId
                (R.styleable.CompNsViewGroup_target_id, -1);
        mInnerScrollId = array.getResourceId
                (R.styleable.CompNsViewGroup_inn_id, -1);
        if (mHeaderResId == -1 || mTargetResId == -1
                || mInnerScrollId == -1)
            throw new RuntimeException("VIEW ID is null");

我们根据attrs.xml中的属性,来初始化View的高度、距离等,计算高度时,需要考虑到状态栏因素:

        mHeaderInitTop = Utils.dip2px(getContext()
                , array.getInt(R.styleable.CompNsViewGroup_header_init_top, 0));
        mHeaderCurrTop = mHeaderInitTop;
        // 屏幕高度 - 底部距离 - 状态栏高度
        mTargetInitBottom = Utils.dip2px(getContext()
                , array.getInt(R.styleable.CompNsViewGroup_target_init_bottom, 0));
        // 注意:当前activity默认去掉了标题栏
        mTargetInitTop = Utils.getScreenHeight(getContext()) - mTargetInitBottom
                - Utils.getStatusBarHeight(getContext().getApplicationContext());
        mTargetCurrTop = mTargetInitTop;

通过上面获取到的View ID,我们能够直接引用到XML中的相关View实例,而后续的滑动,本质上就是针对该View所进行的一系列判断处理。

    @Override
    protected void onFinishInflate() {
        super.onFinishInflate();
        mHeaderView = findViewById(mHeaderResId);
        mTargetView = findViewById(mTargetResId);
        mInnerScrollView = findViewById(mInnerScrollId);
    }

我们重写onMeasure方法,其不仅是给childView传入测量值和测量模式,还将我们自己测量的尺寸提供给父ViewGroup让其给我们提供期望大小的区域。

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);

        measureChildren(widthMeasureSpec, heightMeasureSpec);

        int widthModle = MeasureSpec.getMode(widthMeasureSpec);
        int widthSize = MeasureSpec.getSize(widthMeasureSpec);
        int heightModle = MeasureSpec.getMode(heightMeasureSpec);
        int heightSize = MeasureSpec.getSize(heightMeasureSpec);

        ....

        setMeasuredDimension(widthSize, heightSize);
    }

我们重写onLayout方法,给childView确定位置。需要注意的是,原始bottom不是height高度,而是又向下挪了mTargetInitTop,我们可以想象成,我们一直将mTargetView挪动到了屏幕下方看不到的地方。

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        final int childCount = getChildCount();
        if (childCount == 0)
            return;
        final int width = getMeasuredWidth();
        final int height = getMeasuredHeight();

        // 注意:原始bottom不是height高度,而是又向下挪了mTargetInitTop
        mTargetView.layout(getPaddingLeft()
                , getPaddingTop() + mTargetCurrTop
                , width - getPaddingRight()
                , height + mTargetCurrTop
                        + getPaddingTop() + getPaddingBottom());

        int headerWidth = mHeaderView.getMeasuredWidth();
        int headerHeight = mHeaderView.getMeasuredHeight();
        mHeaderView.layout((width - headerWidth)/2
                , mHeaderCurrTop + getPaddingTop()
                , (width + headerWidth)/2
                , headerHeight + mHeaderCurrTop + getPaddingTop());
    }

此功能实现的核心即事件的分发和拦截了。在接收到事件时,如果上次滚动还未结束,则先停下。随后判断TargetView内的RecyclerView能否向下滑动,如果还能滑动,则不拦截事件,将事件传递给 TargetView。如果点击在Header区域,则不拦截事件,将事件传递给地图MapView。

    @Override
    public boolean onInterceptTouchEvent(MotionEvent event) {

        // 如果上次滚动还未结束,则先停下
        if (!mScroller.isFinished())
            mScroller.forceFinished(true);

        // 不拦截事件,将事件传递给TargetView
        if (canChildScrollDown())
            return false;

        int action = event.getAction();

        switch (action) {
            case MotionEvent.ACTION_DOWN:
                mDownY = event.getY();
                mIsDragging = false;
                // 如果点击在Header区域,则不拦截事件
                isDownInTop = mDownY <= mTargetCurrTop - mTouchSlop;
                break;

            case MotionEvent.ACTION_MOVE:
                final float y = event.getY();
                if (isDownInTop) {
                    return false;
                } else {
                    startDragging(y);
                }

                break;

            case MotionEvent.ACTION_UP:
            case MotionEvent.ACTION_CANCEL:
                mIsDragging = false;
                break;
        }

        return mIsDragging;
    }

当NestedViewGroup拦截事件后,会调用自身的onTouchEvent方法,逻辑与 onInterceptTouchEvent 类似,这里需要注意的是,当事件在ViewGroup内,我们要怎么手动分发给TargetView呢?代码见下:

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        if (canChildScrollDown())
            return false;

        // 添加速度监听
        acquireVelocityTracker(event);
        int action = event.getAction();

        switch (action) {
            case MotionEvent.ACTION_DOWN:
                mIsDragging = false;
                break;

            case MotionEvent.ACTION_MOVE:
                ...
                break;

            case MotionEvent.ACTION_UP:
                if (mIsDragging) {
                    mIsDragging = false;
                    mVelocityTracker.computeCurrentVelocity(500, maxFlingVelocity);
                    final float vy = mVelocityTracker.getYVelocity();
                    // 滚动的像素数太大了,这里只滚动像素数的0.1
                    vyPxCount = (int)(vy/3);
                    finishDrag(vyPxCount);
                }
                releaseVelocityTracker();
                return false;

            case MotionEvent.ACTION_CANCEL:
                // 回收滑动监听
                releaseVelocityTracker();
                return false;
        }

        return mIsDragging;
    }

这是我们手指移动ACTION_MOVE 时的逻辑:

    final float y = event.getY();
    startDragging(y);

    if (mIsDragging) {
        float dy = y - mLastMotionY;
        if (dy >= 0) {
            moveTargetView(dy);
        } else if (mTargetCurrTop + dy <= 0) {
            /**
            * 此时,事件在ViewGroup内,
            * 需手动分发给TargetView
            */
            moveTargetView(dy);
            int oldAction = event.getAction();
            event.setAction(MotionEvent.ACTION_DOWN);
            dispatchTouchEvent(event);
            event.setAction(oldAction);
        } else {
            moveTargetView(dy);
        }
        mLastMotionY = y;
    }

通过canChildScrollDown方法,我们能够判断RecyclerView是否能够向下滑动。这里后续会抽出一个adapter类,来处理不同的滑动控件。这里通过canScrollVertically来判断当前视图是否可以继续滚动,其中正数表示实际是判断手指能否向上滑动,负数表示实际是判断手指能否向下滑动:

    public boolean canChildScrollDown() {
        RecyclerView rv;
        // 当前只做了RecyclerView的适配
        if (mInnerScrollView instanceof RecyclerView) {
            rv = (RecyclerView) mInnerScrollView;
            return rv.canScrollVertically(-1);
        }
        return false;
    }

获取向上能够滑动的距离顶部距离,如果Item数量太少,导致rv不能占满一屏时,注意向上滑动的距离。

    public int toTopMaxOffset() {
        final RecyclerView rv;
        if (mInnerScrollView instanceof RecyclerView) {
            rv = (RecyclerView) mInnerScrollView;
            if (android.os.Build.VERSION.SDK_INT >= 18) {

                return Math.max(0, mTargetInitTop -
                        (rv.computeVerticalScrollRange() - mTargetInitBottom));
            }
        }
        return 0;
    }

手指向下滑动或TargetView距离顶部距离 > 0,则ViewGroup拦截事件。

    private void startDragging(float y) {
        if (y > mDownY || mTargetCurrTop > toTopMaxOffset()) {
            final float yDiff = Math.abs(y - mDownY);
            if (yDiff > mTouchSlop && !mIsDragging) {
                mLastMotionY = mDownY + mTouchSlop;
                mIsDragging = true;
            }
        }
    }

这是获取TargetView和HeaderView顶部距离的方法,我们通过不断刷新顶部距离来实现滑动的效果,并在这里添加距离监听。

    private void moveTargetViewTo(int target) {
        target = Math.max(target, toTopMaxOffset());
        if (target >= mTargetInitTop)
            target = mTargetInitTop;
        // TargetView的top、bottom两个方向都是加上offsetY
        ViewCompat.offsetTopAndBottom(mTargetView, target - mTargetCurrTop);
        // 更新当前TargetView距离顶部高度H
        mTargetCurrTop = target;

        int headerTarget;
        // 下拉超过定值H
        if (mTargetCurrTop >= mTargetInitTop) {
            headerTarget = mHeaderInitTop;
        } else if (mTargetCurrTop <= 0) {
            headerTarget = 0;
        } else {
            // 滑动比例
            float percent = mTargetCurrTop * 1.0f / mTargetInitTop;
            headerTarget = (int) (percent * mHeaderInitTop);
        }
        // HeaderView的top、bottom两个方向都是加上offsetY
        ViewCompat.offsetTopAndBottom(mHeaderView, headerTarget - mHeaderCurrTop);
        mHeaderCurrTop = headerTarget;

        if (mListener != null) {
            mListener.onTargetToTopDistance(mTargetCurrTop);
            mListener.onHeaderToTopDistance(mHeaderCurrTop);
        }
    }

这是mScroller弹性滑动时的一些阈值判断。startScroll本身并没有做任何滑动相关的事,而是通过invalidate方法来实现View重绘,在View的draw方法中会调用computeScroll方法,但本例中并没有在computeScroll中配合scrollTo来实现滑动。注意这里的滑动,是指内容的滑动,而非View本身位置的滑动。

    private void finishDrag(int vyPxCount) {
        if ((vyPxCount >= 0 && vyPxCount <= minFlingVelocity)
                || (vyPxCount <= 0 && vyPxCount >= -minFlingVelocity))
            return;

        if (vyPxCount > 0) {
            // 速度 > 0,说明正向下滚动
            // 防止超出临界值
            if (mTargetCurrTop < mTargetInitTop) {
                mScroller.startScroll(0, mTargetCurrTop, 0,
                        Math.min(vyPxCount, mTargetInitTop - mTargetCurrTop)
                        , 500);  
                invalidate();
            }
        } else if (vyPxCount < 0) {
            // 速度 < 0,说明正向上滚动

            if (mTargetCurrTop <= 0 && mScroller.getCurrVelocity() > 0) {
                // todo: inner scroll 接着滚动
            }

            mScroller.startScroll(0, mTargetCurrTop
                    , 0, Math.max(vyPxCount, -mTargetCurrTop)
                    , 500);
            invalidate();
        }
    }

在View重绘后,computeScroll方法就会被调用,这里通过更新此时TargetView和HeaderView的顶部距离,来实现滑动到新的位置的目的。

    @Override
    public void computeScroll() {
        // 判断是否完成滚动,true:未结束
        if (mScroller.computeScrollOffset()) {
            moveTargetViewTo(mScroller.getCurrY());
            invalidate();
        }
    }

好了,本文到这里,关于嵌套滑动的demo就结束了,当然可优化的点还很多。如果本文对你有用,来点个赞吧。

以上是关于Android修炼系列,事件分发从手写一个嵌套滑动框架开始的主要内容,如果未能解决你的问题,请参考以下文章

Android 嵌套滑动——NestedScrolling完全解析

Android 滑动冲突以及如何解决

从ViewPager嵌套RecyclerView再嵌套RecyclerView看安卓事件分发机制

Android系列View的事件分发机制

Android 事件分发ItemTouchHandler 实现侧滑删除 ( 设置滑动方向 | 启用滑动操作 | 滑动距离判定 | 滑动速度判定 | 设置动画时间 | 设置侧滑触发操作 )

Android 事件分发ItemTouchHandler 简介 ( 拖动/滑动事件 | ItemTouchHelper.Callback 回调 )