Android View的事件分发机制
文章目录
- 1. 使View滑动
- 1.1 View基本知识
- 1.2 使用scrollTo/scrollBy
- 1.3 使用动画来移动View
- 1.4 修改layoutParams来移动View
- 2. View的弹性滑动
- 2.1 Scroller
- 2.2使用动画
- 2. 3 使用延时策略
- View的事件分发机制
- 事件分发源码解析
- 1.Activty事件分发的过程
- 2.ViewGroup事件分发
- 3.View的事件分发
1. 使View滑动
1.1 View基本知识
x并不是等于left,x与left的关系如下
- x = left + translationX
- y = top + translationY
1.2 使用scrollTo/scrollBy
此方法移动的是VIew的内容,并非VIew的实际位置
protected int mScrollX;protected int mScrollY;
将view像左移动100像素,向上移动30像素,scrollTo的移动方向和我们熟知的x轴和y轴方向是相反的
mTextView.scrollTo(100, 30)
scrollBy本质来说也调用的是scrollTo,scrollTo是绝对移动,scrollBy是相对移动
1.3 使用动画来移动View
这种方式改变的是View的以下属性
translationX 和 translationY
public float getTranslationX() {return mRenderNode.getTranslationX();}
使用ObjectAnimator来移动View
ObjectAnimator.ofFloat(animaTextView, "translationX", a, 100f).apply {duration = 100start()}
1.4 修改layoutParams来移动View
val marginLayoutParams = (layoutView.layoutParams as MarginLayoutParams)
marginLayoutParams.marginStart = 300
layoutView.layoutParams = marginLayoutParams
2. View的弹性滑动
如上1.View的滑动所示,这样移动VIew,有点像闪现,没有移动的过程,在现实使用中并不好看,所以为了有更好的移动效果,需要使用弹性滑动。
实现弹性滑动的方式也有三种,Scroller, 属性动画, 使用延时策略
2.1 Scroller
比如使用Scroller去弹性滑动移动一个TestView
private lateinit var scrollView: TextViewprivate lateinit var mScroller: Scrolleroverride fun onCreate(savedInstanceState: Bundle?){val scrollBtn: Button = findViewById(R.id.smooth_scroller_btn);scrollView = findViewById(R.id.ScrollerText)scrollBtn.setOnClickListener {scrollView.setScroller(mScroller)smoothScrollerTo(-200, 0)}Log.d(TAG, "btn.scrollX: " + btn.scrollX + " btn.scrollY: " + btn.scrollY)}private fun smoothScrollerTo(destX: Int, destY: Int) {val curX = scrollView.scrollXval deltaX = destX - curXmScroller.startScroll(curX, 0, deltaX, 0, 2000)scrollView.invalidate()}
Scroller弹性滑动原理
- 首先看看Scroller.startScroll方法, 这个方法并不是开始弹性滑动,仅仅是对进行弹性滑动所需要的参数进行赋值
public void startScroll(int startX, int startY, int dx, int dy, int duration) {mMode = SCROLL_MODE;mFinished = false;mDuration = duration;mStartTime = AnimationUtils.currentAnimationTimeMillis();mStartX = startX;mStartY = startY;mFinalX = startX + dx;mFinalY = startY + dy;mDeltaX = dx;mDeltaY = dy;mDurationReciprocal = 1.0f / (float) mDuration;}
2.真正开始弹性滑动的方法为scrollView.invalidate
其中最终要的是computeScroll方法, 以TextView的为例
@Overridepublic void computeScroll() {if (mScroller != null) {if (mScroller.computeScrollOffset()) {mScrollX = mScroller.getCurrX();mScrollY = mScroller.getCurrY();invalidateParentCaches();postInvalidate(); // 再一次重绘View}}}
大致逻辑就是不断地改变mScrollX和mScroll,然后重新drawView来实现弹性滑动,可以间接理解成不断的去调用scrollTo方法
那不断地drawView什么时候停止?
这就需要看mScroller.computeScrollOffset()方法,这个方法主要做了如下几个事情
public boolean computeScrollOffset() {//如果动画已经完成,return false,从而中断computeScroller方法的调用if (mFinished) {return false;}//计算这个动画经过的时间int timePassed = (int)(AnimationUtils.currentAnimationTimeMillis() - mStartTime);//如果这个动画已经经过您设定的时间if (timePassed < mDuration) {switch (mMode) {//这个模式是默认模式,就是普通的滑动case SCROLL_MODE:final float x = mInterpolator.getInterpolation(timePassed * mDurationReciprocal);mCurrX = mStartX + Math.round(x * mDeltaX);mCurrY = mStartY + Math.round(x * mDeltaY);break;//这种模式滑动后,会会滑翔一小段再停止case FLING_MODE:final float t = (float) timePassed / mDuration;final int index = (int) (NB_SAMPLES * t);float distanceCoef = 1.f;float velocityCoef = 0.f;if (index < NB_SAMPLES) {final float t_inf = (float) index / NB_SAMPLES;final float t_sup = (float) (index + 1) / NB_SAMPLES;final float d_inf = SPLINE_POSITION[index];final float d_sup = SPLINE_POSITION[index + 1];velocityCoef = (d_sup - d_inf) / (t_sup - t_inf);distanceCoef = d_inf + (t - t_inf) * velocityCoef;}mCurrVelocity = velocityCoef * mDistance / mDuration * 1000.0f;mCurrX = mStartX + Math.round(distanceCoef * (mFinalX - mStartX));// Pin to mMinX <= mCurrX <= mMaxXmCurrX = Math.min(mCurrX, mMaxX);mCurrX = Math.max(mCurrX, mMinX);mCurrY = mStartY + Math.round(distanceCoef * (mFinalY - mStartY));// Pin to mMinY <= mCurrY <= mMaxYmCurrY = Math.min(mCurrY, mMaxY);mCurrY = Math.max(mCurrY, mMinY);if (mCurrX == mFinalX && mCurrY == mFinalY) {mFinished = true;}break;}}else {//动画完成mCurrX = mFinalX;mCurrY = mFinalY;mFinished = true;}return true;}
2.2使用动画
像上面有使用ObjectAnimator去实现完全是可以的,这里我们换种方式,使用ValueAnimator去实现
val valueAnimatorBtn: Button = findViewById(R.id.smooth_animter_btn);val valueAnimatorTextView: TextView = findViewById(R.id.smooth_text_btn)val startX = 0val deltaX = 400valueAnimatorBtn.setOnClickListener {ValueAnimator.ofInt(0, 1).apply {duration = 2000addUpdateListener {val fraction = it.animatedFractionvalueAnimatorTextView.scrollTo(-(startX + (deltaX * fraction).toInt()), 0)}start()}}
2. 3 使用延时策略
/** 假设需要1000ms 移动 300px, 每次移动30像素* 总共要移动 300 / 30 = 10次 delay时间 1000 / 10 = 100ms* */private var count = 0private val mHandler = object : Handler(Looper.getMainLooper()) {override fun handleMessage(msg: Message) {count ++when (msg.what) {1 -> {count++if (count <= 10) {handleView.scrollBy(-30, 0)sendEmptyMessageDelayed(1, 100)}}else -> {}}}}
View的事件分发机制
MotionEvent是对应点击事件的封装类,常见的点击事件是down、up、move,分别对应按下,抬起,移动
如何把一个点击事件分发给某一个特定的VIew,这就需要了解Android的事件分发机制。
点击事件分发机制由3个重要方法组成
public boolean dispatchTouchEvent(MotionEvent ev)
//onInterceptTouchEvent方法存在于ViewGroup中,View没有
public boolean onInterceptTouchEvent(MotionEvent ev)
public boolean onTouchEvent(MotionEvent event)
这三个方法的关系是
通过dispatchTouchEvent方法去给VIew分发事件,VIew收到事件后再判断是否拦截该事件,如果要拦截,这个事件才真正的被这个View所消费,否则就将这个事件分发给子View
除了上述三个方法外,还有一个比较重要的Listener,OnTouchListener,可以通过View的setOnTouchListener设置
public interface OnTouchListener {boolean onTouch(View v, MotionEvent event);}
OnTouchListener的优先级比onTouchEvent高,如果onTouch的方法返回true。代表OnTouchListenr消费这个事件,即该View的onTouchEvent不会被调用,因为onClickListener再OnTouchEvent中被调用,所以该View的点击事件也会失效
最终的事件分发的总体框架如下图所示
关于View的事件分发有如下几个结论
- 从手指按压屏幕,移动,再到抬起,被称作一个事件序列
- 正常情况下,一个事件序列只能被同一个View所消费
- 一个ViewGroup一但决定拦截(onInterceptTouchEvent返回ture),那么这一个事件序列都有它来处理
- 某个VIew如果不消耗down事件,那么一个事件序列中的其他事件也不会让他来处理
- ViewGroup默认不消耗任何事件,因为它的onInterceptTouchEvent方法默认返回false
- View没有onInterceptTouchEvent方法,默认都消耗事件,除非View不可点击(clickable, longClickable同时为false)
- View的enbale不会改变onTouchEvent的返回值
事件分发源码解析
1.Activty事件分发的过程
当一个事件出现时,会以以下的顺序进行传递,Activity–> window ->顶层View(DecorView)
decorView是顶层View,我们Activity通过setContentView方法设置的VIew最终会设置到DecorView的子元素上(android.R.id.content),DecorVIew的大致结构如下
事件最终流传到了ViewGroup的dispatchTouchEvent方法中,接下来重点看看这个方法
2.ViewGroup事件分发
先看ViewGrop如何判断是否拦截某个事件
// Handle an initial down.if (actionMasked == MotionEvent.ACTION_DOWN) {// Throw away all previous state when starting a new touch gesture.// The framework may have dropped the up or cancel event for the previous gesture// due to an app switch, ANR, or some other state change.cancelAndClearTouchTargets(ev);resetTouchState();}final boolean intercepted;//如果是down事件 或者 有子元素处理事件时if (actionMasked == MotionEvent.ACTION_DOWN|| mFirstTouchTarget != null) {final boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0;if (!disallowIntercept) {intercepted = onInterceptTouchEvent(ev);ev.setAction(action); // restore action in case it was changed} else {intercepted = false;}} else {// There are no touch targets and this action is not an initial down// so this view group continues to intercept touches.intercepted = true;}
一个ViewGroup是否需要通过onInterceptTouchEvent方法来判断是否拦截事件,取决于两个因素,并且必须都满足
- 是否是down事件 || 或者有子View接受事件,mFirstTouchTarget 是接受事件的子View的引用
actionMasked == MotionEvent.ACTION_DOWN || mFirstTouchTarget != null
- FLAG_DISALLOW_INTERCEPT标记位,如果没有这个标记位,才会调用onInterceptTouchEvent方法
这个标记位可通过requestDisallowInterceptTouchEvent方法却设置,并且绝对不会影响down事件,因为resetTouchState方法会把这个标记位抹除
final boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0;
结论1:如果VIewGroup拦截down事件,那么其他事件也会被VIewGroup拦截,并且onInterceptTouchEvent方法只会再down事件判断一次,其他事件序列将不用onInterceptTouchEvent再次判断 原因如下:
接下来看看如果ViewGroup不拦截事件,事件是如何分发到子VIew的
if (actionMasked == MotionEvent.ACTION_DOWN|| (split && actionMasked == MotionEvent.ACTION_POINTER_DOWN)|| actionMasked == MotionEvent.ACTION_HOVER_MOVE) {//此处省略一些源码for (int i = childrenCount - 1; i >= 0; i--) {//此处省略一些源码if (!child.canReceivePointerEvents()|| !isTransformedTouchPointInView(x, y, child, null)) {ev.setTargetAccessibilityFocus(false);continue;}//此处省略一些源码 }}
首先如上源码所示,在down事件时,遍历所有的子元素,判断子View是否有资格被分发事件,判断条件有2条,并且条件是或的关系
- 通过canReceivePointerEvents方法去判断 View是VISIBLE,或者正在播放动画
/*** Returns whether this view can receive pointer events.* @return {@code true} if this view can receive pointer events.* @hide*/protected boolean canReceivePointerEvents() {return (mViewFlags & VISIBILITY_MASK) == VISIBLE || getAnimation() != null;}
- 通过isTransformedTouchPointInView方法去判断 点击的是否是这个View的区域
这个方法就不详细讲了,大致实现方式就是通过的点击的x和y坐标结合View的长宽和位置去判断
判断完子View是否有资格被分发事件后就会走到下面的逻辑
if (dispatchTransformedTouchEvent(ev, false, child, idBitsToAssign)) {// Child wants to receive touch within its bounds.mLastTouchDownTime = ev.getDownTime();if (preorderedList != null) {// childIndex points into presorted list, find original indexfor (int j = 0; j < childrenCount; j++) {if (children[childIndex] == mChildren[j]) {mLastTouchDownIndex = j;break;}}} else {mLastTouchDownIndex = childIndex;}mLastTouchDownX = ev.getX();mLastTouchDownY = ev.getY();newTouchTarget = addTouchTarget(child, idBitsToAssign);alreadyDispatchedToNewTouchTarget = true;break;
}
如上源码所示其中最关键就是dispatchTransformedTouchEvent方法,在这个方法中回去调用子View的dispatchTouchEvent方法,大致伪代码如下所示
//如果子View为null,就会调用父类(View)的dispatchTouchEvent方法if (child == null) {handled = super.dispatchTouchEvent(event);} else {//将事件分发给子Viewhandled = child.dispatchTouchEvent(event);}
再刚开始分析源码时提到了mFirstTouchTarget成员,他会影响ViewGroup是否拦截某个事件的判断,该成员的赋值时机就在addTouchTarget方法中
private TouchTarget addTouchTarget(@NonNull View child, int pointerIdBits) {final TouchTarget target = TouchTarget.obtain(child, pointerIdBits);target.next = mFirstTouchTarget;mFirstTouchTarget = target;return target;}
根据衣裳代码会发现mFirstTouchTarget 本质是一个链表头,连接着每个被分发事件的子VIew,并采用头插法插入新元素,所以之前才会说mFirstTouchTarget != null代表着已经有事件成功分发给的子元素。当一个事件序列被分发完mFirstTouchTarget 链表就会被清空
if (canceled|| actionMasked == MotionEvent.ACTION_UP|| actionMasked == MotionEvent.ACTION_HOVER_MOVE) {//该方法会便利链表全部置空resetTouchState();
当然也会有这样一种情况,如果将事件分发给子元素,但是子元素也不消耗这个事件,即onTouchEvent方法返回false,如果时这种情况mFirstTouchTarget还是为null的状态,就会走到如下的逻辑中
if (mFirstTouchTarget == null) {// No touch targets so treat this as an ordinary view.handled = dispatchTransformedTouchEvent(ev, canceled, null,TouchTarget.ALL_POINTER_IDS);}
dispatchTransformedTouchEvent方法的child参数为空,上面也分析过这个方法,如果child==null,就会调用super.dispatchTouchEvent方法,该方法最终又会调用ViewGroup的onTouchEvent方法
结论:如果一个子VIew不消费分过来的事件,即onTouchEvent返回false,最终事件会交给ViewGroup的onTouchEvent来处理, 即子元素不处理事件,父亲来处理
down事件时才会遍历子元素,才有机会给mFirstTouchTarget赋值,所以这就得出一个结论:
如果一个VIew不接受down事件,一个事件序列中后面的事件也一定不会给它了
3.View的事件分发
上面说到了如果子View不接受ViewGroup分发的事件,就会调用super.dispatchTouchEvent方法,也就是View的dispatchTouchEvent方法,我们接下来分析下这个方法
if (li != null && li.mOnTouchListener != null&& (mViewFlags & ENABLED_MASK) == ENABLED&& li.mOnTouchListener.onTouch(this, event)) {result = true;}if (!result && onTouchEvent(event)) {result = true;}
上面的源码也印证了上面之前说的一个结论:mOnTouchListener的优先级别比onTouchEvent高,如果mOnTouchListener的onTouch方法的返回值时true,那onTouchEvent就不是执行
接下来好好看看onTouchEvent方法
if ((viewFlags & ENABLED_MASK) == DISABLED&& (mPrivateFlags4 & PFLAG4_ALLOW_CLICK_WHEN_DISABLED) == 0) {if (action == MotionEvent.ACTION_UP && (mPrivateFlags & PFLAG_PRESSED) != 0) {setPressed(false);}mPrivateFlags3 &= ~PFLAG3_FINGER_DOWN;// A disabled view that is clickable still consumes the touch// events, it just doesn't respond to them.return clickable;}
从上面源码中,我们又印证了一个结论一个View是否消费事件与是否enable无关,只与是否click有关
if (clickable || (viewFlags & TOOLTIP) == TOOLTIP) {.................省略switch (action) {case MotionEvent.ACTION_UP:performClickInternal();.....................省略 }.................省略}.................省略
}
如果VIew的可点击的,会在up事件时通过performClickInternal方法去调用onClickListener的onClick方法
onTouchEvent主要是View怎么处理事件的逻辑,这里不再细讲
总结