一、View的事件分發
1、點擊事件的傳遞規則
在介紹點擊事件的傳遞規則之前,首先明白分析的對象就是MotionEvent,即點擊事件。所謂點擊事件的事件分發,其實就是對MotionEvent事件的分發過程,即當一個MoonEvent產生了以後,系統需要把這個事件傳遞給一個具體的View,而這個傳遞的過程就是分發過程。點擊事件的分發過程由三個很重要的分發來完成dispatchTouchEvent、onInterceptTouchEvent和onTouchEvent,下面我們先介紹一下這幾個方法。
上述三個方法到底有什麼區別呢?它們是什麼關係呢?其實它們的關係可以用如下僞代碼可以瞭解
public boolean dispatchTouchEvent(MotionEvent ev) {
boolean consume = false;
if(onInterceptTouchEvent(ev)){
consume = onTouchEvent(ev);
}else {
consume = child.dispatchTouchEvent(ev);
}
return consume;
}
上述的僞代碼已經將三者的區別說明了,我們可以大致的瞭解傳遞的規則就是,對於一個根ViewGroup來說,點擊事件產生以後,首先傳遞給它,這時它的dispatchTouchEvent就會被調用,如果這個ViewGroup的onIntereptTouchEvent方法返回true,就表示它要攔截當前事件,接着事件就會交給這個ViewGroup處理,則他的onTouchEvent方法就會被調用;如果這個ViewGroup的onIntereptTouchEvent方法返回false就表示不需要攔截當前事件,這時當前事件就會繼續傳遞給它的子元素,接着子元素的onIntereptTouchEvent方法就會被調用,如此反覆直到事件被最終處理。
關於事件傳遞的機制,這裏給出一些結論,根據這些結論可以更好地理解整個傳遞機制,如下所示。
(1)同一個事件序列是指從手指接觸屏幕的那一刻起,到手指離開屏慕的那一刻結束,在這個過程中所產生的一系列事件,這個事件序列以down事件開始,中間含有數量不定的move事件,最後以up結束。
(2)正常情況下,一個事件序列只能被一個View攔截且消耗。這一條的原因可以參考(3),因爲一旦一個元素攔截了某此事件,那麼同一個事件序列內的所有事件都會直接交給它處理,因此同一個事件序列中的事件不能分別由兩個View同時處理,但是通過特殊手段可以做到,比如一個Vew將本該自己處理的事件通過onTouchEvent強行傳遞給其他View處理。
(3)某個View一旦決定攔截,那麼這一個事件序列都只能由它來處理(如果事件序列能夠傳遞給它的話),並且它的onInterceprTouchEvent不會再被調用。這條也很好理解,就是說當一個View決定攔截一個事件後,那麼系統會把同一個事件序列內的其他方法都直接交給它來處理,因此就不用再調用這個View的onInterceptTouchEvent去詢問它是否要攔截了。
(4)**某個View一旦開始處理事件,如果它不消耗ACTON_DOWN事件(onTouchEvent返回了false),那麼同一事件序列中的其他事件都不會再交給它來處理,並且事件將重新交由它的父元素去處理,即父元素的onTouchEvent會被調用。**意思就是事件一旦交給一個View處理,那麼它就必須消耗掉,否則同一事件序列中剩下的事件就不再交給它來處理了,這就好比上級交給程序員一件事,如果這件事沒有處理好,短期內上級就不敢再把事情交給這個程序員做了,二者是類似的道理。
(5)如果View不消耗除ACTION_DOWN以外的其他事件,那麼這個點擊事件會消失,此時父元素的onTouchEvent並不會被調用,並且當前View可以持續收到後續的事件,最終這些消失的點擊事件會傳遞給Activity處理。
(6) ViewGroup默認不攔截任何事件。Android源碼中ViewGroup的onInterceptTouchEvent方法默認返回false
(7)View沒有onInterceptTouchEvent方法,一旦有點擊事件傳遞給它,那麼它的onTouchEvent方法就會被調用。
(8)view的onTouchEvent默認都會消耗事件(返回true),除非它是不可點擊的(clickable和longClickable同時爲false),View的longClickable屬性默認都爲false,clickable屬性要分情況,比如Button的clickable屬性默認爲true,而TextView 的clickable屬性默認爲false
(9) view 的enable屬性不影響onTouchEvent的默認返回值。哪怕一個View是disable狀態的,只要它的clickable或者longclickable有一個爲true,那麼它的onTouchEvent就返會true。
(10)onClick會發生的前提實際當前的View是可點擊的,並且他收到了down和up的事件
(11) 事件傳遞過程是由外到內的,理解就是事件總是先傳遞給父元素,然後再由父元素分發給子View,通過requestDisallowInterptTouchEvent方法可以在子元素中干預父元素的事件分發過程,但是ACTION_DOWN除外。
二、事件分發的源碼解析
1.Activity對點擊事件的分發過程
//Activity#dispatchTouchEvent
public boolean dispatchTouchEvent(MotionEvent ev) {
if (ev.getAction() == MotionEvent.ACTION_DOWN) {
onUserInteraction();
}
if (getWindow().superDispatchTouchEvent(ev)) {
return true;
}
return onTouchEvent(ev);
}
2.頂級View對事件的分發過程
// Check for interception.
final boolean intercepted;
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不攔截事件的時候,事件會向下分發由他的子View進行處理
final View[] children = mChildren;
for (int i = childrenCount - 1; i >= 0; i--) {
final int childIndex = getAndVerifyPreorderedIndex(
childrenCount, i, customOrder);
final View child = getAndVerifyPreorderedView(
preorderedList, children, childIndex);
// If there is a view that has accessibility focus we want it
// to get the event first and if not handled we will perform a
// normal dispatch. We may do a double iteration but this is
// safer given the timeframe.
if (childWithAccessibilityFocus != null) {
if (childWithAccessibilityFocus != child) {
continue;
}
childWithAccessibilityFocus = null;
i = childrenCount - 1;
}
if (!canViewReceivePointerEvents(child)
|| !isTransformedTouchPointInView(x, y, child, null)) {
ev.setTargetAccessibilityFocus(false);
continue;
}
newTouchTarget = getTouchTarget(child);
if (newTouchTarget != null) {
// Child is already receiving touch within its bounds.
// Give it the new pointer in addition to the ones it is handling.
newTouchTarget.pointerIdBits |= idBitsToAssign;
break;
}
resetCancelNextUpFlag(child);
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 index
for (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;
}
3.View對點擊事件的處理
View對點擊事件的處理稍微有點簡單, 這裏注意,這裏的View不包含ViewGroup,先看他的dispatchTouchEvent方法
public boolean dispatchTouchEvent(MotionEvent event) {
boolean result = false;
...
if (onFilterTouchEventForSecurity(event)) {
if ((mViewFlags & ENABLED_MASK) == ENABLED && handleScrollBarDragging(event)) {
result = true;
}
//noinspection SimplifiableIfStatement
ListenerInfo li = mListenerInfo;
if (li != null && li.mOnTouchListener != null
&& (mViewFlags & ENABLED_MASK) == ENABLED
&& li.mOnTouchListener.onTouch(this, event)) {
result = true;
}
if (!result && onTouchEvent(event)) {
result = true;
}
}
...
return result;
}
因爲他只是一個View,他沒有子元素所以無法向下傳遞,所以只能自己處理點擊事件,從上門的源碼可以看出View對點擊事件的處理過程,首選會判斷你有沒有設置onTouchListener**,如果onTouchListener中的onTouch爲true,那麼onTouchEvent就不會被調用,可見onTouchListener的優先級高於onTouchEvent,這樣做到好處就是方便在外界處理點擊事件**;
接着我們再來分析下onTouchEvent的實現,先看當View處於不可用的狀態下點擊事件的處理過程,如下,很顯然,不可用狀態下的View照樣會消耗點擊事件,儘管他看起來不可用。
下面再看一下onTouchEvent中點擊事件的具體處理,如下所示:
if (((viewFlags & CLICKABLE) == CLICKABLE ||
(viewFlags & LONG_CLICKABLE) == LONG_CLICKABLE) ||
(viewFlags & CONTEXT_CLICKABLE) == CONTEXT_CLICKABLE) {
switch (action) {
case MotionEvent.ACTION_UP:
boolean prepressed = (mPrivateFlags & PFLAG_PREPRESSED) != 0;
if ((mPrivateFlags & PFLAG_PRESSED) != 0 || prepressed) {
// take focus if we don't have it already and we should in
// touch mode.
boolean focusTaken = false;
if (isFocusable() && isFocusableInTouchMode() && !isFocused()) {
focusTaken = requestFocus();
}
if (prepressed) {
// The button is being released before we actually
// showed it as pressed. Make it show the pressed
// state now (before scheduling the click) to ensure
// the user sees it.
setPressed(true, x, y);
}
if (!mHasPerformedLongPress && !mIgnoreNextUpEvent) {
// This is a tap, so remove the longpress check
removeLongPressCallback();
// Only perform take click actions if we were in the pressed state
if (!focusTaken) {
// Use a Runnable and post this rather than calling
// performClick directly. This lets other visual state
// of the view update before click actions start.
if (mPerformClick == null) {
mPerformClick = new PerformClick();
}
if (!post(mPerformClick)) {
performClick();
}
}
}
black;
}
....
return true;
}
public boolean performClick() {
final boolean result;
final ListenerInfo li = mListenerInfo;
if (li != null && li.mOnClickListener != null) {
playSoundEffect(SoundEffectConstants.CLICK);
li.mOnClickListener.onClick(this);
result = true;
} else {
result = false;
}
sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_CLICKED);
return result;
}
public void setOnClickListener(@Nullable OnClickListener l) {
if (!isClickable()) {
setClickable(true);
}
getListenerInfo().mOnClickListener = l;
}
public void setOnLongClickListener(@Nullable OnLongClickListener l) {
if (!isLongClickable()) {
setLongClickable(true);
}
getListenerInfo().mOnLongClickListener = l;
}
二、View的滑動衝突
滑動衝突時怎樣產生的?其實在界面中只要內外兩層同時可以滑動,這個時候就會產生滑動衝突,如何解決滑動衝突?這既是一鍵困難的事情又是一件簡單的事情,說困難時因爲許多開發者面對滑動衝突都會顯得束手無策,說簡單是因爲滑動衝突的解決辦法有固定的套路。
1.常見的滑動衝突場景
先說場景1,主要是將ViewPager和Fragment配合使用所組成的頁面滑動效果,主流應用幾乎都會使用這個效果。在這種效果中,可以通過左右滑動來切換頁面,而每個頁面內部往往又是一個Listview。本來這種情況下是有滑動衝突的,但是viewPager內部處理了這種滑動衝突,因此採用ViewPager時我們無須關注這個問題,如果我們採用的不是ViewPager而是ScrollView等,那就必須手動處理滑動衝突了,否則造成的後果就是內外兩層只能有一層能夠滑動,這是因爲兩者之間的滑動事件有衝突。除了這種典型情況外,還存在其他情況,比如外部上下滑動、內部左右滑動等,但是它們屬於同一類滑動衝突。
再說場景2,這種情況就稍微複雜一些,當內外兩層都在同一個方向可以滑動的時候,顯然存在邏輯問題。因爲當手指開始滑動的時候,系統無法知道用戶到底是想讓哪一層滑動,所以當手指滑動的時候就會出現問題,要麼只有一層能滑動,要麼就是內外兩層都滑動得很卡頓。在實際的開發中,這種場景主要是指內外兩層同時能上下滑動或者內外兩層,同時能左右滑動。
最後說下場景3,場景3是場景1和場景2兩種情況的嵌套,因此場景3的滑動衝突看起來就更加複雜了。比如在許多應用中會有這麼一個效果:內層有一個場景1中的滑動效果,然後外層又有一個場景2中的滑動效果。具體說就是,外部有一個SlidingMenu效果,然後內部有一個ViewPager,ViewPager的每一個頁面中又是一個Listview。雖然說場景3的滑動衝突看起來更復雜,但是它是幾個單一的滑動衝突的疊加,因此只需要分別處理內層和中層、中層和外層之間的滑動衝突即可,而具體的處理方法其實是和場景1、場景2相同的。
從本質上來說,這三種滑動衝突場景的複雜度其實是相同的,因爲它們的區別僅僅是滑動策略的不同,至於解決滑動衝突的方法,它們幾個是通用的。
2.滑動衝突的處理規則
3.滑動衝突的解決方式
主要有2種:外部攔截法(推薦使用)、內部攔截法。
a.外部攔截法
@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;//必須不能攔截,如果攔截了,後續move和up都由父元素處理了
break;
case MotionEvent.ACTION_MOVE:
if("父容器需要當前點擊事件"){
intercepted = true;
}else {
intercepted = false;
}
break;
case MotionEvent.ACTION_UP:
intercepted = false;//必須不能攔截,如果攔截了,子元素的onClick點擊事件就無法觸發
break;
}
mLastXIntercept = x;
mLastYIntercept = x;
return intercepted;
}
b.內部攔截法
內部攔截法是指父容器不攔截任何事件,所有的事件都傳遞給子元素,如果子元素需要此事件就直接消耗掉,否則就交由父容器進行處理,這種方法和Android中的事件分發機制不一致,需要配合requestDisallowInterceptTouchEvent方法才能正常工作,使用起來較外部攔截法稍顯複雜。它的僞代碼如下,我們需要重寫子元素的dispatchTouchEvent方法:
@Override
public boolean dispatchTouchEvent(MotionEvent event) {
int x = (int) event.getX();
int y = (int) event.getY();
switch (event.getAction()){
case MotionEvent.ACTION_DOWN:
getParent().requestDisallowInterceptTouchEvent(true);//設置父容器不允許攔截
break;
case MotionEvent.ACTION_MOVE:
int deltaX = x - mLastX;
int deltaY = x - mLastY;
if("父容器需要此類點擊事件"){
getParent().requestDisallowInterceptTouchEvent(false);//設置攔截
}
break;
case MotionEvent.ACTION_UP:
break;
}
mLastX = x;
mLastY = y;
return super.dispatchTouchEvent(event);//這個值取決於子view的處理結果,
//例如這個view如果繼承ListView,就需要看listview的這個方法處理結果。並不是再調用了父容器的這個方法。
}
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
int action = ev.getAction();
if(action == MotionEvent.ACTION_DOWN){
return false;//不攔截
}else {
return true;
}
}
總結下:使用外部攔截只需要處理父容器的OnInterceptTouchEvent方法。使用內部攔截需要處理子容器的dispatchTouchEvent和重寫父元素的OnInterceptTouchEvent方法。所以建議使用外部攔截法會比較方便。
下面演示一個外部攔截法的實例:
自定義一個HorizontalScrollViewEx
package com.example.viewsample;
import android.content.Context;
import android.view.MotionEvent;
import android.view.VelocityTracker;
import android.view.ViewGroup;
import android.widget.Scroller;
public class HorizontalScrollViewEx extends ViewGroup {
public static final String TAG = "HorizontalScrollViewEx";
private int mChindrensize;
private int mChindrenWidth;
private int mChindrenIndex;
//分別記錄上次滑動的座標
private int mLastX = 0;
private int mLastY = 0;
//分別記錄上次滑動的座標
private int mLastXIntercept = 0;
private int mLastYIntercept = 0;
private Scroller mScroller;
private VelocityTracker mVelocityTracker;
private void init() {
mScroller = new Scroller(getContext());
mVelocityTracker = VelocityTracker.obtain();
}
public HorizontalScrollViewEx(Context context) {
super(context);
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
}
@Override
public boolean onInterceptHoverEvent(MotionEvent event) {
boolean intercepted = false;
int x = (int) event.getX();
int y = (int) event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
intercepted = true;
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;
}
mLastX = x;
mLastY = y;
mLastXIntercept = x;
mLastYIntercept = y;
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 / mChindrenWidth;
mVelocityTracker.computeCurrentVelocity(1000);
float xVelocity = mVelocityTracker.getXVelocity();
if (Math.abs(xVelocity) >= 50) {
mChindrenIndex = xVelocity > 0 ? mChindrenIndex - 1 : mChindrenIndex + 1;
} else {
mChindrenIndex = (scrollX + mChindrenWidth / 2) / mChindrenWidth;
}
mChindrenIndex = Math.max(0, Math.min(mChindrenIndex, mChindrensize - 1));
int dx = mChindrenIndex * mChindrenWidth - scrollX;
ssmoothScrollBy(dx, 0);
mVelocityTracker.clear();
break;
}
mLastX = x;
mLastY = y;
return true;
}
//這個方法上面一章講解過,Scroller的彈性滑動原理
private void ssmoothScrollBy(int dx, int i) {
mScroller.startScroll(getScrollX(),0,dx,500);
invalidate();
}
@Override
public void computeScroll() {
if(mScroller.computeScrollOffset()){
scrollTo(mScroller.getCurrX(),mScroller.getCurrY());
postInvalidate();
}
}
}
測試:
public class MainActivity extends AppCompatActivity {
public static final String TAG = "MainActivity";
private HorizontalScrollViewEx mListContainer;
private int w,h;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
Log.i(TAG,"onCreate");
initView();
}
private void initView() {
LayoutInflater inflater = getLayoutInflater();
mListContainer = findViewById(R.id.container);
//屏幕寬高
WindowManager wm = (WindowManager) getSystemService(WINDOW_SERVICE);
w = wm.getDefaultDisplay().getWidth();
h = wm.getDefaultDisplay().getHeight();
for (int i = 0; i < 3; i++) {
ViewGroup layout = inflater.inflate(R.layout.content_layout,mListContainer,false);
layout.getLayoutParams().width = w;
TextView textview = (TextView) layout.findViewById(R.id.title);
textview.setText("page" + (i+1));
layout.setBackgroundColor(Color.rgb(255/(i+1),255/(i+1),0));
createList(layout);
mListContainer.addView(layout);
}
}
private void createList(ViewGroup layout) {
ListView listview = (ListView) layout.findViewById(R.id.list);
ArrayList<String>datas= new ArrayList<>();
for (int i = 0; i < 50; i++) {
datas.add("names" + i);
}
ArrayAdapter<String>adapter = new ArrayAdapter<String>(this,R.layout.content_list_item,R.id.name,datas);
listview.setAdapter(adapter);
}
}
內部攔截參考上面的僞代碼自行研究下。滑動衝突解決基本就掌握了。