Android的事件传递可谓比较重要,不管是自定义控件以及项目中还是面试中都会用到,在这个大家都爱开源的时代相信网上肯定有不少关于这方便的文章了,但是本着知行合一的原则还是决定自己折腾下。
源码地址https://github.com/Hemumu/HlRefreshLayout 求star
通过自定义一个通用的下拉刷新控件来重温下Android的事件传递,为什么说是通用的呢?因为他可以包含AbsListView
的子类以及ScrollView
和它的子类甚至一个TextView
前言
首先我们通过一张图来大概了解下Android中的事件传递。图片来自图解 Android 事件分发机制
图片中的事件传递是从左往右从上至下的传递,上中下层分别为Activity
,ViewGroup
,View
。箭头的上面字代表方法返回值。dispatchTouchEvent
和 onTouchEvent
的框里有个【true---->消费】的字,表示的意思是如果方法返回true,那么代表事件就此消费,不会继续往别的地方传了,事件终止。从图中可以看出如果事件不被中段那么事件是按照一个U型图来走的。整个事件流向应该是从Activity
---->ViewGroup
--->View
从上往下调用dispatchTouchEvent
方法,一直到叶子节点(View
)的时候,再由View
--->ViewGroup
--->Activity
从下往上调用onTouchEvent
方法。如果dispatchTouchEvent
和onTouchEvent
返回true
即消费事件,那么事件就终止了谁也不会在收到这个事件。
自定义下拉刷新控件
首页下拉刷新有一个头布局和一个内容布局那么他肯定是一个ViewGroup
我们新建一个类RefreshLayout
继承FrameLayout
。这个类就是我们核心类了,重写dispatchTouchEvent
我们就可以控制事件的分发。
首先我们来看初始化方法
private void init() {
//使用isInEditMode解决可视化编辑器无法识别自定义控件的问题
if (isInEditMode()) {
return;
}
if (getChildCount() > 1) {
throw new RuntimeException("只能拥有一个子控件");
}
//在动画开始的地方快然后慢;
decelerateInterpolator = new DecelerateInterpolator(10);
}
初始化中已经做了很详细的注释了就不多解释了。接下来我们在重写onAttachedToWindow
方法
@Override
protected void onAttachedToWindow() {
super.onAttachedToWindow();
FrameLayout headViewLayout = new FrameLayout(getContext());
LayoutParams layoutParams = new LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0);
layoutParams.gravity = Gravity.TOP;
headViewLayout.setLayoutParams(layoutParams);
mHeadLayout = headViewLayout;
this.addView(mHeadLayout);
//获得子控件
mChildView = getChildAt(0);
if (mChildView == null) {
return;
}
mChildView.animate().setInterpolator(new DecelerateInterpolator());//设置速率为递减
mChildView.animate().setUpdateListener(//通过addUpdateListener()方法来添加一个动画的监听器
new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
int height = (int) mChildView.getTranslationY();//获得mChildView当前y的位置
mHeadLayout.getLayoutParams().height = height;
mHeadLayout.requestLayout();//重绘
}
}
);
}
新建了一个FrameLayout
并且添加到了父容器中,这个FrameLayout
就是我们头布局,我们可以通过添加布局到FrameLayout
来自定义刷新的head。方法如下
/**
* 添加头部vieww
*
* @param header
*/
public void addHeadView(View header) {
mHeadLayout.addView(header);
}
接着为ChildView
添加了动画的插值器为递减并且添加了动画的更新时间,这里是当用户下拉之后手指松开后ChildView
按照递减的动画回到顶部,并且head的高度随之改变。
在下拉刷新的时候必要的一个环节就是判断控件是否滑动到顶部,如果滑动到顶部就显示head否则就将事件交给ChildView
去处理。添加一个方法来判断控件是否下拉到顶部
/**
* 用来判断是否可以下拉
*
* @return boolean
*/
public boolean canChildScrollUp() {
if (mChildView == null) {
return false;
}
if (Build.VERSION.SDK_INT < 14) {
if (mChildView instanceof AbsListView) {
final AbsListView absListView = (AbsListView) mChildView;
return absListView.getChildCount() > 0
&& (absListView.getFirstVisiblePosition() > 0 || absListView.getChildAt(0)
.getTop() < absListView.getPaddingTop());
} else {
return mChildView.getScrollY() > 0;
}
} else {
return ViewCompat.canScrollVertically(mChildView, -1);
}
}
在API14以后ViewCompat
中有一个方法canScrollVertically
检测一个 View 在给定的方向(up or down)能否竖直滑动,负数表示检测上滑,正数表示下滑。而在API14以下我们就得自己背锅了,AbsListView
通过getFirstVisiblePosition
是否为0或者距离顶部的位置来判断,其他则通过getScrollY()
来判断了。
接下来我们看最重要的dispatchTouchEvent
事件的分发直接上代码
/**
* 控件在顶端时最后的Y坐标
*/
float mLastY;
/**
* 头部移动的距离
*/
private float mCurrentPos;
@Override
public boolean dispatchTouchEvent(MotionEvent e) {
//当前正在刷新
if (isRefreshing) {
return super.dispatchTouchEvent(e);
}
switch (e.getAction()) {
case MotionEvent.ACTION_DOWN:
mLastY = e.getY();
mCurrentPos = 0;
break;
case MotionEvent.ACTION_MOVE:
float currentY = e.getY();
float dy = currentY - mLastY;//手指移动的距离
//上拉
if (mLastY - currentY > 0) {//最后的Y坐标大于当前的Y坐标
if (mCurrentPos != 0) {
mCurrentPos = dy * mResistance;
mCurrentPos = Math.max(0, mCurrentPos);
changeView(mCurrentPos);
} else {
return super.dispatchTouchEvent(e);
}
//下拉
} else {
if (!canChildScrollUp()) { //是否滑动到顶部
mCurrentPos = dy * mResistance;
mCurrentPos = Math.max(0, mCurrentPos);
changeView(mCurrentPos);
} else {
mLastY = e.getY();
return super.dispatchTouchEvent(e);
}
}
return true;
case MotionEvent.ACTION_CANCEL:
case MotionEvent.ACTION_UP:
if (mChildView != null) {
if (mChildView.getTranslationY() >= mHeadHeight) {//手指松开后head达到刷新高度
mChildView.animate().translationY(mHeadHeight).start();
pullToRefreshListener.onRefresh(this);
isRefreshing = true;
} else if (mChildView.getTranslationY() > 0)
mChildView.animate().translationY(0).start();
}
}
return super.dispatchTouchEvent(e);
}
/**
* 改变ChildView和head的高度
* @param pos
*/
private void changeView(float pos ){
mChildView.setTranslationY(pos);
mHeadLayout.getLayoutParams().height = (int) (mCurrentPos);
mHeadLayout.requestLayout();
}
代码没有优化请见谅(实在太懒),从上往下看,首先在ACTION_DOWN
的是适合我们记录下了当前按下的Y坐标,重置了head移动的距离。在ACTION_MOVE
中分两种情况
-
ACTION_MOVE
的方向向下,canChildScrollUp()
返回值为true
,则可以移动,header
和ChildView
向下移动,否则,事件交由父类处理。 -
ACTION_MOVE
的方向向上,如果当前位置大于起始位置,则可以移动,Header
和ChildView
向上移动,否则,事件交由父类处理。
这里我们用一个mCurrentPos
字段来记录当前header
的移动距离。当header
和ChildView
可以移动的时候我们默认返回了true
也就是消费了事件,事件将不再传递。ChildView
是可以滑动的控件他们就将拿到事件也就不会在滑动。反之我们调用了super.dispatchTouchEvent(e)
上面为我们说了这个方法就是让事件继续向下传递。
这里我们提供一个完成刷新的方法finishRefreshing
/**
* 刷新结束
*/
public void finishRefreshing() {
if (mChildView != null) {
mChildView.animate().translationY(0).start();
}
isRefreshing = false;
}
接着新建一个SunshineRefresh
继承我们刚写的RefreshLayout
。
public class SunshineRefresh extends RefreshLayout {
public SunshineRefresh(Context context) {
super(context);
initSunshine();
}
public SunshineRefresh(Context context, AttributeSet attrs) {
super(context, attrs);
initSunshine();
}
public SunshineRefresh(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
initSunshine();
}
private void initSunshine() {
setHeadHeight(DensityUtil.dip2px(getContext(), 80));
final View headerView = LayoutInflater.from(getContext()).inflate(R.layout.view_header, null);
final SunshineView sunshineView = (SunshineView) headerView.findViewById(R.id.sunshine);
addHeadView(headerView);
setPullToRefreshListener(new PullToRefreshListener() {
@Override
public void onRefresh(RefreshLayout refreshLayout) {
sunshineView.startAnim();
sunshineView.postDelayed(new Thread(){
@Override
public void run() {
finishRefreshing();
sunshineView.stopAnim();
}
},2000);
}
});
}
}
初始化了一个自定义view也就是我们的header
并把他添加到RefreshLayout
中通过回调方法去开始header
的刷新动画。2秒后停止动画并调用finishRefreshing()
完成刷新。
控件就基本完成了,添加一个ListView
看下效果
布局
<com.helin.hlrefreshlayout.view.SunshineRefresh
android:id="@+id/refresh_layout"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ListView
android:id="@+id/listview"
android:layout_width="match_parent"
android:layout_height="match_parent"></ListView>
</com.helin.hlrefreshlayout.view.SunshineRefresh>
哎哟,不错哦!这时候有好(搞)奇(事)的又说了不是可以刷新任何控件么?好吧,我们添加一个TextView
让你见识一下哥的厉害!
<com.helin.hlrefreshlayout.view.SunshineRefresh
android:id="@+id/refresh_layout"
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:text="听说有人要搞事!!!" />
</com.helin.hlrefreshlayout.view.SunshineRefresh>
你会发现怎么下拉都没有反应,搞事啊兄弟!既然你诚心诚意的发问了,那我也就老老实实的来解决了。
分析问题原因
既然它没有下拉刷新说明dispatchTouchEvent
中的代码根本就没有执行!我们在dispatchTouchEvent
中打印日志会发现只有ACTION_DOWN
的时候进入了dispatchTouchEvent
,ACTION_MOVE
和ACTION_UP
时候并没有到dispatchTouchEvent
中。那么说明ACTION_DOWN
和ACTION_MOVE
,ACTION_UP
事件传递的路径不是一样的。
那么我们来设置TextView
的OnTouchListener
看看事件是怎么传递的
TextView te = (TextView) findViewById(R.id.textview);
te.setOnTouchListener(new View.OnTouchListener() {
@Override
public boolean onTouch(View v, MotionEvent event) {
switch (event.getAction()){
case MotionEvent.ACTION_DOWN:
Log.e("MainActivity","ACTION_DOWN");
break;
case MotionEvent.ACTION_MOVE:
Log.e("MainActivity","ACTION_MOVE");
break;
case MotionEvent.ACTION_UP:
Log.e("MainActivity","ACTION_UP");
break;
}
return false;
}
});
运行后我们在TextView
上拖动发现打印日志如下
11-24 17:55:59.829 28531-28531/com.helin.hlrefreshlayout E/MainActivity: ACTION_DOWN
发现只有ACTION_DOWN
事件传递了下来。这样根本找不到问题所在,我们新建一个类继承TextView
在重写他的dispatchTouchEvent
和onTouchEvent
看一看事件是怎么传递的。
public class TestVieww extends TextView {
public TestVieww(Context context) {
super(context);
}
public TestVieww(Context context, AttributeSet attrs) {
super(context, attrs);
}
public TestVieww(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
@Override
public boolean dispatchTouchEvent(MotionEvent event) {
switch (event.getAction()){
case MotionEvent.ACTION_DOWN:
Log.e("dispatchTouchEvent","ACTION_DOWN------");
case MotionEvent.ACTION_MOVE:
Log.e("dispatchTouchEvent","ACTION_MOVE------");
break;
}
return true;
}
@Override
public boolean onTouchEvent(MotionEvent event) {
Log.e("onTouchEvent","onTouchEvent------");
switch (event.getAction()){
case MotionEvent.ACTION_DOWN:
Log.e("onTouchEvent","ACTION_DOWN------");
case MotionEvent.ACTION_MOVE:
Log.e("onTouchEvent","ACTION_MOVE------");
break;
}
return super.onTouchEvent(event);
}
}
我们在dispatchTouchEvent
中拦截了事件,然后我们在TextView
中拖动发现日志打印如下
11-24 18:06:45.128 10487-10487/com.helin.hlrefreshlayout E/dispatchTouchEvent: ACTION_DOWN------
11-24 18:06:45.138 10487-10487/com.helin.hlrefreshlayout E/dispatchTouchEvent: ACTION_MOVE------
11-24 18:06:45.158 10487-10487/com.helin.hlrefreshlayout E/dispatchTouchEvent: ACTION_MOVE------
11-24 18:06:45.168 10487-10487/com.helin.hlrefreshlayout E/dispatchTouchEvent: ACTION_MOVE------
发现dispatchTouchEvent
拦截到了ACTION_MOVE
事件,如果我们在中onTouchEvent
返回true
也就是拦截事件呢?我们来看看日志
11-24 18:08:45.316 12904-12904/com.helin.hlrefreshlayout E/dispatchTouchEvent: ACTION_DOWN------
11-24 18:08:45.326 12904-12904/com.helin.hlrefreshlayout E/dispatchTouchEvent: ACTION_MOVE------
11-24 18:08:45.326 12904-12904/com.helin.hlrefreshlayout E/onTouchEvent: onTouchEvent------
11-24 18:08:45.326 12904-12904/com.helin.hlrefreshlayout E/onTouchEvent: ACTION_DOWN------
11-24 18:08:45.326 12904-12904/com.helin.hlrefreshlayout E/onTouchEvent: ACTION_MOVE------
11-24 18:08:45.386 12904-12904/com.helin.hlrefreshlayout E/dispatchTouchEvent: ACTION_MOVE------
11-24 18:08:45.386 12904-12904/com.helin.hlrefreshlayout E/onTouchEvent: onTouchEvent------
11-24 18:08:45.386 12904-12904/com.helin.hlrefreshlayout E/onTouchEvent: ACTION_MOVE------
可以看到部分日志如上所示。可以看到都拦截都了ACTION_MOVE
事件,现在我们把自定义的这TextView
放入我们刚自定的下拉刷新控件中,并且在TextView
中dispatchTouchEvent
的拦截事件。运行如何呢?
<com.helin.hlrefreshlayout.view.SunshineRefresh
android:id="@+id/refresh_layout"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.helin.hlrefreshlayout.view.TestView
android:id="@+id/textview"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:text="听说有人要搞事!!!"
android:padding="10dp" />
</com.helin.hlrefreshlayout.view.SunshineRefresh>
这不就成了么?听说你要搞事。既然能下拉刷新说明我们自定义控件的dispatchTouchEvent
拿到了ACTION_MOVE
,ACTION_UP
事件,这也就说明TextView
中没有消费事件,默认的它只有ACTION_DOWN
这也就是我为什么上面的setOnTouchListener()
事件中只能拿到ACTION_DOWN
,那怎么才能让TextView
自己消费事件呢?
查看TextView
发现它没有重写dispatchTouchEvent
,那就去它的父类View
去找,果然在里面找到了。怎么能才能让View去消费事件呢?最后去看这个View
源码真的是看的我身体不适,浑身难受,具体过程我就不吐槽了,最后发现View
的方法setClickable(),默认像TextView
这些控件的isClickable()
方法是返回false的,也就是不会去处理ACTION_MOVE
,ACTION_UP
事件,换句话说就是不去消费这个事件。知道这个就简单了啊,我们只需要一行代码就可以解决这个问题了
mChildView.setClickable(true);
在RefreshLayout
中拿到ChildView
的时候设置他去消费这个事件,那么我们下拉刷新控件里面就能拿到ACTION_MOVE
,ACTION_UP
事件了,
<com.helin.hlrefreshlayout.view.SunshineRefresh
android:id="@+id/refresh_layout"
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:id="@+id/textview"
android:layout_width="match_parent"
android:gravity="center"
android:layout_height="match_parent"
android:text="这下满意了吧"
android:padding="10dp" />
</com.helin.hlrefreshlayout.view.SunshineRefresh>
ImageView
<com.helin.hlrefreshlayout.view.SunshineRefresh
android:id="@+id/refresh_layout"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ImageView
android:id="@+id/textview"
android:layout_width="match_parent"
android:gravity="center"
android:layout_height="match_parent"
android:src="@mipmap/ic_launcher"
android:padding="10dp" />
</com.helin.hlrefreshlayout.view.SunshineRefresh>
其实下拉刷新也没有那么难嘛!
总结
ACTION_MOVE
,ACTION_UP
事件它们的传递过程如下
- 在哪个View的
onTouchEvent
返回true,或者dispatchTouchEvent
返回true,那么ACTION_MOVE
和ACTION_UP
的事件从上往下传到这个View后就不再往下传递了,当然父View也将收到。在onTouchEvent
中消费那么直接传给自己和父View的dispatchTouchEvent
,onTouchEvent
。在dispatchTouchEvent
消费就只传递给dispatchTouchEvent
和父View的dispatchTouchEvent
,onTouchEvent
。并结束本次事件传递过程。 如果没有任何View消费事件那么ACTION_MOVE
,ACTION_UP
将不会向下传递 。
ACTION_DOWN
事件则是遵循文首中的图片传递流程,事件走到哪那么ACTION_DOWN
就会传递到哪。
最后希望大家都有这种“搞事”的精神,不懂就问,看资料。不行就试,一次不行两次三次。知行合一!有什么不对的地方希望大家多指教。