本文将通过三个demo来让你深刻感受到了解View的事件分发机制之后你能做什么,能做好什么!!
首先我们装作这些概念都理解了:(下文详细介绍)
触摸事件类型: 主要类型三种:
ACTION_DOWN
ACTION_MOVE
ACTION_UP
完整的事件传递主要包括三个阶段: 事件的分发,拦截和消费
分发:对应dispatchTouchEvent方法。返回true表示事件被当前视图消费,不再继续分发
拦截:对应onInterceptTouchEvent方法。返回true表示拦截此事件,不继续分发。(viewGroup和其子类中才拥有)
消费:对应onTouchEvent方法,返回true表示消费事件,不在向上传递。
view的事件传递机制:
—— 触摸事件的传递流程是从dispatchTouchEvent开始的,如果我们不进行重写(也就是返回默认的父类同名函数),则事件将会依照嵌套层次从外层向内层传递,到底最内层的View时,就交给它的onTouchEvent处理,该方法如果能消费该事件,则返回true,如果处理不了,则返回false,这时事件会重新向外传递。并由外层的onTouchEvent处理,依此类推
—— 如果事件在向内层传递的过程中被我们重写事件处理函数返回true时,则会导致整个事件提前被消费,内层View不会收到这个事件了。
—— View控件的事件触发顺序是先执行onTouch方法,最后才执行onClick方法。如果onTouch方法返回true的话,则事件将不会继续传递,最后也不会调用onClick方法,如果onTouch返回false,则继续向下传递。因为button的preformClick是利用onTouchEvent实现的,假设onTouchEvent没有被调用到,那么点击事件就无效了。
viewGroup的事件传递机制:
—— 触摸事件的传递顺序是由Activity到ViewGroup,再由ViewGrop递归传递给它的子View。
—— ViewGroup通过onInterceptTouchEvent方法对事件进行拦截,如果该方法返回true,则事件不会继续传递给子View,如果返回false或者super.onInterceptTouchEvent,则事件会继续传递给子View。
—— 在子View对事件进行消费后,ViewGroup将接收不到任何事件。
卧槽,这些概念我在别的地方也看到过呀,你这也不是就bibi一些概念吗,可是到底怎么用,用在哪里呢,这些概念表达的意思又到底是个啥呀?
咱们举个例子哈,在一个美好的早晨,一家子人都起来啦,打开门迎接美好的一天,突然天上掉下一个馅饼,还是金的,掉在你的祖爷爷面前。(金馅饼就是我们的事件),这时大家就聚在一起啊,你的祖爷爷(activity)辈分最大,馅饼也是掉他那的,先拥有这个馅饼的分配权,你的祖爷爷非常爱你们,他说这馅饼啊,我都不久于人世了用不着,留给我的宝贝儿子吧(ViewGroup),他儿子不就是你爷爷吗,你爷爷也爱你爸啊,就又给了你爸,你爸最后给了你,这时你就开心了,我拿到了这个金馅饼,那我是留着还是留着?这时你非常激动啊,你想着自己还没娶媳妇,你就说那恭敬不如从命了,你拿着馅饼娶了一个漂亮能干的媳妇(消费掉了事件,onTouchEvent返回true),那这个事情就结束了。当然,还有一种情况,你已经有漂亮媳妇了,不需要了,你觉得应该孝顺长辈,你又跟你爸说,我不用啊,这金饼给我也没用,我有大金链子,你身体不好自己拿着看大夫吧。然后你爸拿着一想孩子说的没错,就自己拿去治病了(消费掉了)。
一个馅饼由一次触摸事件的ACTION_DOWN开始,最先拿到的是Activity(Window),然后一层一层往下分发(dispatchTouchEvent),如果有谁需要拿到这个金馅饼干啥,他就拦截掉(onInterceptTouchEvent),那么备份最小的你(View)就根本摸不到这个金馅饼了,如果没有ViewGroup(比你辈分大的爷爷,爸爸等)拦截,都想给你娶媳妇,,那么你就拿到了这个金馅饼,先调用你的onTouchEvent事件,你确实不需要啊,然后又一层一层返回去,一层层调用onTouchEvent,看谁需要,大致就这么一个逻辑。
哇靠,你这么说我似乎优点明白了,但咱能不拿这个馅饼说事吗,我开发又不是写馅饼,能不拿举个别的例子啊。好的,客官你别急,这就给你上菜。
滑动冲突想必是在开发中老生常谈的问题了,只要我们内部View和外部View都能滑动,那么必定就会存在滑动冲突,我们想要处理的话,就需要用到我们的事件分发知识啦。而通常我们处理滑动冲突分为两种,分别叫做外部拦截法和内部拦截法,比如我们一个ViewPager中嵌套了一个RecycleView或ListView,滑动时非常的不爽,安卓并不知道是具体谁要处理这个事件,金饼就一块,我到底给谁啊,你们好几个人都要。那我肯定需要添加一些条件了,看看到底是给谁啊,外部拦截就是重写父容器的onInterceptTouchEvent()方法,因为这块金饼先到的还是长辈手里,这个时候你就要 处理好啊,我到底是留给自己处理ViewPager的左右滑动呢,还是处理ListView的上下滑动呀,你只需要比较在X轴和Y轴移动的距离,如果X轴大于Y,那就是左右滑动,就把这块金饼直接拦截掉消费了,就不给ListView了,如果X小于Y,那就是上下滑动了,你就不拦截,把金饼给ListView消费。
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;
}
mLastXIntercept = x;
mLastYIntercept = y;
return intercepted;}
以上是外部拦截法的模板代码,针对不同的滑动冲突,只需要修改父容器需要拦截当前事件这个条件即可,其他均不需做修改并且也不能修改,在onInterceptTouchEvent方法中,首先是ACTION_DOWN这个事件,父容器绝大部分情况下必须返回false,即不拦截ACTION_DOWN事件,这是因为一旦父容器拦截了ACTION_DOWN,那么后续的ACTION_MOVE和ACTION_UP事件都会直接交给父容器处理,这个时候事件没法再传递给子元素了;其次是ACTION_MOVE事件,这个事件可以根据需要来决定是否拦截,如果父容器需要拦截就返回true,否则返回false;最后是ACTION_UP事件,这里必须要返回false,因为ACTION_UP事件本身没有太多意义。
卧槽,那玩意我是一个ViewPager嵌套一个ViewPager呢,两个都是水平方向的滑动,这个我要怎么判断啊,这个.....这个貌似这种方式行不通吧,好像比较难啊,怎么去获取判断的条件啊,依据是什么呀。
不要怕不要惊慌啊,我们肯定是可以解决的。我们外部拦截法行不通有没有内部拦截法,自然是有的,内部拦截法其实就是重写子元素的dispatchTouchEvent()方法,并调用getParent().requestDisallowInterceptTouchEvent(true)父容器不能拦截子元素需要的事件。用我们的馅饼来说就是不管有多少长辈(ViewGroup父容器),馅饼都应该是先给你的(子元素),你拥有烧饼的最先处理权,如果你需要消费它那你就直接消费掉,不需要再交给父容器处理。但是我们事件dispatchTouchEvent是由父辈们一层一层分发下来的,万一哪个中间摆你一道,把馅饼拿去花掉了呢,为了预防这种情况,我们就需要配合getParent().requestDisallowInterceptTouchEvent(true)来事先通知他们不可以拦截。
首先是子元素的dispatchTouchEvent方法:
public boolean dispatchTouchEvent(MotionEvent event) {
...
switch (action) {
case MotionEvent.ACTION_MOVE:
getParent().requestDisallowInterceptTouchEvent(true);
break;
case MotionEvent.ACTION_MOVE:
if(父容器需要处理此事件)
getParent().requestDisallowInterceptTouchEvent(false);
break;
case MotionEvent.ACTION_UP: {
break;
}
...
return super.dispatchTouchEvent(event); }
这事件我们还要修改父容器的onInterceptTouchEvent()方法,代码如下:
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
int action=ev.getAction();
if(action==MotionEvent.ACTION_DOWN){
return false;
}else {
return true;
}
}
父容器拦截了除了DOWN事件以外的其他事件,这样当子元素调用parent.requestDisallowInterceptTouchEvent(false)方法时,父元素才能继续拦截所需的事件。当返回true时,不分发到子元素,并执行自己的onTouch方法。onInterceptTouchEvent()方法默认是不拦截的,所以我们需要考虑到,当子元素不处理时,我们需要父元素(外层ViewPager来处理),所以我们才会重写父容器的onInterceptTouchEvent方法。
现在相信大家对于安卓Touch事件有了一个相对还比较清晰的了解了,至少知道他们的一个事件流向,分发,拦截以及消费,这里在安卓开发探索一书中总结得特别好,大致如下:
1:同一个事件序列是指手机接触屏幕那一刻起,到离开屏幕那一刻结束,有一个down事件,若干个move事件,一个up事件构成。
2:某个View一旦决定拦截事件,那么这个事件序列之后的事件都会由它来处理,并且不会再调用onInterceptTouchEvent。
3:正常情况下,一个事件序列只能被一个View拦截并消耗。这个原因可以参考第2条,因为一旦拦截了某个事件,那么这个事件序列里的其他事件都会交给这个View来处理,所以同一事件序列中的事件不能分别由两个View同时处理,但是我们可以通过特殊手段做到,比如一个View将本该自己处理的事件通过onTouchEvent强行传递给其他View处理。
4:一个View如果开始处理事件,如果它不处理down事件(onTouchEvent里面返回了false),那么这个事件序列的其他事件就不会交给它来继续处理了,而是会交给它的父元素去处理。
5:如果一个View处理了down事件,却没有处理其他事件,那么这些事件不会交给父元素处理,并且这个View还能继续受到后续的事件。而这些未处理的事件,最终会交给Activity来处理。
6:ViewGroup的onInterceptToucheEvent默认返回false,也就是默认不拦截事件。
7:View没有InterceptTouchEvent方法,如果有事件传过来,就会直接调用onTouchEvent方法。
8:View的onTouchEvent方法默认都会消耗事件,也就是默认返回true,除非他是不可点击的(longClickable和clickable同时为false)。
9:View的enable属性不会影响onTouchEvent的默认返回值。就算一个View是不可见的,只要他是可点击的(clickable或者longClickable有一个为true),它的onTouchEvent默认返回值也是true。
10:onClick方法会执行的前提是当前View是可点击的,并且它收到了down和up事件。
11:事件传递过程是由外向内的,也就是事件会先传给父元素在向下传递给子元素。但是子元素可以通过requestDisallowInterceptTouchEvent来干预父元素的分发过程,但是down事件除外(因为down事件方法里,会清除所有的标志位)。
我们用伪代码表示一下分发,拦截和消费的关系:
public boolean dispatchTouchEvent(MotionEvent ev){
boolean consume = false;
if (onInterceptTouchEvent(ev)){
consume = onTouchEvent(ev);
}else {
consume = child.dispatchTouchEvent(ev);
}
return consume
}
如果以上知识,你基本了解清楚了,那么针对各种情况下的滑动冲突,你都能处理了,只是判断的逻辑不同而已。这个就需要自己多加练习了,最近我也会不断补强这一块的知识,在实践中不断成长。
那么本文到此就结束了吗,View的事件分发机制就是用来处理滑动冲突的吗,随便这已经很了不起了但他能做的远远不止这些。
当我们的设计师用想象力冲破天机的思维告诉你想要什么什么样的交互效果,我的内心是崩溃的
那么通常这样的交互效果需要我们时刻追踪着用户在屏幕上的一举一动,然后获取到用户操作的坐标,通过动画等产生特定的效果,让我们的用户有一个爽歪歪的交互体验。
这里我们就实现一个最简单的下拉头部图片放大,松手时自动回弹的ScrollView吧。
卧槽我也不知道为什么录制的gif这么小,反正大概就这样子。
拿到这样一个需求我们首先要分析他,解剖一下。
(1)下拉时头部变大
(2)松手后回弹,头部回复大小
那么这两个需求,可能用到哪些知识点来实现呢?
1:记录下拉的值,下拉越大,头部倍数越大,在哪里一直记录这个下拉值,自然就是我们今天学的咯,我们可以在dispatchTouchEvent或者onTouchEvent中获取到我们触摸点的坐标
2:View头部的变大和回弹,需要动画来达到一个流畅顺滑有弹性的效果,而我们的补间代码似乎无法满足此类要求,所以我们需要考虑用到一个ValueAnimator动画,通过改变View对象的属性来实现动画效果。
3:放大应该有一个最大倍数,不可能无限放大,那太难看了
4:自定义ScrollView的话,我们需要获取到ScrollView中的头部这个View
5:缩放的话我们应该通过设置头部LayoutParam改变
下面我就直接贴代码了,大家可以自己参考一下:
public class PullBackScrollView extends ScrollView {
private View mHeaderView;
private int mHeaderWidth;
private int mHeaderHeight;
// 是否正在下拉
private boolean mIsPulling;
private int mLastY;
// 最大的放大倍数
private float mScaleTimes = 2.0f;
// 滑动放大系数:系数越大,滑动时放大程度越大
private float mScaleRatio = 0.4f;
// 回弹时间系数:系数越小,回弹越快
private float mReplyRatio = 0.5f;
// 当前坐标值
private float currentX = 0;
private float currentY = 0;
// 移动坐标值
private float distanceX = 0;
private float distanceY = 0;
// 最后坐标值
private float lastX = 0;
private float lastY = 0;
// 上下滑动标记
private boolean upDownSlide = false;
public static final String TAG = "PullBackScrollView";
public PullBackScrollView(Context context) {
this(context, null);
}
public PullBackScrollView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public PullBackScrollView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
@Override
protected void onFinishInflate() {
super.onFinishInflate();
// 设置不可过度滚动,否则上移后下拉会出现部分空白的情况
setOverScrollMode(OVER_SCROLL_NEVER);
View child = getChildAt(0);
if (child != null && child instanceof ViewGroup) {
// 获取默认第一个子View
ViewGroup vg = (ViewGroup) getChildAt(0);
if (vg.getChildAt(0) != null) {
mHeaderView = vg.getChildAt(0);//此时headView为activity_header.xml中的RelativeLayout
}
}
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
mHeaderWidth = mHeaderView.getMeasuredWidth();
mHeaderHeight = mHeaderView.getMeasuredHeight();
}
//重写事件分发
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
currentX = ev.getX();
currentY = ev.getY();
switch (ev.getAction()) {
case MotionEvent.ACTION_MOVE:
distanceX = currentX - lastX;
distanceY = currentY - lastY;
if (Math.abs(distanceX) < Math.abs(distanceY) && Math.abs(distanceY) > 12) {
upDownSlide = true;
}
break;
}
lastX = currentX;
lastY = currentY;
if (upDownSlide && mHeaderView != null) {
commOnTouchEvent(ev);
}
return super.dispatchTouchEvent(ev);
}
/**
* @Description 触摸事件
*/
private void commOnTouchEvent(MotionEvent ev) {
switch (ev.getAction()) {
case MotionEvent.ACTION_UP:
// 手指离开后头部恢复图片
mIsPulling = false;
replyView();
clear();
break;
case MotionEvent.ACTION_MOVE:
if (!mIsPulling) {
// 第一次下拉
if (getScrollY() == 0) {
// 滚动到顶部时记录位置,否则正常返回
mLastY = (int) ev.getY();
} else {
break;
}
}
int distance = (int) ((ev.getY() - mLastY) * mScaleRatio);
// 当前位置比记录位置要小时正常返回
if (distance < 0) {
break;
}
mIsPulling = true;
setZoom(distance);
break;
}
}
/**
* @Description 头部缩放
*/
private void setZoom(float s) {
float scaleTimes = (float) ((mHeaderWidth + s) / (mHeaderWidth * 1.0));
// 如超过最大放大倍数则直接返回
if (scaleTimes > mScaleTimes) {
return;
}
ViewGroup.LayoutParams layoutParams = mHeaderView.getLayoutParams();
layoutParams.width = (int) (mHeaderWidth + s);
layoutParams.height = (int) (mHeaderHeight * ((mHeaderWidth + s) / mHeaderWidth));
// 设置控件水平居中
((MarginLayoutParams) layoutParams).setMargins(-(layoutParams.width - mHeaderWidth) / 2, 0, 0, 0);
mHeaderView.setLayoutParams(layoutParams);
}
/**
* @Description 回弹动画
*/
private void replyView() {
final float distance = mHeaderView.getMeasuredWidth() - mHeaderWidth;
// 设置动画
ValueAnimator anim = ObjectAnimator.ofFloat(distance, 0.0F).setDuration((long) (distance * mReplyRatio));
anim.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
setZoom((Float) animation.getAnimatedValue());
}
});
anim.start();
}
/**
* @Description 清除属性值
*/
private void clear() {
lastX = 0;
lastY = 0;
distanceX = 0;
distanceY = 0;
upDownSlide = false;
}}
在xml中引用我们的控件:
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:context="com.example.pz.zoomscrollview.MainActivity">
<com.example.pz.zoomscrollview.PullBackScrollView
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<include layout="@layout/activity_header" />
<include layout="@layout/activity_content" />
</LinearLayout>
</com.example.pz.zoomscrollview.PullBackScrollView></LinearLayout>
xml中的代码我就不全部贴出来了,自己随便写点啥都可以,喜欢美女的放张美女背景图,喜欢跑车的放跑车。
按道理来说Activity代码是什么都不需要写的,你在xml中引用了自定义就好了,这里的话为了视觉效果,实现了一下沉浸式状态,就也贴出来参考一下实现。
public class MainActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
fullScreen(this);
}
private void fullScreen(Activity activity) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
//5.x开始需要把颜色设置透明,否则导航栏会呈现系统默认的浅灰色
Window window = activity.getWindow();
View decorView = window.getDecorView();
//两个 flag 要结合使用,表示让应用的主体内容占用系统状态栏的空间
int option = View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
| View.SYSTEM_UI_FLAG_LAYOUT_STABLE;
decorView.setSystemUiVisibility(option);
window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS);
window.setStatusBarColor(Color.TRANSPARENT);
//导航栏颜色也可以正常设置
// window.setNavigationBarColor(Color.TRANSPARENT);
} else {
Window window = activity.getWindow();
WindowManager.LayoutParams attributes = window.getAttributes();
int flagTranslucentStatus = WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS;
int flagTranslucentNavigation = WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION;
attributes.flags |= flagTranslucentStatus;
// attributes.flags |= flagTranslucentNavigation;
window.setAttributes(attributes);
}
}
}}
本文可能有一些知识点并未阐述得特别详细,由于本人水平有限,也会在最近一段时间不短的学习相关知识以及更新一些相关文章,大家一起成长。