Android进阶 View事件体系(三):典型的滑动冲突情况和解决策略
内容概要
本篇文章为总结View事件体系的第三篇文章,前两篇文章的在这里:
- Android进阶 View事件体系(一):概要介绍和实现View的滑动
- Android进阶 View事件体系(二):从源码解析View的事件分发
本篇文章主要是介绍两种基本的滑动冲突情况和对应的解决策略,内容有:
- 基本的滑动冲突情况
- 解决滑动冲突的基本策略
- 解决滑动冲突的具体示例
基本的滑动冲突情况
实际上最基本的滑动冲突情况就两种:
- 外部滑动方向和内部滑动方向不一致
- 外部滑动方向和内部滑动方向一致
还有一种情况就是上面两种基本情况的嵌套:
- 既有滑动方向不一致,又有滑动方向一致的情况
具体的场景可以如下面几幅图片所示:
场景一显然可以在使用到ViewPager 或者 ViewPager2配合Fragment进行滑动的页面中见到,这种页面中外部可以进行左右滑动进行Fragment切换,Fragment中又往往有RecycleView或者ListView等可以上下滑动的组件,就会产生滑动的冲突。ViewPager系列中已经解决了这种滑动冲突,我们就不关注了,但是如果大家使用过ScrollView,就会发现会产生许多奇奇怪怪的问题,就像需要我们解决滑动冲突。
场景二是在内外两层都在同一个方向可以滑动时产生的,当我们进行滑动时,系统就不知道到底要滑动哪一层,最终造成的结果就是两层都能滑动或者两层都不能滑动。
场景三则是前两种情况的嵌套,实际上在我的开发情况中倒是没有见过很多这种情况,一般嵌套过多的话会强制禁止一层进行滑动,不过我们还是得分析一下。它可以拆分为外中层的嵌套和中内层的嵌套。所以我们处理起来就可以分解为之前两种情况的组合。
本质上来说这三种滑动冲突的复杂度是相同的,因为他们的不同仅仅是滑动策略的不同,所以他们也有几种通用的解决策略,下面我们来介绍几种解决策略。
解决滑动冲突的基本策略
基本策略
在 Android 中,处理滑动冲突的策略可以根据具体的需求和布局结构选择不同的方法。一般来说,有以下几种策略:
-
内部拦截法(内部消费法):父容器拦截滑动事件,并根据需要将滑动事件传递给子视图处理。这种策略常用于嵌套滑动的情况,例如在一个垂直滚动的列表或滚动视图中包含水平滚动的子视图。父容器可以通过重写onInterceptTouchEvent()方法来判断是否拦截滑动事件。
-
外部拦截法(外部消费法):子视图拦截滑动事件,不让其传递给父容器处理。这种策略常用于需要子视图完全接管滑动事件的情况,例如在一个自定义的可拖拽控件中,子视图需要处理拖拽操作并阻止父容器的滑动。
-
嵌套滑动:使用嵌套滑动机制,通过NestedScrollingParent和NestedScrollingChild接口实现父子视图之间的协调滑动。父容器和子视图可以协同工作,根据滑动距离和方向进行滑动的分发和处理,以实现平滑的滑动效果。这种策略适用于复杂的滑动场景,例如嵌套的滚动视图或多层级的滑动布局。
-
触摸事件优先级处理:通过调整视图的触摸事件分发优先级来处理滑动冲突。可以通过修改视图的事件处理顺序、设置requestDisallowInterceptTouchEvent()方法或使用ViewGroup.setDescendantFocusability()方法等方式来调整触摸事件的分发顺序。
-
多指触摸处理:对于多指触摸冲突,可以使用手势识别器(GestureDetector)或处理多指触摸事件的相关方法来判断和处理滑动冲突。可以根据具体的手势类型或触摸点位置等条件来进行冲突解决。
其中,内部拦截法和外部拦截法是最基本的两种策略,这里我们也将详细介绍这两种解决策略。
分析冲突情况
为了解决我们上面所说的三种冲突情况,我们需要分析下这三种情况。对于情况一来说,我们只需要判断用户的意图究竟是横向滑动还是纵向滑动即可,若是横向滑动就交由父容器处理,否则就交由子View处理。那该如何判断呢?实际上通过横向滑动的距离和纵向滑动的距离就可以判断出滑动角度了,若滑动路径与纵向坐标夹角的较小角度大于45°,则判断为纵向滑动,否则判断为横向滑动,这样可能有点抽象,我们看下面图片进行分析:
此次滑动的水平滑动值为dx,垂直滑动值为dy,只要当dy > dx时则可以满足我们之前所说的对纵向滑动的判断标准,即:当dy > dx 时,判断为纵向滑动,交由子View处理;dx > dy时,判断为横向滑动,交由父ViewGroup容器处理。
对于场景二,它无法通过滑动的角度,距离差以及速度差来进行判断,但这个时候一般都能在业务上找到突破点,比如业务上有规定:当处于某种状态时需要外部View响应用户的滑动,而处于另外一种状态时则需要内部View响应View的滑动,根据这种业务上的需求我们也可以得出响应的处理规则,有了处理规则同样就可以进行下一步的处理了。
而场景三就是场景一二的组合,还是需要从业务上找到突破点。
外部拦截法
所谓外部拦截法就是指点击事件都要先经过父容器的拦截处理,如果父容器需要拦截就进行事件拦截,否则就不需要进行拦截。这样就可以解决滑动冲突的问题,这种方法也符合事件分发机制。
外部拦截法需要重写父容器的onInterceptTouchEvent方法,在内部进行相应的拦截,伪代码如下所示:
public boolean onInterceptTouchEvent(MotionEvent event){
boolean intercepted = false;
int x = (int) event.getX();
int y = (int) event.getY();
switch(event.getAction()){
case MotionEvent.ACTION_DOWN:{
intercepted = false;
break;
}
case MotionEvent.ACTION_MOVE:{
if(父容器需要当前点击事件){
intercepted = true;
}else{
intercepted = false;
}
break;
}
case MotionEvent.ACTION_UP:{
intercepted = false;
break;
}
default:
break;
}
mLastXIntercepted = x;
mLastYIntercepted = y;
return intercepted;
}
要理解这里的逻辑需要我们对之前的事件分发的流程熟记于心,在onInterceptTouchEvent方法中,对ACTION_DOWN事件父容器必须返回false,即必须不拦截,因为一旦父容器拦截了事件,后续的ACTION_MOVE和ACTION_UP事件就不能传递给子View了,而是直接交给父容器处理。
而对于ACTION_MOVE,则需要根据需求决定是否需要拦截。比如说对于我们的情况一来说,当横向滑动距离dx 大于 纵向滑动距离dy,那么就需要父容器进行拦截并处理。
最后是ACTION_UP事件,这里我们一般返回false,即不拦截,这样可以传递给子View,如果有特殊需求的话可以进行调整。
内部拦截法
内部拦截法就是指父容器不拦截任何事件,所有事件都传递给子View,如果子View需要的话就直接消耗掉,否则就交给父容器处理,需要配合requestDisallowInterceptTouchEvent方法进行。我们需要重写子元素的dispatchTouchEvent方法,伪代码如下:
public boolean dispatchTouchEvent(MotionEvent event){
int x = (int) event.getX();
int y = (int) event.getY();
swicth(event.getAction()){
case MotionEvent.ACTION_DOWN:{
parent.requestDisallowInterceptTouchEvent(true);//禁止父容器拦截
break;
}
case MotionEvent.ACTION_MOVE:{
int deltaX = x - mLastX;
int deltaY = y - mLastY;
if(父容器需要点击事件){
parent.requestDisallowInterceptTouchEvent(false);//允许父容器拦截
}
break;
}
case MotionEvent.ACTION_UP:{
break;
}
default:
break;
}
mLastX = x;
mLastY = y;
return super.dispatchTouchEvent(event); //还要调用超类的逻辑是为了不影响其内部的View的事件响应
}
接着,我们还需要修改父容器的onInterceptTouchEvent方法配合:
public boolean onInterceptTouchEvent(MotionEvent event){
int action = event.getAction();
if(action == MotionEvent.ACTION_DOWN){
return false;
}else {
return true;
}
}
这里首先要将父容器的DOWN事件设置为不拦截,因为内部拦截法就是要先将事件交给子View进行处理,一个事件序列又总是从DOWN事件开始的,所以一旦父容器将DOWN事件拦截,那么之后的一系列事件都无法传递给子View处理了;后面的其余事件都拦截是因为,如果子事件设置了父容器的DisallowInterceptTouchEvent为false的话,那就说明这个时候子View允许父容器进行拦截了,所以其他事件都要返回true。
可以看到,内部拦截法是比较复杂且其不符合事件分发的流程,所以我们一般还是建议使用外部拦截法进行处理。
解决滑动冲突的具体示例
接下来,我们在一些具体的场景下进行具体分析,这里都用外部拦截法进行处理了。
场景一
场景一即内部和外部滑动方向不同的情况下,我们来实现一个类似于ViewPager的控件,其实就相当于一个可以左右滑动的LinearLayout,内部横向摆放着若干个ListView,这样LinearLayout是左右滑动的,而ListView本身又可以上下滑动,就造成了滑动冲突。这种情况下,我们可以根据我们之前分析的滑动策略来解决,即判断水平滑动距离大还是竖直滑动距离大,我们先来看自定义LinearLayout类的源码:
public class HorizontalScrollViewEx extends LinearLayout {
private int mLastXIntercept = 0;
private int mLastYIntercept = 0;
private int mLastX = 0;
private int mLastY = 0;
//子元素的相关信息
private int mChildrenSize = 3;
private int mChildWidth;
private int mChildIndex;
private Scroller mScroller;//弹性滚动对象-仅能滚动内容
private VelocityTracker mVelocityTracker;
public HorizontalScrollViewEx(Context context) {
super(context);
init();
}
public HorizontalScrollViewEx(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
init();
}
public HorizontalScrollViewEx(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init();
}
public HorizontalScrollViewEx(Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
}
public void setInfo(int mChildrenSize,int mChildWidth){
this.mChildWidth = mChildWidth;
this.mChildrenSize = mChildrenSize;
}
private void init(){//初始化速度追踪器和弹性滚动对象
mScroller = new Scroller(getContext());
mVelocityTracker = VelocityTracker.obtain();
}
//先是外部拦截法
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
boolean intercepted = false;
int x = (int) ev.getX();//获取触摸点的坐标
int y = (int) ev.getY();
switch (ev.getAction()){
case MotionEvent.ACTION_DOWN:
intercepted = false;
if(!mScroller.isFinished()){
mScroller.abortAnimation();
intercepted = true; //如果还没有完成滚动就要进行拦截
}
break;
case MotionEvent.ACTION_MOVE:
int deltaX = x - mLastXIntercept;//获取滑动差值
int deltaY = y - mLastYIntercept;
if(Math.abs(deltaX) > Math.abs(deltaY)){
//如果横向滑动距离大于纵向滑动距离-即判定为水平滑动的话-那么由父布局拦截处理了
intercepted = true;
}else{
intercepted = false;
}
break;
case MotionEvent.ACTION_UP:
intercepted = false;//一次完整的事件序列结束后,重置状态
break;
default:
break;
}
//更新状态
mLastX = x;
mLastY = y;
mLastYIntercept = y;
mLastXIntercept = x;
return intercepted;
}
@Override
public boolean onTouchEvent(MotionEvent event) {
mVelocityTracker.addMovement(event);
int x = (int) event.getX();
int y = (int) event.getY();
switch (event.getAction()){
case MotionEvent.ACTION_DOWN:{
if(!mScroller.isFinished()){
mScroller.abortAnimation();//停止滚动动画
}
break;
}
case MotionEvent.ACTION_MOVE:{
int deltaX = x - mLastX;
int deltaY = y - mLastY;
scrollBy(-deltaX,0);
break;
}
case MotionEvent.ACTION_UP:{
int scrollX = getScrollX();
int scrollToChildIndex = scrollX / mChildWidth;
mVelocityTracker.computeCurrentVelocity(1000);
float velocityX = mVelocityTracker.getXVelocity();//获取横向滑动速度
if(Math.abs(velocityX) >= 50){
mChildIndex = velocityX > 0 ? mChildIndex - 1:mChildIndex + 1;
}else{
mChildIndex = (scrollX + mChildWidth/2) / mChildWidth;
}
mChildIndex = Math.max(0,Math.min(mChildIndex,mChildrenSize-1));
int dx = mChildIndex * mChildWidth - scrollX;
smoothScrollBy(dx,0);
mVelocityTracker.clear();
break;
}
default:
break;
}
mLastX = x;
mLastY = y;
return true;
}
private void smoothScrollBy(int dx,int dy){
mScroller.startScroll(getScrollX(),0,dx,0,1000);
invalidate();
}
@Override
public void computeScroll() {
if(mScroller.computeScrollOffset()){
scrollTo(mScroller.getCurrX(),mScroller.getCurrY());
postInvalidate();
}
}
}
可以看到,在onIntercepTouchEvent方法中通过水平和垂直滑动距离来判断是否要进行父容器的处理。然后我们看在父容器的onTouchEvent方法中是如何处理滑动事件的,首先如果是正常滑动的话,即是MOVE事件的话就用scrollBy方法将这个View的画布进行滑动,实现了滑动的效果。我们接着还需要考虑一些特殊情况,比如说如果滑动的距离超过了画布中内容的最大宽度或者小于最小宽度的话,为了防止越界的话,就要在UP事件中进行特殊的处理:
case MotionEvent.ACTION_UP:{
int scrollX = getScrollX();
int scrollToChildIndex = scrollX / mChildWidth;
mVelocityTracker.computeCurrentVelocity(1000);
float velocityX = mVelocityTracker.getXVelocity();//获取横向滑动速度
if(Math.abs(velocityX) >= 50){
mChildIndex = velocityX > 0 ? mChildIndex - 1:mChildIndex + 1;
}else{
mChildIndex = (scrollX + mChildWidth/2) / mChildWidth;
}
mChildIndex = Math.max(0,Math.min(mChildIndex,mChildrenSize-1));
int dx = mChildIndex * mChildWidth - scrollX;
smoothScrollBy(dx,0);
mVelocityTracker.clear();
break;
}
首先是获得了scrollX的值,这个值的意义就是画布的左边界距离View的边界的距离,这个值为正值的话就是说明此时画布已经左移了。然后我们用速度追踪器追踪了移动事件的速度,这样做的意义就是当手指快速移动的时候即使滑动距离很小也将由父容器进行处理。
接下来的判断逻辑就是用来处理一些特殊情况的,速度超过50就将其视为一次父容器的滑动,且当滑动距离大于子View的二分之一宽度时将其视为一次父布局的滑动,反之将其视为不滑动。最后得到的mChildIndex参数则是计算出的应该滑动到的子View的下标
mChildIndex = Math.max(0,Math.min(mChildIndex,mChildrenSize-1));
这段代码则是为了防止下标越界的问题。
看完了自定义LinearLayout的代码,接着我们还要在Activity中进行相应的初始化操作:
public class MainActivity extends AppCompatActivity {
private static final String TAG = "MainActivity";
private HorizontalScrollViewEx mListContainer;
private DisplayMetrics displayMetrics;//用来获得屏幕尺寸的
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
Log.d(TAG, "onCreate: ");
initView();
}
private void initView(){//初始化视图
displayMetrics = new DisplayMetrics();
WindowManager windowManager = (WindowManager) getSystemService(Context.WINDOW_SERVICE);
LayoutInflater inflater = getLayoutInflater();//获取xml加载器--加载到该Activity中
mListContainer = findViewById(R.id.mListContainer);
int screenWidth = 0;//屏幕宽度
int screenHeight = 0;//屏幕高度
if(windowManager != null){
windowManager.getDefaultDisplay().getMetrics(displayMetrics);
screenWidth = displayMetrics.widthPixels;
screenHeight = displayMetrics.heightPixels;
}
Log.d(TAG, "width : "+screenWidth);
Log.d(TAG, "height : "+screenHeight);
for(int i = 0;i < 3;i++){
ViewGroup layout = (ViewGroup) inflater.inflate(R.layout.content_layout,mListContainer,false);
layout.getLayoutParams().width = screenWidth;
TextView textView = layout.findViewById(R.id.mTitle);
textView.setText("Page "+(i + 1));
layout.setBackgroundColor(Color.rgb(255/(i+1),255/(i+1),255/(i+1)));//设置背景色
createList(layout);//为当前页面生成纵向List
mListContainer.addView(layout);//添加到自定义View中
}
mListContainer.setInfo(3,screenWidth);
}
private void createList(ViewGroup layout){
ListView listView = layout.findViewById(R.id.mList);
ArrayList<String> datas = new ArrayList<>();
for(int i = 0; i < 50;i++){
datas.add("name "+i);
}
ArrayAdapter<String> adapter = new ArrayAdapter<>(this, android.R.layout.simple_list_item_1,datas);
listView.setAdapter(adapter);
}
}
首先是通过WindowManager和DisplayMetrics获取到了当前设备屏幕的宽和高,方便我们在初始子View时将其正好填满一个手机屏幕的大小。接着使用布局膨胀器将xml文件加载成View并添加入Activity中,下面是xml文件的源码,使用的是约束布局:
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:id="@+id/mTitle"
android:layout_width="0dp"
android:layout_height="36dp"
android:gravity="center"
android:text="TextView"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<ListView
android:id="@+id/mList"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/mTitle" />
</androidx.constraintlayout.widget.ConstraintLayout>
接着再createList方法中,我们将ListView的内容进行了初始化,运行程序,我们就可以得到我们自己写的ViewPager了:
场景二
要处理场景二的冲突的话比较复杂,这里我们实现一个类似于可折叠toolbar(CollapsingToolbarLayout)结合ListView的效果的冲突,差不多就是这个情况:
我们在之前写的HorizontalScrollView的基础上进行修改,这里我们首先要总结出处理逻辑(首先默认Header一开始为展开状态):
- 即当Header为展开状态且ListView已经到顶部时,且滑动方向为向上,这个时候将事件交由父ViewGroup进行处理,将Header进行折叠;
- 当Header为折叠状态且ListView已经到顶部时,且滑动方向为向下,这个时候将事件交由父ViewGroup进行处理,将Header进行展开;
- 当Header为折叠状态且ListView已经到顶部时,且滑动方向为向上,这个时候将事件交由子View进行处理,滑动ListView即可;
- 滑动完成后要更新是否到达顶部和折叠状态的更新。
这里笔者根据这几个简单的逻辑就简单实现了这个功能,不过这里仅做演示,这个自定View实际上还有许多别的问题,先上结果图:
demo我放在我的github里☞:滑动冲突示例
这里只给出自定义View的源码,完整的一套代码还是放在github中了:
public class StickyLayout extends LinearLayout {
private static final String TAG = "StickyLayout";
private int mLastXIntercept = 0;
private int mLastYIntercept = 0;
private int mLastX = 0;
private int mLastY = 0;
//子元素的相关信息
//特有的状态标志位来判断
private int mHeaderStatue = Statue_EXPAND; //一开始状态为展开
private boolean isSticky = true; //ListView是否到达顶部 - 一开始状态为到达顶部
private static final int Statue_EXPAND = 1;//状态为展开
private static final int Statue_COLLASPLE = 2;//状态为折叠
private int mTouSlop;
public TextView mHeader;
public ListView mListView;
private Scroller mScroller;//弹性滚动对象-仅能滚动内容
private VelocityTracker mVelocityTracker;
public StickyLayout(Context context) {
super(context);
init();
}
public StickyLayout(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
init();
}
public StickyLayout(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init();
}
public StickyLayout(Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
}
private void init(){//初始化速度追踪器和弹性滚动对象
mScroller = new Scroller(getContext());
mVelocityTracker = VelocityTracker.obtain();
mTouSlop = ViewConfiguration.get(getContext()).getScaledTouchSlop();
}
//先是外部拦截法
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
boolean intercept = false;
int x = (int) ev.getX();
int y = (int) ev.getY();
switch (ev.getAction()){
case MotionEvent.ACTION_DOWN:{
intercept = false;
break;
}
case MotionEvent.ACTION_MOVE:{
int deltaX = x - mLastXIntercept;
int deltaY = y - mLastYIntercept;
if(mHeaderStatue == Statue_EXPAND && isSticky == true){
intercept = true;
}else if(mHeaderStatue == Statue_COLLASPLE && deltaY > 0 && isSticky == true){
intercept = true;
}else if(mHeaderStatue == Statue_COLLASPLE && deltaY < 0 && isSticky == true){
intercept = false;
}
//判断ListView是否到达顶部了
if(mListView.getFirstVisiblePosition() == 0){
View view = mListView.getChildAt(0);
if(view != null && view.getTop() >= 0){
isSticky = true;
}else{
isSticky = false;
}
}else if(mListView.getFirstVisiblePosition() != 0){
isSticky = false;
}
break;
}
case MotionEvent.ACTION_UP:{
intercept = false;
break;
}
default:
break;
}
mLastYIntercept = y;
mLastXIntercept = x;
mLastY = y;
mLastX = x;
Log.d(TAG, "finish ScrollY: "+getScrollY());
Log.d(TAG, "isColl?: "+ mHeaderStatue);
Log.d(TAG, "isSticky?: "+isSticky);
return intercept;
}
@Override
public boolean onTouchEvent(MotionEvent event) {
int x = (int) event.getX();
int y = (int) event.getY();
switch (event.getAction()){
case MotionEvent.ACTION_DOWN:{
break;
}
case MotionEvent.ACTION_MOVE:{
int deltaX = x - mLastX;
int deltaY = y - mLastY;
scrollBy(0,-deltaY);
break;
}
case MotionEvent.ACTION_UP:{
if(getScrollY() < 0){
smoothScrollBy(0,-getScrollY());
}else if(getScrollY() >= 300){
smoothScrollBy(0,200-getScrollY());//剩余200dp就判断为折叠了
mHeaderStatue = Statue_COLLASPLE;//如果滑动到了一定程度,就判定为折叠了
}
if(getScrollY() <= 0){
mHeaderStatue = Statue_EXPAND;
}
break;
}
default:
break;
}
mLastX = x;
mLastY = y;
return true;
}
private void smoothScrollBy(int dx,int dy){
mScroller.startScroll(getScrollX(),getScrollY(),dx,dy,1000);
invalidate();
}
@Override
public void computeScroll() {
if(mScroller.computeScrollOffset()){
scrollTo(mScroller.getCurrX(),mScroller.getCurrY());
postInvalidate();
}
}
}
其中的
private int mHeaderStatue = Statue_EXPAND; //一开始状态为展开
private boolean isSticky = true; //ListView是否到达顶部 - 一开始状态为到达顶部
private static final int Statue_EXPAND = 1;//状态为展开
private static final int Statue_COLLASPLE = 2;//状态为折叠
这几个变量就是用来保持状态以便后续判断的。这里还防止了过度滑动的问题,如果滑动超过边界则会调用弹性调用方法移动到边界处。
到此为止,前面两种基本的滑动冲突的解决方法就介绍完成了,至于第三种情况,只要把它拆分为前两种情况的组合就可以了。