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

在说代码之前,可以先看下最终的 CompNsViewGroup XML 结构,CompNsViewGroup 内部包含顶部地图 MapView 和滑动布局 LinearLayout,而 LinearLayout 布局的内部即我们常用的滑动控件 RecyclerView,在这里为何还要加层 LinearLayout 呢?这样做的好处是,我们可以更好的适配不同滑动控件,而不仅仅是将CompNsViewGroup 与 RecyclerView 耦合住。
<com.comp.ns.CompNsViewGroupandroid: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.MapViewandroid:id="@+id/t_map_view"android:layout_width="match_parent"android:layout_height="match_parent" /><LinearLayoutandroid:id="@+id/target_layout"android:layout_width="match_parent"android:layout_height="wrap_content"android:orientation="vertical"android:background="#fff"><androidx.recyclerview.widget.RecyclerViewandroid: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 所进行的一系列判断处理。
@Overrideprotected void onFinishInflate() {super.onFinishInflate();mHeaderView = findViewById(mHeaderResId);mTargetView = findViewById(mTargetResId);mInnerScrollView = findViewById(mInnerScrollId);}
我们重写 onMeasure 方法,其不仅是给 childView 传入测量值和测量模式,还将我们自己测量的尺寸提供给父 ViewGroup 让其给我们提供期望大小的区域。
@Overrideprotected 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 挪动到了屏幕下方看不到的地方。
@Overrideprotected 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高度,而是又向下挪了mTargetInitTopmTargetView.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。
@Overridepublic boolean onInterceptTouchEvent(MotionEvent event) {// 如果上次滚动还未结束,则先停下if (!mScroller.isFinished())mScroller.forceFinished(true);// 不拦截事件,将事件传递给TargetViewif (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呢?代码见下:
@Overridepublic 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.1vyPxCount = (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两个方向都是加上offsetYViewCompat.offsetTopAndBottom(mTargetView, target - mTargetCurrTop);// 更新当前TargetView距离顶部高度HmTargetCurrTop = target;int headerTarget;// 下拉超过定值Hif (mTargetCurrTop >= mTargetInitTop) {headerTarget = mHeaderInitTop;} else if (mTargetCurrTop <= 0) {headerTarget = 0;} else {// 滑动比例float percent = mTargetCurrTop * 1.0f / mTargetInitTop;headerTarget = (int) (percent * mHeaderInitTop);}// HeaderView的top、bottom两个方向都是加上offsetYViewCompat.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 的顶部距离,来实现滑动到新的位置的目的。
@Overridepublic void computeScroll() {// 判断是否完成滚动,true:未结束if (mScroller.computeScrollOffset()) {moveTargetViewTo(mScroller.getCurrY());invalidate();}}
源码地址:
https://codechina.csdn.net/mirrors/MingJieZuo/CustomWidget
到这里就结束啦.
