Android中为我们提供了一些解决嵌套滑动的方式方法,本篇文章利用这些方式方法来实现和处理一下嵌套滑动,作为实战的实例。
先铺垫一下NestedScrollingParent和NestedScrollingChild,后面利用CoordinatorLayout完成电商首页。
上篇文章也有说到,其实在一些已经把嵌套滑动解决的控件中。
比如RecyclerView
public class RecyclerView extends ViewGroup implements ScrollingView,
NestedScrollingChild2, NestedScrollingChild3 {
NestedScrollView
public class NestedScrollView extends FrameLayout implements NestedScrollingParent3,
NestedScrollingChild3, ScrollingView {
CoordinatorLayout
public class CoordinatorLayout extends ViewGroup implements NestedScrollingParent2,
NestedScrollingParent3 {
可以看到在一些滑动的组件中都继承了NestedScrollingChild或者NestedScrollingParent来对嵌套滑动进行了处理。所以大家在使用这些组件进行嵌套时,会发现没什么嵌套滑动的问题。
那我们来看看NestedScrollingChild、NestedScrollingParent都有什么因为我们下面要使用CoordinatorLayout
什么时候去继承:可滑动的ViewGroup作为父容器的时候,那么就需要实现这个接口。
实现这个接口主要是想去重写他的几个方法,来帮助我们解决滑动冲突,所以这块不用太纠结。把这几个方法了解了就行:
public interface NestedScrollingParent {
/**
* React to a descendant view initiating a nestable scroll operation, claiming the
* nested scroll operation if appropriate.
* 当子View调用startNestedScroll方法的时候,父容器会在这个方法中获取回调
*/
boolean onStartNestedScroll(@NonNull View child, @NonNull View target, @ScrollAxis int axes);
/**
* React to the successful claiming of a nested scroll operation.
* 在onStartNestedScroll调用之后,就紧接着调用这个方法
*/
void onNestedScrollAccepted(@NonNull View child, @NonNull View target, @ScrollAxis int axes);
/**
* React to a nested scroll operation ending.
* 当子View调用 stopNestedScroll方法的时候回调
*/
void onStopNestedScroll(@NonNull View target);
/**
* React to a nested scroll in progress.
*
*/
void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed,
int dxUnconsumed, int dyUnconsumed);
/**
* React to a nested scroll in progress before the target view consumes a portion of the scroll.
* 在子View调用dispatchNestedPreScroll之后,这个方法拿到了回调
*
*/
void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed);
/**
* Request a fling from a nested scroll.
*
*/
boolean onNestedFling(@NonNull View target, float velocityX, float velocityY, boolean consumed);
/**
* React to a nested fling before the target view consumes it.
*
*/
boolean onNestedPreFling(@NonNull View target, float velocityX, float velocityY);
/**
* Return the current axes of nested scrolling for this NestedScrollingParent.
* 返回当前滑动的方向
*/
@ScrollAxis
int getNestedScrollAxes();
}
什么时候去继承:作为可滑动的子View,那么就需要实现NestedScrollingChild接口。
public interface NestedScrollingChild {
/**
* Enable or disable nested scrolling for this view.
*
* 启动或者禁用嵌套滑动,如果返回ture,那么说明当前布局存在嵌套滑动的场景,反之没有
* 使用场景:NestedScrollingParent嵌套NestedScrollingChild
* 在此接口中的方法,都是交给NestedScrollingChildHelper代理类实现
*/
void setNestedScrollingEnabled(boolean enabled);
/**
* Returns true if nested scrolling is enabled for this view.
* 其实就是返回setNestedScrollingEnabled中设置的值
*/
boolean isNestedScrollingEnabled();
/**
* Begin a nestable scroll operation along the given axes.
* 表示view开始滚动了,一般是在ACTION_DOWN中调用,如果返回true则表示父布局支持嵌套滚动。
* 一般也是直接代理给NestedScrollingChildHelper的同名方法即可。这个时候正常情况会触发Parent的onStartNestedScroll()方法
*/
boolean startNestedScroll(@ScrollAxis int axes);
/**
* Stop a nested scroll in progress.
* 停止嵌套滚动,一般在UP或者CANCEL事件中执行,告诉父容器已经停止了嵌套滑动
*/
void stopNestedScroll();
/**
* Returns true if this view has a nested scrolling parent.
* 判断当前View是否存在嵌套滑动的Parent
*/
boolean hasNestedScrollingParent();
/**
* 当前View消费滑动事件之后,滚动一段距离之后,把剩余的距离回调给父容器,父容器知道当前剩余距离
* dxConsumed:x轴滚动的距离
* dyConsumed:y轴滚动的距离
* dxUnconsumed:x轴未消费的距离
* dyUnconsumed:y轴未消费的距离
* 这个方法是嵌套滑动的时候调用才有用,返回值 true分发成功;false 分发失败
*/
boolean dispatchNestedScroll(int dxConsumed, int dyConsumed,
int dxUnconsumed, int dyUnconsumed, @Nullable int[] offsetInWindow);
/**
* Dispatch one step of a nested scroll in progress before this view consumes any portion of it.
* 在子View消费滑动距离之前,将滑动距离传递给父容器,相当于把消费权交给parent
* dx:当前水平方向滑动的距离
* dy:当前垂直方向滑动的距离
* consumed:输出参数,会将Parent消费掉的距离封装进该参数consumed[0]代表水平方向,consumed[1]代表垂直方向
* @return true:代表Parent消费了滚动距离
*/
boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed,
@Nullable int[] offsetInWindow);
/**
* Dispatch one step of a nested scroll in progress.
* 处理惯性事件,与dispatchNestedScroll类似,也是在消费事件之后,将消费和未消费的距离都传递给父容器
*/
boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed);
/**
* Dispatch a fling to a nested scrolling parent before it is processed by this view.
* 与dispatchNestedPreScroll类似,在消费之前首先会传递给父容器,把优先处理权交给父容器
*/
boolean dispatchNestedPreFling(float velocityX, float velocityY);
}
通过这两个接口,我们大概就能够明白,其实嵌套滑动机制完全是子View在做主导,通过子View能够决定Parent是否能够优先消费事件(dispatchNestedPreScroll)。
NestedScrollingChildHelper 和 NestedScrollingParentHelper 类的作用:主要是帮助内部View和外部View实现交互逻辑。
举个例子就知道了:
在实现了NestedScrollingChild就需要声明NestedScrollingChildHelper进行
public class NestedChildView extends View implements NestedScrollingChild {
......
private NestedScrollingChildHelper mScrollingChildHelper;
private void init() {
mScrollingChildHelper = new NestedScrollingChildHelper(this);
}
.....
/**
* 设置是否允许嵌套滑动
*/
@Overridepublic void setNestedScrollingEnabled(boolean enabled) {
//通过mScrollingChildHelper进行通信
mScrollingChildHelper.setNestedScrollingEnabled(enabled);
}
/**
* 是否允许嵌套滑动
*/
@Overridepublic boolean isNestedScrollingEnabled() {
return mScrollingChildHelper.isNestedScrollingEnabled();
}
.....
知道干啥的了吧,不用整那么细,把上面的方法都解读一下。了解有什么作用,用的时候再去细看就行。都是事件分发那一套。
CoordinatorLayout翻译为协调者布局,是用来协调其子View们之间动作的一个容器,他是一个超级强大的FrameLayout,结合AppBarLayout、 CollapsingToolbarLayout等可产生各种炫酷的效果。
下面用一个例子来讲解
以下面的图片为例:
我们按照这样的架构实现下面的交互功能:
1、上面为一个headerView模块我们以一个图片模块代替
2、中间为一个TabLayout,有多个Table结合下面的ViewPage
3、下面为ViewPage中的Fragment,其中用RecyclerView展示我们的商品
4、TabLayout可以吸顶
5、当headerView没有完全隐藏的情况下在RecyclerView上滑动时,优先滑动headerView直至隐藏后RecyclerView继续滑动。
6、在headView上向下fling,会将剩余的惯性传递到RecyclerView。
下面是小编实现的一个效果:
接下来看看都是怎么实现的一步一步进行。
首先实现前三步:(这是比较常规的代码了)
1、上面为一个headerView模块我们以一个图片模块代替
2、中间为一个TabLayout,有多个Table结合下面的ViewPage
3、下面为ViewPage中的Fragment,其中用RecyclerView展示我们的商品
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<ImageView
android:id="@+id/head_view"
android:layout_width="match_parent"
android:layout_height="200dp"
android:scaleType="fitXY"
android:src="@mipmap/image" />
<com.google.android.material.tabs.TabLayout
android:id="@+id/tab_layout"
android:layout_width="match_parent"
android:layout_height="40dp"
android:background="@color/pick"/>
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/view_page"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/black"/>
LinearLayout>
首先实现这三个模块(内部代码自己写吧后面我会把代码地址给出来)
大概就是这个样子。
之后大家运行一下发现,几乎跟我们上面实现的效果不搭边。 那我们就继续实现:
4、TabLayout可以吸顶
5、当headerView没有完全隐藏的情况下在RecyclerView上滑动时,优先滑动headerView直至隐藏后RecyclerView继续滑动。
6、在headView上向下fling,会将剩余的惯性传递到RecyclerView。
这时候就要用到CoordinatorLayout布局了。
1、CoordinatorLayout是什么布局?
他是协调者布局,本身是个FrameLayout,是继承与NestedScrollingParent2和NestedScrollingParent3来解决滑动冲突。
CoordinatorLayout is a super-powered FrameLayout. //API中的解释
public class CoordinatorLayout extends ViewGroup implements NestedScrollingParent2,
NestedScrollingParent3 {
2、协调员是谁?
协调员就是我们的父布局CoordinatorLayout
3、协调谁?
CoordinatorLayout的子女们
4、通过谁来协调?
通过Behavior来萧条
5、协调什么内容?
代理儿女的事件流程、相应兄弟的变化、代理儿女的绘制
4、TabLayout可以吸顶
5、当headerView没有完全隐藏的情况下在RecyclerView上滑动时,优先滑动headerView直至隐藏后RecyclerView继续滑动。
其中这两项功能通过我们的现有API代码就可以实现。这里我先将代码给大家,再进一步讲解。
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout
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">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/appbar_layout"
app:layout_behavior="com.google.android.material.appbar.AppBarLayout$Behavior"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<com.google.android.material.appbar.CollapsingToolbarLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fitsSystemWindows="true"
app:layout_scrollFlags="scroll|exitUntilCollapsed">
<ImageView
android:id="@+id/head_view"
android:layout_width="match_parent"
android:layout_height="200dp"
android:scaleType="fitXY"
android:src="@mipmap/image" />
</com.google.android.material.appbar.CollapsingToolbarLayout>
<com.google.android.material.tabs.TabLayout
android:id="@+id/tab_layout"
android:layout_width="match_parent"
android:layout_height="40dp"
android:background="@color/pick"/>
</com.google.android.material.appbar.AppBarLayout>
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/view_page"
app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
我们只需要改变我们的XML就可以实现这个效果。不需要改动任何之前实现的代码。
那这里是怎么处理的呢?
这里是通过:
a、AppBarLayout实现吸顶、并配合CollapsingToolbarLayout实现head的ImageView进行折叠的效果。这里不清楚的单独去查一下AppBarLayout和CollapsingToolbarLayout的作用,毕竟是一个布局这里就不细说了。来实现我们的吸顶
b、AppBarLayout通过AppBarLayout $ Behavior将滑动先传递给ViewPage2的AppBarLayout $ ScrollingViewBehavior"来实现。当headerView没有完全隐藏的情况下在RecyclerView上滑动时,优先滑动headerView直至隐藏后ViewPage2中的RecyclerView继续滑动。
这样我的4、和5也完成了
6、在headView上向下Fling,会将剩余的惯性传递到RecyclerView。
这个需要我们稍微的麻烦一些,通过自定义的Behavior来实现效果。在下面哦,先铺垫一下Behavior的知识。
1、首先Behavior是什么?
Behavior是一个可以控制子View间互动的类
2、Behavior能做什么?
可以用来实现类似于嵌套的ScrollView顶部悬浮的效果,或者实现联动效果,例如当一个View移动时,其他的View随之进行相应的调整。它可以通过实现Behavior类或者继承已有的Behavior类来自定义Behavior,实现在不同的交互场景下子View的动态协调。
3、有几种常见的Behavior
Behavior | 功能作用 |
---|---|
AppBarLayout.ScrollingViewBehavior | 用于将可滚动视图与AppBarLayout关联,实现当可滚动视图滚动时,AppBarLayout随之滚动,实现顶部标题栏随着内容的滚动而发生变化。 |
BottomSheetBehavior | 用于在屏幕的底部展示一个可滑动的特定区域,一般用来显示应用程序的底部菜单或者对话框。 |
SwipeDismissBehavior | 用于给View添加滑动关闭的功能,类似于Android系统自带的Notification 消息滑动删除功能。 |
HeaderScrollingViewBehavior | 用于将一个可滑动控件(如RecyclerView、NestedScrollView)中的headerView与一个具有特殊行为的顶部控件(如Toolbar)关联,实现当滚动视图滚动时,顶部控件也会同步进行动画效果。 |
FloatingActionButton.Behavior | 用于让FloatingActionButton随着滚动事件而出现或者消失。 |
总体而言,在Android开发中,使用Behavior时要根据实际情况进行选择,不同Behavior之间具有不同的效果,选择合适的Behavior能够大大提升用户体验。 |
想实现Fling的效果还是需要我们来自定义的
先看思路:
第一步:要知道他是Fling这里用ViewConfiguration.get(context).scaledTouchSlop获取并利用ACTION_MOVE判断
第二步:利用onInterceptTouchEvent中ACTION_MOVE判断Fling之后拦截到本层ViewGroup处理事件
第三步:怎么处理事件? 就是将HeaderView的AppBarLayout.Behavior事件交给ViewPage的AppBarLayout.ScrollingViewBehavior事件进行处理。
第四步:(可以说应该是第一步)获取HeaderView的AppBarLayout.Behavior事件
下面代码的中心思想就是围绕着上面的步骤开展的。
class CustomBehavior : AppBarLayout.Behavior {
constructor() : super()
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {
if (mTouchSlop < 0) {
mTouchSlop = ViewConfiguration.get(context).scaledTouchSlop
}
}
private var mTouchSlop: Int = -1
private lateinit var mScrollingViewBehaviorView: View
private var mIsBeginDragged = false
private var mNeedDispatcher = true
private var mLastMotionY = 0
private var mActivityPointId = -1
private var mCurrentEvent: MotionEvent? = null
//确认滑动去拦截
override fun onInterceptTouchEvent(
parent: CoordinatorLayout,
child: AppBarLayout,
ev: MotionEvent
): Boolean {
Log.e("onInterceptTouchEvent", "onInterceptTouchEvent: " + ev.action)
when (ev.action) {
MotionEvent.ACTION_DOWN -> {
mIsBeginDragged = false
mNeedDispatcher = true
val x = ev.x
val y = ev.y.toInt()
if (parent.isPointInChildBounds(child, x.toInt(), y)) {
mLastMotionY = y
mActivityPointId = ev.getPointerId(0)
mCurrentEvent?.recycle()
mCurrentEvent = MotionEvent.obtain(ev)
}
}
MotionEvent.ACTION_MOVE -> {
val activityId = mActivityPointId
if (activityId != 0 && ev.findPointerIndex(activityId) != -1) {
val moveY = ev.y
val diffY = abs(moveY - mLastMotionY)
if (diffY > mTouchSlop) { //说明是滑动动作
mIsBeginDragged = true
}
}
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
mIsBeginDragged = false
mNeedDispatcher = true
mActivityPointId = -1
}
}
if (!mIsBeginDragged) {
return super.(parent, child, ev)
}
return true
}
//去处理将AppBarLayout.Behavior事件交给AppBarLayout.ScrollingViewBehavior
override fun onTouchEvent(
parent: CoordinatorLayout,
child: AppBarLayout,
ev: MotionEvent
): Boolean {
Log.e("onInterceptTouchEvent", "onTouchEvent: " + ev.action)
var mIsTouchEvent = false
when (ev.action) {
MotionEvent.ACTION_DOWN -> {
mIsTouchEvent = false
}
MotionEvent.ACTION_MOVE -> {
val moveY = ev.y
val diffY = abs(moveY - mLastMotionY)
if (diffY > mTouchSlop) { //说明是滑动动作
mIsBeginDragged = true
}
if (mIsBeginDragged) {
//位移到下方View
val offset = child.height - child.bottom
if (mNeedDispatcher) {
mNeedDispatcher = false
mCurrentEvent?.offsetLocation(0F, offset.toFloat())
//传递下去 进行移交事件
mScrollingViewBehaviorView.dispatchTouchEvent(mCurrentEvent)
} else {
ev.offsetLocation(0F, offset.toFloat())
mScrollingViewBehaviorView.dispatchTouchEvent(ev)
mIsTouchEvent = true
}
}
}
MotionEvent.ACTION_UP -> {
if (mIsBeginDragged) {
ev.offsetLocation(0F, (child.height - child.bottom).toFloat())
mScrollingViewBehaviorView.dispatchTouchEvent(ev)
mIsTouchEvent = true
}
}
}
if (!mIsTouchEvent) {
return super.onTouchEvent(parent, child, ev)
}
return true
}
override fun onLayoutChild(
parent: CoordinatorLayout,
abl: AppBarLayout,
layoutDirection: Int
): Boolean {
val onLayoutChild = super.onLayoutChild(parent, abl, layoutDirection)
val childCount: Int = parent.childCount
for (i in 0 until childCount) {
val childView: View = parent.getChildAt(i)
val behavior: CoordinatorLayout.Behavior<AppBarLayout> =
(childView.layoutParams as CoordinatorLayout.LayoutParams).behavior as CoordinatorLayout.Behavior<AppBarLayout>
if (behavior is AppBarLayout.ScrollingViewBehavior) {
mScrollingViewBehaviorView = childView
}
}
return onLayoutChild
}
}
虽然Android API提供给我们很多的方式方法去解决滑动嵌套的问题。但是万变不离其宗,还是要清楚的知道事件分发的流程和具体的原因。
不然就算你去使用了API处理了滑动嵌套的问题。一旦出了问题,依旧会一头雾水。