Android 仿滴滴首页嵌套滑动效果

目录表

这是最终的实现效果,由于使用的模拟器录制,所以顶部地图的渲染效果不是很好。
在这里插入图片描述

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

    <com.comp.ns.CompNsViewGroup
        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.comp.ns.CompNsViewGroup>

实现

在 attrs.xml 文件下为 CompNsViewGroup 添加自定义属性,其中 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 中 CompNsViewGroup 中的 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);

        // 计算子VIEW的尺寸
        measureChildren(widthMeasureSpec, heightMeasureSpec);

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

        switch (widthModle) {
            case MeasureSpec.AT_MOST:
            case MeasureSpec.UNSPECIFIED:
                // TODO:wrap_content 暂不考虑
                break;

            case MeasureSpec.EXACTLY:
                // 全屏或者固定尺寸
                break;
        }

        switch (heightModle) {
            case MeasureSpec.UNSPECIFIED:
            case MeasureSpec.AT_MOST:
                break;

            case MeasureSpec.EXACTLY:
                break;
        }

        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;
    }

当 CompNsViewGroup 拦截事件后,会调用自身的 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:
                final float y = event.getY();
                startDragging(y);

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

通过 canChildScrollDown 方法,我们能够判断 RecyclerView 是否能够向下滑动。这里后续会抽出一个adapter类,来处理不同的滑动控件。

    /**
     * 由TargetView来处理滑动事件。
     *
     * <p>注意{@link RecyclerView#canScrollVertically}
     * 来判断当前视图是否可以继续滚动。
     * <ul>
     * <li>正数:实际是判断手指能否向上滑动
     * <li>负数:实际是判断手指能否向下滑动
     * </ul>
     */
    public boolean canChildScrollDown() {
        RecyclerView rv;
        // 当前只做了RecyclerView的适配
        if (mInnerScrollView instanceof RecyclerView) {
            rv = (RecyclerView) mInnerScrollView;
            if (android.os.Build.VERSION.SDK_INT < 14) {
                RecyclerView.LayoutManager lm = rv.getLayoutManager();
                boolean isFirstVisible;
                if (lm != null && lm instanceof LinearLayoutManager) {
                    isFirstVisible = ((LinearLayoutManager)lm)
                            .findFirstVisibleItemPosition() > 0;
                    return rv.getChildCount() > 0
                            && (isFirstVisible || rv.getChildAt(0)
                            .getTop() < rv.getPaddingTop());
                }
            } else {
                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 >= 14) {

                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;

        // 速度 > 0,说明正向下滚动
        if (vyPxCount > 0) {
            // 防止超出临界值
            if (mTargetCurrTop < mTargetInitTop) {
                mScroller.startScroll(0, mTargetCurrTop
                        , 0, vyPxCount < (mTargetInitTop - mTargetCurrTop)
                                ? vyPxCount : (mTargetInitTop - mTargetCurrTop)
                        , 650);
                invalidate();
            }
        }
        // 速度 < 0,说明正向上滚动
        else if (vyPxCount < 0) {
            if (mTargetCurrTop <= 0) {
                if (mScroller.getCurrVelocity() > 0) {
                    // inner scroll 接着滚动
                }
            }

            mScroller.startScroll(0, mTargetCurrTop
                    , 0, vyPxCount > -mTargetCurrTop
                            ? vyPxCount : -mTargetCurrTop
                    , 650);
            invalidate();
        }
    }

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

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

gitHub - CompNestedSlidet

猜你喜欢

转载自blog.csdn.net/MingJieZuo/article/details/107778818