目录表

  • android 自定义view必备api
  • android 可拖动圆环刻度条
  • android 仿滴滴大头针跳动波纹效果
  • android 仿网易云鲸云音效
  • 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来处理滑动事件。     *     * 

注意{@link RecyclerView#canScrollVertically} * 来判断当前视图是否可以继续滚动。 *

    *
  • 正数:实际是判断手指能否向上滑动 *
  • 负数:实际是判断手指能否向下滑动 *
*/
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

更多相关文章

  1. Android触摸事件分发
  2. Android(安卓)ListView:实现item内部控件的点击事件
  3. Android核心分析(17) ------电话系统之rilD
  4. android事件拦截处理机制详解
  5. Android简明开发教程十九:线程 Bezier曲线
  6. 我的Android进阶之旅------>Android中解析XML 技术详解---->SAX
  7. Android(安卓)Touch 触摸事件
  8. Android核心分析(17) ------电话系统之rilD
  9. Android的MotionEvent事件分发机制

随机推荐

  1. 该指数增长的Android应用市场
  2. Android(安卓)Studio集成讯飞语音开发出
  3. 10个常见的 Android(安卓)新手误区
  4. android事件处理机制
  5. Android视频框架 Vitamio 打造自己的万能
  6. Android中的Fragment详解 ("碎片"这个翻
  7. Android(安卓)C/C++ 开发
  8. Android(安卓)source build/envsetup.sh
  9. Android(安卓)ContentObserver
  10. Android(安卓)onTouchEvent, onClick及on