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来处理滑动事件。
*
* <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
到这里就结束啦.