Android仿滴滴首頁嵌套滑動(dòng)效果
效果圖

在說代碼之前,可以先看下最終的 CompNsViewGroup XML 結(jié)構(gòu),CompNsViewGroup 內(nèi)部包含頂部地圖 MapView 和滑動(dòng)布局 LinearLayout,而 LinearLayout 布局的內(nèi)部即我們常用的滑動(dòng)控件 RecyclerView,在這里為何還要加層 LinearLayout 呢?這樣做的好處是,我們可以更好的適配不同滑動(dòng)控件,而不僅僅是將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>
實(shí)現(xiàn)
在 attrs.xml 文件下為 CompNsViewGroup 添加自定義屬性,其中 header_id 對應(yīng)頂部地圖 MapView,target_id 對應(yīng)滑動(dòng)布局 LinearLayout,inn_id 對應(yīng)滑動(dòng)控件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>
我們根據(jù) attrs.xml 中的屬性,獲取 XML 中 CompNsViewGroup 中的 View ID
// 獲取配置參數(shù)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");
我們根據(jù) attrs.xml 中的屬性,來初始化 View 的高度、距離等,計(jì)算高度時(shí),需要考慮到狀態(tài)欄因素
mHeaderInitTop = Utils.dip2px(getContext(), array.getInt(R.styleable.CompNsViewGroup_header_init_top, 0));mHeaderCurrTop = mHeaderInitTop;// 屏幕高度 - 底部距離 - 狀態(tài)欄高度mTargetInitBottom = Utils.dip2px(getContext(), array.getInt(R.styleable.CompNsViewGroup_target_init_bottom, 0));// 注意:當(dāng)前activity默認(rèn)去掉了標(biāo)題欄mTargetInitTop = Utils.getScreenHeight(getContext()) - mTargetInitBottom- Utils.getStatusBarHeight(getContext().getApplicationContext());mTargetCurrTop = mTargetInitTop;
通過上面獲取到的 View ID,我們能夠直接引用到 XML 中的相關(guān) View 實(shí)例,而后續(xù)的滑動(dòng),本質(zhì)上就是針對該 View 所進(jìn)行的一系列判斷處理。
@Overrideprotected void onFinishInflate() {super.onFinishInflate();mHeaderView = findViewById(mHeaderResId);mTargetView = findViewById(mTargetResId);mInnerScrollView = findViewById(mInnerScrollId);}
我們重寫 onMeasure 方法,其不僅是給 childView 傳入測量值和測量模式,還將我們自己測量的尺寸提供給父 ViewGroup 讓其給我們提供期望大小的區(qū)域。
@Overrideprotected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {super.onMeasure(widthMeasureSpec, heightMeasureSpec);// 計(jì)算子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 挪動(dòng)到了屏幕下方看不到的地方。
@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());}
此功能實(shí)現(xiàn)的核心即事件的分發(fā)和攔截了。在接收到事件時(shí),如果上次滾動(dòng)還未結(jié)束,則先停下。隨后判斷TargetView 內(nèi)的 RecyclerView 能否向下滑動(dòng),如果還能滑動(dòng),則不攔截事件,將事件傳遞給 TargetView。如果點(diǎn)擊在Header區(qū)域,則不攔截事件,將事件傳遞給地圖 MapView。
@Overridepublic boolean onInterceptTouchEvent(MotionEvent event) {// 如果上次滾動(dòng)還未結(jié)束,則先停下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;// 如果點(diǎn)擊在Header區(qū)域,則不攔截事件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;}
當(dāng) CompNsViewGroup 攔截事件后,會(huì)調(diào)用自身的 onTouchEvent 方法,邏輯與 onInterceptTouchEvent 類似,這里需要注意的是,當(dāng)事件在ViewGroup內(nèi),我們要怎么手動(dòng)分發(fā)給TargetView呢?代碼見下:
@Overridepublic boolean onTouchEvent(MotionEvent event) {if (canChildScrollDown())return false;// 添加速度監(jiān)聽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 {/*** 此時(shí),事件在ViewGroup內(nèi),* 需手動(dòng)分發(fā)給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();// 滾動(dòng)的像素?cái)?shù)太大了,這里只滾動(dòng)像素?cái)?shù)的0.1vyPxCount = (int)(vy/3);finishDrag(vyPxCount);}releaseVelocityTracker();return false;case MotionEvent.ACTION_CANCEL:// 回收滑動(dòng)監(jiān)聽releaseVelocityTracker();return false;}return mIsDragging;}
通過 canChildScrollDown 方法,我們能夠判斷 RecyclerView 是否能夠向下滑動(dòng)。這里后續(xù)會(huì)抽出一個(gè)adapter類,來處理不同的滑動(dòng)控件。
/*** 由TargetView來處理滑動(dòng)事件。** <p>注意{@link RecyclerView#canScrollVertically}* 來判斷當(dāng)前視圖是否可以繼續(xù)滾動(dòng)。* <ul>* <li>正數(shù):實(shí)際是判斷手指能否向上滑動(dòng)* <li>負(fù)數(shù):實(shí)際是判斷手指能否向下滑動(dòng)* </ul>*/public boolean canChildScrollDown() {RecyclerView rv;// 當(dāng)前只做了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;}
獲取向上能夠滑動(dòng)的距離頂部距離,如果Item數(shù)量太少,導(dǎo)致rv不能占滿一屏?xí)r,注意向上滑動(dòng)的距離。
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;}
手指向下滑動(dòng)或 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 頂部距離的方法,我們通過不斷刷新頂部距離來實(shí)現(xiàn)滑動(dòng)的效果。
private void moveTargetViewTo(int target) {target = Math.max(target, toTopMaxOffset());if (target >= mTargetInitTop)target = mTargetInitTop;// TargetView的top、bottom兩個(gè)方向都是加上offsetYViewCompat.offsetTopAndBottom(mTargetView, target - mTargetCurrTop);// 更新當(dāng)前TargetView距離頂部高度HmTargetCurrTop = target;int headerTarget;// 下拉超過定值Hif (mTargetCurrTop >= mTargetInitTop) {headerTarget = mHeaderInitTop;} else if (mTargetCurrTop <= 0) {headerTarget = 0;} else {// 滑動(dòng)比例float percent = mTargetCurrTop * 1.0f / mTargetInitTop;headerTarget = (int) (percent * mHeaderInitTop);}// HeaderView的top、bottom兩個(gè)方向都是加上offsetYViewCompat.offsetTopAndBottom(mHeaderView, headerTarget - mHeaderCurrTop);mHeaderCurrTop = headerTarget;if (mListener != null) {mListener.onTargetToTopDistance(mTargetCurrTop);mListener.onHeaderToTopDistance(mHeaderCurrTop);}}
這是 mScroller 彈性滑動(dòng)時(shí)的一些閾值判斷。startScroll 本身并沒有做任何滑動(dòng)相關(guān)的事,而是通過 invalidate 方法來實(shí)現(xiàn) View 重繪,在 View 的 draw 方法中會(huì)調(diào)用 computeScroll 方法,但本例中并沒有在computeScroll 中配合 scrollTo 來實(shí)現(xiàn)滑動(dòng)。注意這里的滑動(dòng),是指內(nèi)容的滑動(dòng),而非 View 本身位置的滑動(dòng)。
private void finishDrag(int vyPxCount) {if ((vyPxCount >= 0 && vyPxCount <= minFlingVelocity)|| (vyPxCount <= 0 && vyPxCount >= -minFlingVelocity))return;// 速度 > 0,說明正向下滾動(dòng)if (vyPxCount > 0) {// 防止超出臨界值if (mTargetCurrTop < mTargetInitTop) {mScroller.startScroll(0, mTargetCurrTop, 0, vyPxCount < (mTargetInitTop - mTargetCurrTop)? vyPxCount : (mTargetInitTop - mTargetCurrTop), 650);invalidate();}}// 速度 < 0,說明正向上滾動(dòng)else if (vyPxCount < 0) {if (mTargetCurrTop <= 0) {if (mScroller.getCurrVelocity() > 0) {// inner scroll 接著滾動(dòng)}}mScroller.startScroll(0, mTargetCurrTop, 0, vyPxCount > -mTargetCurrTop? vyPxCount : -mTargetCurrTop, 650);invalidate();}}
在 View 重繪后,computeScroll 方法就會(huì)被調(diào)用,這里通過更新此時(shí) TargetView 和 HeaderView 的頂部距離,來實(shí)現(xiàn)滑動(dòng)到新的位置的目的。
@Overridepublic void computeScroll() {// 判斷是否完成滾動(dòng),true:未結(jié)束if (mScroller.computeScrollOffset()) {moveTargetViewTo(mScroller.getCurrY());invalidate();}}
源碼地址:
https://codechina.csdn.net/mirrors/MingJieZuo/CustomWidget
到這里就結(jié)束啦.
