前言
1.1 意义
为什么要给RecyclerView添加一个Header呢?
其实原因是这样的,RecyclerView上的ItemView可能会比较频繁的detachFromWindow和attachFromWindow,这就容易引发一些意想不到的效果,比如轮播图白屏等。
其实官方也提供了类似CoordinateLayout的组件,但是也有些小毛病,如滑动,还有几十吸顶支持的也不够好,我们本篇直接自行处理修复了一些滑动问题,这些也在官方组件中出现过。
当然,最核心的是,Scrolling机制可以更加友好的辅助我们实现吸顶效果。
1.2 效果预览
下面是本篇的预览效果
注意:左边的图看着不明显,主要原因是你还没看代码,实际上那个熊猫不在RecyclerView中,右边吸顶的Tab也不在RecyclerView中,而在Header中,为什么要这样做,主要原因是RecyclerView 有detach和attach操作,如果有些需求依赖View是否在Window上(如Surface 所在的一些类),那么显然RecyclerView是不合适的,则会产生冲突。
1.3 实现原理
实际上,我们要实现下面的布局效果,有以下几个特点
- HeaderView的是不可被回收的
- 父布局最大能滑动的距离和HeaderView的高度相等
- RecyclerView的高度和父布局高度相等
- RecyclerView自身的滑动不受限
- RecyclerView需要和HeaderView保持滑动效果的联动
基于上述逻辑,我们可以实现下面的结构
当然HeaderView滑动之后还可以到到布局内容区域以外
本篇我们重点使用到的是NestedScrolling机制。
二、关于 NestedScrolling
NestedScrolling 机制主要是能够让父 View 和子 View 在滚动时互相协调配合。其中有两个重要的类,分别是:
接口类
NestedScrollingParent(最新:NestedScrollingParent2) - 代表类:NestedScrollView
NestedScrollingChild(最新:NestedScrollingChild2) - 代表类:RecyclerView
帮助类
NestedScrollingChildHelper
NestedScrollingParentHelper(用处不是很大)
父类继承 NestedScrollingParent 接口,而子类继承 NestedScrollingChild 接口,同时让父类包含子类,而不是自接父子关系,就搭起了 NestedScrollingParent 机制的基本骨架。
其主要流程是:
-
子类滑动,把滑动产生的事件和参数传给父类
-
父类根据子类传过来的参数偏移量、滑动方向等参数、当前滑动 View 等判断是否关注此事件,如果不关注,那么父 View 不会参与子 View 的滑动
-
父 View 关注了子 View 的滑动,子 View 通过 PreScroll/PreFling 会优先让父 view 消费事件,其实本质是子 View 的回调
-
接着,子 View 消费 “剩余事件 “(父 View 不一定消费掉所有事件,比如某时刻的偏移数据)
-
流程终止,子 view 通知父 view 本次滑动任务完成
public interface NestedScrollingChild { /** * 设置嵌套滑动是否能用 */ @Override public void setNestedScrollingEnabled(boolean enabled); /** * 判断嵌套滑动是否可用 */ @Override public boolean isNestedScrollingEnabled(); /** * 开始嵌套滑动 * * @param axes 表示方向轴,有横向和竖向 */ @Override public boolean startNestedScroll(int axes); /** * 停止嵌套滑动 */ @Override public void stopNestedScroll(); /** * 判断是否有父View 支持嵌套滑动 */ @Override public boolean hasNestedScrollingParent() ; /** * 滑行时调用 * @param velocityX x 轴上的滑动速率 * @param velocityY y 轴上的滑动速率 * @param consumed 是否被消费 * @return true if the nested scrolling parent consumed or otherwise reacted to the fling */ @Override public boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed) ; /** * 进行滑行前调用 * @param velocityX x 轴上的滑动速率 * @param velocityY y 轴上的滑动速率 * @return true if a nested scrolling parent consumed the fling */ @Override public boolean dispatchNestedPreFling(float velocityX, float velocityY) ; /** * 子view处理scroll后调用 * @param dxConsumed x轴上被消费的距离(横向) * @param dyConsumed y轴上被消费的距离(竖向) * @param dxUnconsumed x轴上未被消费的距离 * @param dyUnconsumed y轴上未被消费的距离 * @param offsetInWindow 子View的窗体偏移量 * @return true if the event was dispatched, false if it could not be dispatched. */ @Override public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, @Nullable int[] offsetInWindow) ; /** * 在子View的onInterceptTouchEvent或者onTouch中,调用该方法通知父View滑动的距离 * @param dx x轴上滑动的距离 * @param dy y轴上滑动的距离 * @param consumed 父view消费掉的scroll长度 * @param offsetInWindow 子View的窗体偏移量 * @return 支持的嵌套的父View 是否处理了 滑动事件 */ @Override public boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed, @Nullable int[] offsetInWindow); }
二、实现
2.1 实现原理
我们之前使用 ViewDragerHelper 实现的《自定义地图上滑组件 NestedScrollLayout》,其实可以通过 NestedScrolling 机制实现,NestedScrolling 相比 ViewDragHelper,ViewDragHelper 联动使用不当可能产生丢帧问题,联动机制非常强大,相比 ViewDragHelper,可减少很多事件处理,让联动变的更简单。当然如果是单 View 非联动操作 ViewDragHelper 更有优势,具体问题具体解决。
我们通过本文开头展示效果,给 RecyclerView 增加一个的 Header,这个 Header 不是通过 Adapter 实现,而是通过联动效果效果实现。
步骤:
- 标记HeaderView
- 将RecyclerView标记为Body部分
- 组合联动
为什么本篇能实现的呢,主要原因是RecyclerView也实现了Scrolling机制。
2.2 全部代码
下面是完整的逻辑,NestedScrollingChildLayout具备2个View,一个是Header,另一个是作为Body的RecyclerView
public class NestedScrollChildLayout extends FrameLayout implements NestedScrollingParent2 {
private final int mFlingVelocity;
private int mOverScrollExtends;
private float startEventX = 0;
private float startEventY = 0;
private float mSlopTouchScale = 0;
private boolean isTouchMoving = false;
private View mHeaderView = null;
private View mBodyView = null;
private View mVerticalScrollView = null;
private VelocityTracker mVelocityTracker;
public NestedScrollChildLayout(@NonNull Context context) {
this(context, null);
}
public NestedScrollChildLayout(@NonNull Context context, @Nullable AttributeSet attrs) {
this(context, attrs, 0);
}
public NestedScrollChildLayout(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
mSlopTouchScale = ViewConfiguration.get(context).getScaledTouchSlop();
mFlingVelocity = ViewConfiguration.get(context).getScaledMaximumFlingVelocity();
setClickable(true);
}
public void setOverScrollExtends(int overScrollExtends) {
this.mOverScrollExtends = overScrollExtends;
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int childCount = getChildCount();
int height = MeasureSpec.getSize(heightMeasureSpec);
int overScrollExtent = overScrollExtent();
for (int i = 0; i < childCount; i++) {
View child = getChildAt(i);
LayoutParams lp = (LayoutParams) child.getLayoutParams();
if (lp.childLayoutType == LayoutParams.TYPE_BODY) {
final int childWidthMeasureSpec = getChildMeasureSpec(widthMeasureSpec,
getPaddingLeft() + getPaddingRight() + lp.leftMargin + lp.rightMargin
+ 0, lp.width);
final int childHeightMeasureSpec = getChildMeasureSpec(heightMeasureSpec,
getPaddingTop() + getPaddingBottom() + lp.topMargin + lp.bottomMargin
+ 0, height-overScrollExtent);
child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
}
}
}
//计算垂直方向是否可以继续滑动
public boolean canScrollVertically(int direction) {
final int offset = computeVerticalScrollOffset();
final int range = computeVerticalScrollRange() - computeVerticalScrollExtent();
if (range == 0) return false;
if (direction < 0) {
return offset > 0;
} else {
return offset < range;
}
}
@Override
protected int computeVerticalScrollRange() {
int childCount = getChildCount();
if (childCount == 0) return super.computeVerticalScrollRange();
int range = getPaddingBottom() + getPaddingTop();
for (int i = 0; i < childCount; i++) {
View child = getChildAt(i);
LayoutParams lp = (LayoutParams) child.getLayoutParams();
range += child.getHeight() + lp.bottomMargin + lp.topMargin;
}
if (range < getHeight()) {
return super.computeVerticalScrollRange();
}
return range;
}
@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
super.onLayout(changed, left, top, right, bottom);
mHeaderView = getChildView(LayoutParams.TYPE_HEAD);
mBodyView = getChildView(LayoutParams.TYPE_BODY);
int childLeft = getPaddingLeft();
int childTop = getPaddingTop();
if (mHeaderView != null) {
LayoutParams lp = (LayoutParams) mHeaderView.getLayoutParams();
mHeaderView.layout(childLeft + lp.leftMargin, childTop + lp.topMargin, childLeft + lp.leftMargin + mHeaderView.getMeasuredWidth(), childTop + lp.topMargin + mHeaderView.getMeasuredHeight());
childTop += mHeaderView.getMeasuredHeight() + lp.topMargin + lp.bottomMargin;
}
if (mBodyView != null) {
LayoutParams lp = (LayoutParams) mBodyView.getLayoutParams();
mBodyView.layout(childLeft + lp.leftMargin, childTop + lp.topMargin, childLeft + lp.leftMargin + mBodyView.getMeasuredWidth(), childTop + lp.topMargin + mBodyView.getMeasuredHeight());
}
}
protected int overScrollExtent() {
return mOverScrollExtends;
}
private View getHeaderView() {
return mHeaderView;
}
private View getBodyView() {
return mBodyView;
}
private View findTouchView(float currentX, float currentY) {
for (int i = 0; i < getChildCount(); i++) {
View child = getChildAt(i);
float childX = (child.getX() - getScrollX());
float childY = (child.getY() - getScrollY());
if (currentX < childX || currentX > (childX + child.getWidth())) {
continue;
}
if (currentY < childY || currentY > (childY + child.getHeight())) {
continue;
}
return child;
}
return null;
}
private boolean hasHeader() {
int count = getChildCount();
for (int i = 0; i < count; i++) {
LayoutParams lp = (LayoutParams) getChildAt(i).getLayoutParams();
if (lp.childLayoutType == LayoutParams.TYPE_HEAD) {
return true;
}
}
return false;
}
public View getChildView(int layoutType) {
int count = getChildCount();
for (int i = 0; i < count; i++) {
LayoutParams lp = (LayoutParams) getChildAt(i).getLayoutParams();
if (lp.childLayoutType == layoutType) {
return getChildAt(i);
}
}
return null;
}
private boolean hasBody() {
int count = getChildCount();
for (int i = 0; i < count; i++) {
LayoutParams lp = (LayoutParams) getChildAt(i).getLayoutParams();
if (lp.childLayoutType == LayoutParams.TYPE_BODY) {
return true;
}
}
return false;
}
@Override
public void addView(View child) {
assertLayoutType(child);
super.addView(child);
}
private void assertLayoutType(View child) {
ViewGroup.LayoutParams lp = child.getLayoutParams();
assertLayoutParams(lp);
}
private void assertLayoutParams(ViewGroup.LayoutParams lp) {
if (hasHeader() && hasBody()) {
throw new IllegalStateException("header and body has already existed");
}
if (hasHeader()) {
if (!(lp instanceof LayoutParams)) {
throw new IllegalStateException("header should keep only one");
}
if (((LayoutParams) lp).childLayoutType == LayoutParams.TYPE_HEAD) {
throw new IllegalStateException("header should keep only one");
}
}
if (hasBody()) {
if ((lp instanceof LayoutParams) && ((LayoutParams) lp).childLayoutType == LayoutParams.TYPE_BODY) {
throw new IllegalStateException("header should keep only one");
}
}
}
@Override
public void addView(View child, int index, ViewGroup.LayoutParams params) {
assertLayoutParams(params);
super.addView(child, index, params);
}
@Override
public void addView(View child, int index) {
assertLayoutType(child);
super.addView(child, index);
}
@Override
public void addView(View child, int width, int height) {
assertLayoutParams(new LinearLayout.LayoutParams(width, height));
super.addView(child, width, height);
}
@Override
public void onViewAdded(View child) {
super.onViewAdded(child);
LayoutParams lp = (LayoutParams) child.getLayoutParams();
if (lp.childLayoutType != LayoutParams.TYPE_BODY) {
return;
}
if (!(child instanceof NestedScrollingChild) && !(child instanceof ScrollFlingChild)) {
throw new RuntimeException("body must be 'view implemention NestedScrollingChild or ScrollFlingChild '");
}
}
@Override
protected boolean checkLayoutParams(ViewGroup.LayoutParams p) {
return p instanceof LayoutParams;
}
@Override
protected FrameLayout.LayoutParams generateDefaultLayoutParams() {
return new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT);
}
@Override
public FrameLayout.LayoutParams generateLayoutParams(AttributeSet attrs) {
return new LayoutParams(getContext(), attrs);
}
@Override
protected ViewGroup.LayoutParams generateLayoutParams(ViewGroup.LayoutParams lp) {
return new LayoutParams(lp);
}
@Override
public boolean onStartNestedScroll(@NonNull View child, @NonNull View target, int axes, int type) {
if (axes == SCROLL_AXIS_VERTICAL) {
//只关注垂直方向的移动
int maxOffset = computeVerticalScrollRange() - computeVerticalScrollExtent();
int offset = computeVerticalScrollOffset();
if (offset <= maxOffset) {
mVerticalScrollView = target;
return true;
}
}
return false;
}
@Override
protected int computeVerticalScrollExtent() {
int computeVerticalScrollExtent = super.computeVerticalScrollExtent();
return computeVerticalScrollExtent ;
}
@Override
public void onNestedScrollAccepted(@NonNull View child, @NonNull View target, int axes, int type) {
}
@Override
public void onStopNestedScroll(@NonNull View target, int type) {
if (mVerticalScrollView == target) {
Log.d("onNestedScroll", "::::onStopNestedScroll vertical");
}
}
@Override
public void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, int type) {
}
@Override
public void onNestedPreScroll(@NonNull View target, int dx, int dy, @Nullable int[] consumed, int type) {
int scrollRange = computeVerticalScrollRange();
if (scrollRange <= getHeight()) {
return;
}
if (target == null) return;
if (mVerticalScrollView != target) {
return;
}
int maxOffset = computeVerticalScrollRange() - computeVerticalScrollExtent();
int scrollOffset = computeVerticalScrollOffset();
handleVerticalNestedScroll(dx, dy, consumed, maxOffset, scrollOffset);
}
private void handleVerticalNestedScroll(int dx, int dy, @Nullable int[] consumed, int maxOffset, int scrollOffset) {
if (dy == 0) return;
if (!checkScrollableStationTop(mVerticalScrollView)) {
return;
}
int dyOffset = dy;
int targetOffset = scrollOffset + dy;
if (targetOffset >= maxOffset) {
dyOffset = maxOffset - scrollOffset;
}
if (targetOffset <= 0) {
dyOffset = 0 - scrollOffset;
}
if (!canScrollVertically(dyOffset)) {
return;
}
consumed[1] = dyOffset;
Log.d("onNestedScroll", "::::" + dyOffset + "+" + scrollOffset + "=" + (scrollOffset + dyOffset));
scrollBy(0, dyOffset);
}
@Override
public boolean dispatchTouchEvent(MotionEvent event) {
int scrollRange = computeVerticalScrollRange();
if (scrollRange <= getHeight()) {
return super.dispatchTouchEvent(event);
}
if (mVelocityTracker == null) {
mVelocityTracker = VelocityTracker.obtain();
}
int action = event.getAction();
switch (action) {
case MotionEvent.ACTION_DOWN:
mVelocityTracker.addMovement(event);
startEventX = event.getX();
startEventY = event.getY();
isTouchMoving = false;
break;
case MotionEvent.ACTION_MOVE:
float currentX = event.getX();
float currentY = event.getY();
float dx = currentX - startEventX;
float dy = currentY - startEventY;
if (!isTouchMoving && Math.abs(dy) < Math.abs(dx)) {
startEventX = currentX;
startEventY = currentY;
break;
}
View touchView = null;
int offset = (int) -dy;
if (Math.abs(dy) >= mSlopTouchScale) {
touchView = findTouchView(currentX, currentY);
isTouchMoving = touchView != null && touchView == getHeaderView();
}
if (offset != 0 && !canScrollVertically(offset)) {
isTouchMoving = false;
}
startEventX = currentX;
startEventY = currentY;
if (!isTouchMoving) {
break;
}
mVelocityTracker.addMovement(event);
int maxOffset = computeVerticalScrollRange() - computeVerticalScrollExtent();
int scrollOffset = computeVerticalScrollOffset();
int targetOffset = scrollOffset + offset;
if (targetOffset >= maxOffset) {
offset = maxOffset - scrollOffset;
}
if (targetOffset <= 0) {
offset = 0 - scrollOffset;
}
if (offset == 0) {
break;
}
scrollBy(0, offset);
Log.d("onNestedScroll", ">:>:>" + offset + "+" + scrollOffset + "=" + (scrollOffset + offset));
super.dispatchTouchEvent(event);
return true;
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
case MotionEvent.ACTION_OUTSIDE:
mVelocityTracker.addMovement(event);
if (isTouchMoving) {
isTouchMoving = false;
mVelocityTracker.computeCurrentVelocity(1000, mFlingVelocity);
startFling(mVelocityTracker, (int) event.getX(), (int) event.getY());
mVelocityTracker.recycle();
mVelocityTracker = null;
}
break;
}
return super.dispatchTouchEvent(event);
}
private void startFling(VelocityTracker velocityTracker, int x, int y) {
int xVolecity = (int) velocityTracker.getXVelocity();
int yVolecity = (int) velocityTracker.getYVelocity();
if (mVerticalScrollView instanceof NestedScrollingChild) {
Log.d("onNestedScroll", "onNestedScrollfling xVolecity=" + xVolecity + ", yVolecity=" + yVolecity);
((RecyclerView) mVerticalScrollView).fling(xVolecity, -yVolecity);
}
if (mVerticalScrollView instanceof ScrollFlingChild) {
((ScrollFlingChild) mVerticalScrollView).startFling(xVolecity, yVolecity);
}
}
private boolean checkScrollableStationTop(View view) {
if (view instanceof RecyclerView) {
//显示区域最上面一条信息的position
RecyclerView.LayoutManager manager = ((RecyclerView) view).getLayoutManager();
if (manager == null) {
return true;
}
if (manager.getChildCount() == 0) {
return true;
}
int scrollOffset = ((RecyclerView) view).computeVerticalScrollOffset();
return scrollOffset <= 0;
}
if (view instanceof NestedScrollingChild) {
return view.canScrollVertically(-1);
}
if ((view instanceof View) && !(view instanceof ViewGroup)) {
return true;
}
throw new IllegalArgumentException("不支持非NestedScrollingChild子类ViewGroup");
}
public static class LayoutParams extends FrameLayout.LayoutParams {
public final static int TYPE_HEAD = 0;
public final static int TYPE_BODY = 1;
private int childLayoutType = TYPE_HEAD;
public LayoutParams(@NonNull Context c, @Nullable AttributeSet attrs) {
super(c, attrs);
if (attrs == null) return;
final TypedArray a = c.obtainStyledAttributes(attrs, R.styleable.NestedScrollChildLayout);
childLayoutType = a.getInt(R.styleable.NestedScrollChildLayout_layoutScrollNestedType, 0);
a.recycle();
}
public LayoutParams(int width, int height) {
super(width, height);
}
public LayoutParams(@NonNull ViewGroup.LayoutParams source) {
super(source);
}
public LayoutParams(@NonNull MarginLayoutParams source) {
super(source);
}
}
public interface ScrollFlingChild {
public void startFling(int xVolecity, int yVolecity);
}
}
2.2 定义 View 属性参数
用于标记不同的角色,这个主要作用于布局问题
<declare-styleable name="NestedScrollChildLayout">
<attr name="layoutScrollNestedType" format="flags">
<flag name="Head" value="0"/>
<flag name="Body" value="1"/>
</attr>
</declare-styleable>
2.3 使用方式
下面是在布局中的使用方式,我们通过nestedChildLayoutType对View进行标记,head表示Header,而body表示内容区域,本篇的核心是RecyclerView,这里我们使用RecyclerView。
<com.cn.scrolllayout.view.NestedScrollChildLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:focusable="true"
android:focusableInTouchMode="true"
android:orientation="vertical">
<LinearLayout
android:id="@+id/head"
android:layout_width="match_parent"
android:layout_height="200dp"
app:nestedChildLayoutType="Header"
>
<ImageView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:src="@mipmap/img_sample_panda"
android:scaleType="centerCrop"
/>
</LinearLayout>
<android.support.v7.widget.RecyclerView
android:id="@+id/body"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:nestedChildLayoutType="Body"
android:background="@color/colorPrimary"
/>
</com.cn.scrolllayout.view.NestedScrollChildLayout>
2.4 吸顶效果
当然,我们还有个吸顶效果,其实这里的实现更简单,在代码中设置便宜量,就能减少RecyclerView的高度,从而可以露出吸顶View的一部份。
public void setOverScrollExtends(int overScrollExtends) {
this.mOverScrollExtends = overScrollExtends;
}
三、总结
本篇是利用NestedScrollLing机制实现了自定义View布局,相比ViewDragHelper和传统的事件处理,开发效率和简单程度上大幅提升,同时避免了ViewDragHelper不能多View联动以及传统事件处理的复杂性。实际上本篇并不能阻止传统的View组件没落的趋势,唯有Canvas不分还能继续使用外,传统的布局中处理Surface相关类之外,被jetPack Compose取代是迟早的事。本篇主要还是复习Android知识。