本章节主要从 嵌套滑动、滑动冲突方案解决来入手,这里不是基于协调者布局;
我们先来看一下淘宝和京东的首页滑动效果:
可以看到首页是有一个嵌套滑动的效果,上推的时候,频道先跟着移动,然后置顶不动,这种效果基于协调者布局可以实现,但是如果没有协调者布局的情况下,如何实现呢?
在不使用 协调者 布局的情况下,界面最终的布局效果应该是类似下面这样的一层结构;
最外层 ScrollVIew/NestedScrollView
嵌套:不能滑动的RecyclerView、TabLayout、ViewPager
经过分析,我们来搭建一个这样的布局来看下
<ScrollView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<!-- 这里要稍微改造下,改成不能滑动的 RecyclerView -->
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/combo_top_view"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<com.google.android.material.tabs.TabLayout
android:id="@+id/tablayout"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/viewpager_view"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</LinearLayout>
</LinearLayout>
</ScrollView>
然后我们编译执行下,看下最终的滑动效果,是否能达到我们的预期:
可以看到,并没有达到我们期望的联动效果,那么原因是什么呢?
事件分发是从运行时角度来分析的;
Activity dispatchTouchEvent ->?
public boolean dispatchTouchEvent(MotionEvent ev) {
if (ev.getAction() == MotionEvent.ACTION_DOWN) {
onUserInteraction();
}
if (getWindow().superDispatchTouchEvent(ev)) {
return true;
}
return onTouchEvent(ev);
}
PhoneWindow superDispatchTouchEvent ->
public boolean superDispatchTouchEvent(MotionEvent event) {
return mDecor.superDispatchTouchEvent(event);
}
DecorView superDispatchTouchEvent ->?
public boolean superDispatchTouchEvent(MotionEvent event) {
return super.dispatchTouchEvent(event);
}
DecorView 的父类是 FrameLayout,其父类是 ViewGroup,最终调度到 ViewGroup 的 dispatchTouchEvent 方法
这?dispatchTouchEvent 方法中,先进行是否需要拦截判断,通过?onInterceptTouchEvent?方法
public boolean dispatchTouchEvent(MotionEvent ev) {
//
...
// 省略部分代码
// 首先问询自身要不要进行事件的拦截
final boolean intercepted;
if (actionMasked == MotionEvent.ACTION_DOWN
|| mFirstTouchTarget != null) {
final boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0;
if (!disallowIntercept) {
intercepted = onInterceptTouchEvent(ev);
ev.setAction(action); // restore action in case it was changed
} else {
intercepted = false;
}
} else {
// There are no touch targets and this action is not an initial down
// so this view group continues to intercept touches.
intercepted = true;
}
}
final boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0;
这个是用来判断子 View 有没请求父 View(当前View)不要进行事件的拦截
子 View 通过调用这个方法,设置 mGroupFlags 的值,来修改?disallowIntercept?的逻辑;
如果子 View 没有要求父 View 不拦截,则父View(当前View)执行?onInterceptTouchEvent?来问询自身要不要拦截;
如果自身不拦截,则
需要两个角色,一个角色用来实现 NestedScrollingParent3,一个角色需要实现 NestedScrollingChild3 接口,用来表示一个是父亲,一个是孩子,以此来表示它们之间的嵌套滑动;
那么,有人就提出了,使用 NestedScrollView,因为它实现了?NestedScrollingParent3,那么我们将布局改成?NestedScrollView :
<androidx.core.widget.NestedScrollView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<!-- 这里要稍微改造下,改成不能滑动的 RecyclerView -->
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/combo_top_view"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<com.google.android.material.tabs.TabLayout
android:id="@+id/tablayout"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/viewpager_view"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</LinearLayout>
</LinearLayout>
</androidx.core.widget.NestedScrollView>
我们运行看下效果:
虽然实现了嵌套滚动,但是 TabLayout 没有吸顶,随着滑动一起滚出了屏幕,还是不符合预期;
那么如何实现吸顶来达到我们的预期呢?
我们可以将 NestedScrollView? 的内容元素进行拆分,将不能滑动的区域作为 NestedScrollView? 的 header 部分,将 TabLayout 和 RecyclerView 整体划分成一部分作为 NestedScrollView 的最后一个 View,并且最后一个 View 的高度是整个屏幕的高度,这样当 NestedScrollView 滑动到最后一个 View 的时候,整个View 是充满屏幕的,那么 TabLayout 就能置顶了;
那么我们就需要这个 NestedScrollView 来判断最后是不是一个 View,以及获取最后一个View之后,设置它的高度为屏幕高度;
继承 NestedScrollView 实现下面两个方法:
@Override
protected void onFinishInflate() {
super.onFinishInflate();
// 根据 xml 的层级结构,我们需要获取的是第一个子 View 下的 第二个子 View
contentView = ((ViewGroup) getChildAt(0)).getChildAt(1);
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
// 重新设置 contentView 的高度为屏幕高度
ViewGroup.LayoutParams layoutParams = contentView.getLayoutParams();
layoutParams.height = getMeasuredHeight();
contentView.setLayoutParams(layoutParams);
}
我们编译运行看下:
可以看到,达到了我们期望的效果,实现了吸顶,但是还是有滑动冲突的地方,没有达到预期的效果,触摸滑动的时候 NestedScrollView 没有先滑动,而是 RecyclerView 滑动了一些距离才进行 NestedScrollView 的滑动;
我们可以根据嵌套滑动流程图来分析下:
根据流程图,可以知道,调用 startNestedScrolled 的时候,说明开始滑动了,我们进入 RecyclerView 的 startNestedScroll 方法看一下:
@Override
public boolean startNestedScroll(int axes) {
return getScrollingChildHelper().startNestedScroll(axes);
}
这里通过一个 Helper 帮助类,来处理滑动事件,我们进入这个 Helper 看下
public boolean startNestedScroll(@ScrollAxis int axes) {
return startNestedScroll(axes, TYPE_TOUCH);
}
public boolean startNestedScroll(@ScrollAxis int axes, @NestedScrollType int type) {
if (hasNestedScrollingParent(type)) {
// Already in progress
return true;
}
// 判断有没有开启嵌套滑动
if (isNestedScrollingEnabled()) {
// 获取当前 View 的 parent
ViewParent p = mView.getParent();
View child = mView;
while (p != null) {
// 问询这个 parent 是否支持嵌套滑动
if (ViewParentCompat.onStartNestedScroll(p, child, mView, axes, type)) {
setNestedScrollingParentForType(type, p);
// 如果支持,接受嵌套滑动
ViewParentCompat.onNestedScrollAccepted(p, child, mView, axes, type);
return true;
}
if (p instanceof View) {
child = (View) p;
}
p = p.getParent();
}
}
return false;
}
因为是 NestedScrollView 所以肯定支持嵌套滑动,这里遇到 NestedScrollView 的时候就会 return true;
当手指滑动的时候,正常应该是先让 NestedScrollView 滑动,等它不能滑动的时候,RecyclerView 才进行滑动;
开始滑动的时候,RecyclerView 会执行 dispatchNestedPreScroll,而 NestedScrollView 则会执行 onNestedPreScroll 方法,来判断并且决定 NestedScrollView 要不要执行滑动
@Override
public void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed,
int type) {
dispatchNestedPreScroll(dx, dy, consumed, null, type);
}
@Override
public boolean dispatchNestedPreScroll(int dx, int dy, int[] consumed, int[] offsetInWindow,
int type) {
return mChildHelper.dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow, type);
}
我们发现在 NestedScrollView 中它又去问询它的父亲能不能滑动了,这里就和 RecyclerView 冲突了,这是因为 NestedScrollView 即可以当父亲也可以当孩子导致,那么上面的问题原因也就找到了,我们把 NestedScrollView 当成了父亲,那么它就不需要再去问询自己的父亲了,只需要判断下自己能不能滑动,能滑动就自己滑动,同时记录消费的距离;
所以我们复写 NestedScrollView 的 onNestPresScroll 方法,在 RecyclerView 滑动之前,自己先处理滑动;
public void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed) {
// 我们需要处理不可滑动的区域的可见高度
boolean hideTop = dy > 0 && getScrollY() < topView.getMeasuredHeight();
if (hideTop) {
scrollBy(0, dy);
consumed[1] = dy;
}
}
编译后效果如下:
可以看到,已经可以吸顶,并且先滑动顶部,再滑动 RecyclerView;但是在快速滑动之后的惯性滑动还是没有;我们需要加上惯性滑动,来保证体验的完美;
我们需要接入 Fling 来实现,重写 fling 方法,让子View RecyclerView fling 同样的值;
public void fling(int velocityY) {
super.fling(velocityY);
if (velocityY > 0) {
ViewPager2 viewPager2 = getChildView(this, ViewPager2.class);
if(viewPager2 != null) {
RecyclerView childRecyclerView = getChildView(((ViewGroup)viewPager2.getChildAt(0)), RecyclerView.class);
if (childRecyclerView != null) {
childRecyclerView.fling(0, velocityY);
}
}
}
}
我们运行看下效果:
可以看到,实现了惯性滑动的效果,perfect~~
简历上可写:深度理解嵌套滑动实现原理,可实现复杂的嵌套滚动效果;
事件冲突与解决方案大揭秘;
来都来了,点个关注点个赞吧,你的支持是我最大的动力~~~