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

龙旋

共 14162字,需浏览 29分钟

 ·

2021-04-25 12:50

效果



在说代码之前,可以先看下最终的 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();        }    }


源码地址:

https://codechina.csdn.net/mirrors/MingJieZuo/CustomWidget


到这里就结束啦.


浏览 36
点赞
评论
收藏
分享

手机扫一扫分享

举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

举报