Android(安卓)仿滴滴首页嵌套滑动效果
目录表
- 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
更多相关文章
- Android触摸事件分发
- Android(安卓)ListView:实现item内部控件的点击事件
- Android核心分析(17) ------电话系统之rilD
- android事件拦截处理机制详解
- Android简明开发教程十九:线程 Bezier曲线
- 我的Android进阶之旅------>Android中解析XML 技术详解---->SAX
- Android(安卓)Touch 触摸事件
- Android核心分析(17) ------电话系统之rilD
- Android的MotionEvent事件分发机制