最近想学习一下自定义View,发现其中有个麻烦的滑动冲突处理问题,为了应对滑动冲突,决定先研究一下Android的事件传递机制。
首先开局一张图,借鉴一篇神文中的图Android事件分发机制详解:史上最全面、最易懂
如果能将上面这张图脱稿画出,基本上事件传递机制也掌握地差不多了。
上图中,将事件传递分为三个模块,一般情况下也就主要是这几个模块的组合,无非是
- Activity-ViewGroup-View
- Activity-ViewGroup-ViewGroup-View
- Activity-ViewGroup-ViewGroup-ViewGroup…-View
所以只需要将第一种最经典的事件传递机制搞清楚了,其他的组合也都类似的处理即可。
首先分析一下各个模块中的关键方法:
Activity
- dispatchTouchEvent
事件分发的原始出口,所有的事件分发都从这里开发,dispatchTouchEvent方法决定是否分发事件,如果这里将事件消费掉(返回true or false),那所有的布局,控件都无法收到事件。 - onTouchEvent
事件的最终处理会在这里,如果ViewGroup不继续分发事件,或者后面所有的布局都不消费事件,那事件的最终归宿就是这里,无论在这返回什么,事件到这将彻底结束,不再分发。
ViewGroup
- dispatchTouchEvent
当ViewGroup的父布局(可能是Activity,可能是ViewGroup)决定分发事件时,即父布局决定分发并且不拦截该事件时,事件将传递给ViewGroup进行分发,这里的分发机制跟Activity有所差异。
(1)Activity中的dispatchTouchEvent如果返回false,事件将被消费掉,而这里返回false,不会消费事件,而是将事件抛给父布局的onTouchEvent处理
(2)返回true时与Activity处理一致,消费事件,事件传递到此结束
(3)调用默认实现super的处理与Activity也不同,Activity会直接扔给子布局的dispatch函数处理,这里会扔给事件拦截函数onInterceptTouchEvent处理。 - onInterceptTouchEvent
onInterceptTouchEvent方法并不消费事件,只有在dispatchTouchEvent默认实现的时候调用,如果返回true表示拦截该事件,交给自己的onTouchEvent处理,否则就扔给子布局分发。 - onTouchEvent
这里的onTouchEvent有三种情况会被调用,一种是onInterceptTouchEvent拦截了该事件,一种是子布局拒绝分发该事件,最后一种是子布局分发了该事件,但并不消费该事件。
(1)返回true,消费该事件,事件终止
(2)返回false,不消费事件,事件上抛给父布局的onTouchEvent处理
(3)默认(super),调用performClick,消费该事件
View
- dispatchTouchEvent
到这里一般是分发的终点了,因为View并没有子布局,往下已经没办法分发了,所以要么在这里消费掉,要么往上抛,下面是没有接盘侠的,所以这里的处理会比ViewGroup处理简单一些:
(1)返回true,消费掉该事件,事件终结。
(2)返回false,不接受该事件,抛给ViewGroup自己去处理
(3)默认实现(super),扔给自己的onTouchEvent去处理 - onTouchEvent
如果调用到这,也是我们日常开发中最常遇到的了,调用到这里只有一种情况,那就是所有的传递没有人为干预,事件按照正常的流转进行的。
(1)返回true,消费该事件,事件终止
(2)返回false,不消费事件,事件上抛给父布局的onTouchEvent处理
(3)默认(super),调用performClick,消费该事件
针对上面分析的方法,再回过头去看图,应该会有更清晰的认识。但程序中所有的认识都是基于实操而加深印象的,任何没有代码的分析都是扯淡,所以要想更深入地了解事件传递机制,只有一个办法,代码验证,虽然通过打log验证的方案很low,但对于理解事件分发流程是没有任何阻碍的,所以这里也就不多废话了,直接上用来测试的代码,大家可以根据测试代码自行验证。
自定义Button:
很简单的自定义Button,只是重写了dispatchTouchEvent和onTouchEvent方法而已。
import android.content.Context;
import android.util.AttributeSet;
import android.util.Log;
import android.view.MotionEvent;
import androidx.appcompat.widget.AppCompatButton;
public class CustomButton extends AppCompatButton {
public CustomButton(Context context) {
super(context);
}
public CustomButton(Context context, AttributeSet attrs) {
super(context, attrs);
}
public CustomButton(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
/**
* 当ViewGroup返回super时调用
* */
@Override
public boolean dispatchTouchEvent(MotionEvent event) {
Log.e("CustomButton", "CustonButton-----------dispatchTouchEvent");
/**
* 默认处理
* 事件扔给onTouchEvent处理
* */
return super.dispatchTouchEvent(event);
/**
* return true
* 表示事件在这里被消费掉了,后续不会出现任何与该事件相关的处理
* */
// return true;
/**
* return false
* 表示事件不再分发,扔给viewGroup的onTouchEvent处理
* */
// return false;
}
/**
* 当dispatchTouchEvent返回super时调用
* */
@Override
public boolean onTouchEvent(MotionEvent event) {
Log.e("CustomButton", "CustonButton-----------onTouchEvent");
/**
* 默认处理
* 调用performClick处理
* */
return super.onTouchEvent(event);
/**
* return true
* 表示事件在这里被消费掉了,后续不会出现任何与该事件相关的处理
* */
// return true;
/**
* return false
* 表示事件在这里不做处理,不会调用click事件
* 而是将事件向上抛,扔给viewGroup的onTouchEvent处理
* */
// return false;
}
}
自定义Layout
很简单的自定义布局,继承自线性布局,重写了dispatchTouchEvent,onInterceptTouchEvent和onTouchEvent方法。
import android.content.Context;
import android.util.AttributeSet;
import android.util.Log;
import android.view.MotionEvent;
import android.widget.LinearLayout;
public class CustomLayout extends LinearLayout {
public CustomLayout(Context context) {
super(context);
}
public CustomLayout(Context context, AttributeSet attrs) {
super(context, attrs);
}
public CustomLayout(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
/**
* 当Activity的dispatchTouchEvent返回super时调用
* */
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
Log.e("CustomLayout", "CustomLayout------------------dispatchTouchEvent");
/**
* 默认处理
* 因为是viewGroup,拥有众多子View
* 所以调用onInterceptTouchEvent方法判断是否拦截该事件
* */
return super.dispatchTouchEvent(ev);
/**
* return true
* 表示事件在这里被消费掉了,后续不会出现任何与该事件相关的处理
* */
// return true;
/**
* return false
* 表示事件不再分发,扔给Activity的onTouchEvent处理
* */
// return false;
}
/**
* 只做事件拦截,不消费事件
* 但dispatchTouchEvent返回super时调用
* */
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
Log.e("CustomLayout", "CustomLayout------------------onInterceptTouchEvent");
/**
* 默认处理
* 默认不拦截,事件下发到View的dispatchTouchEvent中处理
* */
return super.onInterceptTouchEvent(ev);
/**
* return true
* 拦截事件,事件在onTouchEvent中处理
* */
// return true;
/**
* return false
* 不拦截,事件下发到View的dispatchTouchEvent中处理
* */
// return false;
}
/**
* 当onInterceptTouchEvent返回true时调用
* */
@Override
public boolean onTouchEvent(MotionEvent event) {
Log.e("CustomLayout", "CustomLayout------------------onTouchEvent");
/**
* 默认处理
* 调用performClick处理
* */
return super.onTouchEvent(event);
/**
* return true
* 表示事件在这里被消费掉了,后续不会出现任何与该事件相关的处理
* */
// return true;
/**
* return false
* 表示事件在这里不做处理,不会调用click事件
* 而是将事件向上抛,扔给Activity的onTouchEvent处理
* */
// return false;
}
}
Activity
最基本的Activity,重写了dispatchTouchEvent和onTouchEvent方法,并对控件设置了点击监听。
import androidx.appcompat.app.AppCompatActivity;
import androidx.constraintlayout.widget.ConstraintLayout;
import android.os.Bundle;
import android.util.Log;
import android.view.MotionEvent;
import android.view.View;
public class MainActivity extends AppCompatActivity implements View.OnClickListener{
CustomButton customButton;
CustomLayout customLayout;
ConstraintLayout activityView;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
customButton = findViewById(R.id.custom_button);
customButton.setOnClickListener(this);
customLayout = findViewById(R.id.custom_layout);
customLayout.setOnClickListener(this);
activityView = findViewById(R.id.activity_view);
activityView.setOnClickListener(this);
}
/**
* 事件分发的入口,Touch事件的分发就是从这开始的
* 一旦这里返回true,事件就被消费了,后续将再也收不到任何事件
* */
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
Log.e("MainActivity", "MainActivity----------dispatchTouchEvent");
/**
* 默认处理
* 将事件下发给ViewGroup的dispatchTouchEvent处理
* */
return super.dispatchTouchEvent(ev);
/**
* return true
* 表示事件在这里被消费掉了,后续不会出现任何与该事件相关的处理
* */
// return true;
/**
* return false
* 理论上应该交给DecorView的onTouchEvent处理
* 一般也就代表被消费了,后续不会出现任何与该事件相关的处理
* */
// return false;
}
/**
* 当view和viewGroup的onTouchEvent都返回false,即他们都不消费事件时
* 事件被抛到最上层Activity处理
* */
@Override
public boolean onTouchEvent(MotionEvent event) {
Log.e("MainActivity", "MainActivity----------onTouchEvent");
/**
* 默认处理
* 调用performClick处理
* */
return super.onTouchEvent(event);
/**
* return true
* 表示事件在这里被消费掉了,后续不会出现任何与该事件相关的处理
* */
// return true;
/**
* return false
* 表示事件在这里不做处理
* 而是将事件向上抛,但因为上面都不处理
* 所以无论这里返回什么,事件分发到此都结束了
* 实践证明,即使这里返回false,还是会调用Activity的click方法(这里还需研究一下)
* */
// return false;
}
@Override
public void onClick(View view) {
switch (view.getId()) {
case R.id.custom_button:
Log.e("MainActivity", "Button------onClick: button clicked!");
break;
case R.id.custom_layout:
Log.e("MainActivity", "Layout------onClick: layout clicked!");
break;
case R.id.activity_view:
Log.e("MainActivity", "Activity------onClick: activity clicked!");
break;
}
}
}
布局文件
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/activity_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<com.skydianshi.toucheventtest.CustomLayout
android:id="@+id/custom_layout"
android:layout_width="match_parent"
android:layout_height="500dp"
android:background="@color/colorAccent"
app:layout_constraintTop_toTopOf="parent">
<com.skydianshi.toucheventtest.CustomButton
android:id="@+id/custom_button"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="Button"/>
</com.skydianshi.toucheventtest.CustomLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
针对不同的返回值在代码中都有对应的解释,读者可以放开对应的返回值调试,体验不同返回值下的事件传递机制,体验完了再去看一下最上面的图,就更明白其中所述了。
最终效果就是能默写出事件传递图就基本差不多啦,想要深入到源码探究的,可以点进开头的那篇神文中,其中做了最详尽的解释。
Demo
下面通过一个滑动冲突的解决demo来实践一下事件传递,相信通过这个案例,应该可以更好地理解上文所述。
上图中A为横向ScrollView,B为纵向ScrollView,A在B中,需要实现的效果很简单:
- 在A中横向滑动可以正常横向滑动A界面
- 在B中纵向滑动可以正常纵向滑动B界面
- 在A中纵向滑动可以纵向滑动B界面
前两个效果很简单,只要正常添加好滚动布局就行了,系统自动就是这种效果。但要实现第三个效果,就要解决A和B的横向纵向滑动冲突问题。
先分析一下怎么实现,到底需要修改哪个布局的哪个方法?肉眼就能看出是A布局,通俗点说,就是在A布局中纵向滑动时不处理事件,把事件扔给B处理不就行了嘛。
对,就是这个意思,老规矩,直接看代码:
<?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"
tools:context=".TouchTestActivity">
<ScrollView
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="#999999"
android:orientation="vertical">
<com.skydianshi.toucheventtest.CustomHorizontalScrollView
android:id="@+id/scroll_horizon"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintTop_toTopOf="parent">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="#ffffff"
android:orientation="horizontal">
<TextView
android:layout_width="3000dp"
android:layout_height="400dp"
android:layout_gravity="center"
android:gravity="center"
android:text="hello world"
android:textSize="50sp"
android:textColor="#000000"/>
</LinearLayout>
</com.skydianshi.toucheventtest.CustomHorizontalScrollView>
<TextView
android:layout_width="match_parent"
android:layout_height="800dp"
android:layout_gravity="center"
android:gravity="center"
android:textSize="50sp"
android:textColor="#000000"
android:text="HELLO WORLD"/>
</LinearLayout>
</ScrollView>
</androidx.constraintlayout.widget.ConstraintLayout>
布局文件很简单,就是两个滚动布局的叠加,并在其中添加了一些控件。
import android.content.Context;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.widget.HorizontalScrollView;
public class CustomHorizontalScrollView extends HorizontalScrollView {
private float x1 = 0.0f;
private float x2 = 0.0f;
private float y1 = 0.0f;
private float y2 = 0.0f;
public CustomHorizontalScrollView(Context context) {
super(context);
}
public CustomHorizontalScrollView(Context context, AttributeSet attrs) {
super(context, attrs);
}
public CustomHorizontalScrollView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
@Override
public boolean onTouchEvent(MotionEvent ev) {
boolean isHandleEvent = true;
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN:
x1 = ev.getX();
y1 = ev.getY();
break;
case MotionEvent.ACTION_MOVE:
x2 = ev.getX();
y2 = ev.getY();
isHandleEvent = judgeHorizonMove(x1, x2, y1, y2);
x1 = ev.getX();
y1 = ev.getY();
break;
case MotionEvent.ACTION_UP:
break;
}
if (isHandleEvent) {
// 如果横向移动,就自己处理
return super.onTouchEvent(ev);
} else {
// 如果纵向移动,就不处理,扔给父布局处理
return false;
}
}
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
// 也可以把上面的判断处理放到这里,如果横向移动,则分发事件,否则拒绝分发,抛给父布局处理。
return super.dispatchTouchEvent(ev);
}
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
/**
* 也可以把上面的判断处理放到这里,如果横向移动,则拦截事件自己处理,否则就分发下去
* 但由于分发下去也没人处理,因此还是会将事件向上抛
* */
return super.onInterceptTouchEvent(ev);
}
/**
* 判断是否横向移动
* */
private boolean judgeHorizonMove(float x3, float x4, float y3, float y4) {
if (Math.abs(x4 - x3) > Math.abs(y4 - y3)) {
return true;
}
return false;
}
}
关键的处理就是在这个自定义横向滚动布局中,在这里重写了onTouchEvent方法,当然也可以重写dispatchTouchEvent或者onInterceptTouchEvent方法,都可以实现效果,代码比较简单,关键地方也做了解释,就不啰嗦了。感兴趣的同学可以自己写一下,感受一下事件传递的乐趣,还挺有意思的。
本来想传一下效果图的,拍了一下发现太大,传不了,就不传了,各位可以自行尝试!