View的事件體系
1.1 View簡述
- View是Android中所有控件的基類。View是一種界面層的控件的一種抽象,代表一個控件。
- ViewGroup:控件組。ViewGroup內部包含多個控件(View)。ViewGroup也繼承View,意味着View本身就可以是單個控件也可以是多個控件組成的一組控件。
1.1.1 View的位置參數
View的位置由四個頂點確定,對應View的四個屬性:top(左上縱座標)、left(左上橫座標)、right(右下橫座標)、bottom(右下縱座標)。
View的寬高和座標關係:
width = right - left
height = bottom - top
位置參數的獲取方法:
Left=getLeft()
Right=getRight()
Top=getTop()
Bottom=getBottom()
View的其他參數:
x:View的左上角橫座標
y:View的左上角縱座標
translationX:View的左上角橫座標相對於父容器的偏移量。(默認0)
translationY:View的左上角縱座標相對於父容器的偏移量。(默認0)
x=left+translationX
y=top+translationY
注意:top和left表示的是原始左上角的位置信息,不會改變;x、y、translationX、translationY這四個參數會發生改變。
1.1.2 MotionEvent和TouchSlop
MotionEvent:
- ACTION_DOWN:手指剛接觸屏幕。
- ACTION_MOVE:手指在屏幕上移動。
- ACTION_UP:手指從屏幕上鬆開的一瞬間。
- 通過MotionEvent對象可以獲取點擊事件發生的x和y座標。getX/getY返回相對於當前View左上角的x和y座標;getRawX/getRawY返回相對於手機屏幕左上角的x和y座標。
TouchSlop:
- 系統所能識別出的被認爲是滑動的最小距離。TouchSlop是一個和設備相關的常量。通過
ViewConfiguration.get(getContext()).getScaledTouchSlop()
方法獲取。
1.1.3 VelocityTracker、GestureDetector、Scroller
VelocityTracker:
速度追蹤。
/** * 在View的onTouchEvent方法中追蹤當前點擊事件的速度 */ VelocityTracker velocityTracker = VelocityTracker.obtain(); velocityTracker.addMovement(event); /** * 獲取當前速度 * 要在獲取速度前調用computeCurrentVelocity()計算速度。單位ms。 * 速度是指一段時間內手指所劃過的像素數。帶方向,可以爲負值。 * 速度 = (終點位置 - 起點位置)/時間段 */ velocityTracker.computeCurrentVelocity(1000); int xVelocity=(int)velocityTracker.getXvelocity(); int yVelocity=(int)velocityTracker.getYvelocity(); /** * 不需要時,需要重置並回收內存 */ velocityTracker.clear() velocityTracker.recycle()
GestureDetector:
手勢檢測。
/** * 創建GestureDetector對象,並實現OnGestureListener接口 */ GestureDetector mGestureDetector = new GestureDetector(this); //解決長按屏幕後無法無法拖動現象 mGestureDetector.setIsLongpressEnabled(false); /** * 在View的onTouchEvent方法中實現 */ boolean consume = mGestureDetector.onTouchEvent(event); return consume;
完成上述步驟後,可選擇性地實現OnGestureListener和OnDoubleTapListener中的方法:
方法名 描述 所屬接口 onDown 手指輕輕觸摸屏幕的一瞬間,由1個ACTION_DOWN觸發 OnGestureListener onShowPress 手指輕輕觸摸屏幕,尚未鬆開或拖動,由1個ACTION_DOWN觸發。和onDown()的區別:它強調的是沒有鬆開或者拖動的狀態。 OnGestureListener onSingleTapUp 手指(輕觸摸屏幕後)鬆開,伴隨着1個 ACTION_UP觸發,是單擊行爲。 OnGestureListener onScroll 手指按下屏幕並拖動,由1個ACTION_DOWN,多個ACTION_MOVE觸發,是拖動行爲。 OnGestureListener onLongPress 用戶長久地按着屏幕不放,即長按。 OnGestureListener onFling 用戶按下觸摸屏、快速滑動後鬆開,由1個ACTION_DOWN、多個ACTION_MOVE和1個ACTION_UP觸發,是快速滑動行爲。 OnGestureListener onDoubleTap 雙擊,由2次連續的單擊組成。不可能和onSingleTapConirned共存。 OnDoubleTapListener onSingleTapConfirmed 嚴格的單擊行爲。和onSingleTapUp的區別:如果觸發了onSingleTapConfirmed,那麼後面不可能再緊跟着另一個單擊行爲,即這隻可能是單擊,而不可能是雙擊中的一次單擊。 OnDoubleTapListener onDoubleTapEvent 雙擊行爲,在雙擊的期間,ACTION DOWN、ACTION_ MOVE和ACTION_ UP都會觸發此回調。 OnDoubleTapListener 常用:onSingleTapUp、onFling、onScroll、onLongPress、onDoubleTap。
注意:實際開發中,可以不使用GestureDetector,可以完全在View的ontouchEvent方法中實現所需監聽。
Scroller:
彈性滑動。
View的scollTo/scrollBy方法進行滑動時,過程是瞬間完成的,沒有過渡效果。Scroller可以實現有過渡效果的滑動,Scroller本身無法讓View彈性滑動,需要和View的computeScroll方法配合實現。
典型代碼:
Scroller mScroller = new Scroller(mContext); //緩慢滾動到指定位置 private void smoothScrollTo(int destX,int destY){ int scrollX = getScrollX(); int delta = destX - scrollX; // 1000ms 內滑向 destX,效果就是慢慢滑動 mScroller.startScroll(scrollX,0,delta,1000); invalidate(); } @Override public void computeScroll(){ if(mScroller.computeScrollOffset()){ scrollTo(mScroller.getCurrX(),mScroller.getCurrY()); postInvalidate(); } }
1.2 View的滑動
1.2.1 使用scrollTo/scrollBy
- scrollTo實現了基於所傳遞參數的絕對滑動;scrollBy實現了基於當前位置的相對滑動。scrollBy實質上調用的也是scrollTo方法。
- View內部存在兩個屬性mScrollX和mScrollY(單位是像素);這兩個屬性可以通過getScrollX和getScrollY獲得。mScrollX的值等於View左邊緣和View內容左邊緣在水平方向上的距離;mScrollY的值等於View上邊緣和View內容上邊緣在垂直方向上的距離。
- scrollTo和scrollBy只能改變View內容的位置而不能改變View在佈局中的位置。
- 從左向右滑動mScrollX爲負值,反之爲正值;從上往下滑動mScrollY爲負值,反之爲正值。
1.2.2 使用動畫
通過動畫讓一個View進行平移(平移是一種滑動),主要通過操作View的translationX和translationY屬性實現。可以採用View動畫或者屬性動畫。
View動畫:(100ms內將一個View從原始位置向右下角移動100個像素)
<?xml version="1.0" encoding="utf-8"?> <set xmlns:android="http://schemas.android.com/apk/res/android" android:fillAfter="true" android:zAdjustment="normal"> <translate android:duration="100" android:fromXDelta="0" android:fromYDelta="0" android:interpolator="@android:anim/linear_interpolator" android:toXDelta="100" android:toYDelta="100"/> </set>
View動畫是對View的影像做操作,不能真正的改變View的位置參數;如果希望動畫後的狀態得以保留需要將fillAfter屬性設爲true,否則動畫完成後回到初始狀態。
屬性動畫:(100ms內將一個View從原始位置向右移動100個像素)
ObjectAnimator.ofFloat(targetView,"translationX",0,100).setDuration(100).start();
注意:在很久的版本上(現在的版本不存在這個問題),因爲動畫移動的是影像,而真身還在原先位置,從而導致,經過動畫移動後的控件的觸控事件在新位置上無法觸發,在原先位置上可以觸發。
1.2.3 改變佈局參數
改變佈局參數,即改變LayoutParams。
ViewGroup.MarginLayoutParams params =(ViewGroup.MarginLayoutParams)mButton.getLayoutParams(); params.width +=100; params.leftMargin +=100; mButton.requestLayout(); //或者mButton.setLayoutParams(params);
1.2.4 滑動方式的對比
- scrollTo/scrollBy:操作簡單,適合對View內容的滑動。
- 動畫:操作簡單,適合用於沒有交互的View和實現複雜的動畫效果。
- 改變佈局參數:操作稍微複雜,適用於有交互的View。
1.3 彈性滑動
1.3.1 使用Scroller
典型代碼:
Scroller mScroller = new Scroller(mContext); //緩慢滾動到指定位置 private void smoothScrollTo(int destX,int destY){ int scrollX = getScrollX(); int delta = destX - scrollX; // 1000ms 內滑向 destX,效果就是慢慢滑動 mScroller.startScroll(scrollX,0,delta,1000); invalidate(); } @Override public void computeScroll(){ if(mScroller.computeScrollOffset()){ scrollTo(mScroller.getCurrX(),mScroller.getCurrY()); postInvalidate(); } }
Scroller實現讓View彈性滑動原理:
- startScroll方法:僅是保存傳遞的參數。
- invalidate方法:該方法會導致View重繪,在View的draw方法中會調用computeScroll方法,computeScroll在View中是一個空實現,需要我們實現。
- computeScroll方法:示例代碼實現該方法,完成彈性滑動。當View重繪後會在draw方法中調用computeScroll,computeScroll會向Scroller獲取當前的ScrollX和ScrollY,然後通過scrollTo方法滑動到當前位置,之後postInvalidate進行二次重繪。與第一次重繪相似,還是會調用computeScroll方法,然後獲取當前的ScrollX和ScrollY,再通過scrollTo方法滑動到當前位置,如此反覆,直至滑動過程結束。
- computeScrollOffset方法:該方法通過時間的流逝計算出當前的ScrollX和ScrollY的值,類似於差值器的概念。該方法的返回爲true表示滑動還未結束,false表示滑動結束。
Scroller的工作原理:
Scroller 本身並不能實現View的滑動,需要配合View的computeScroll方法才能完成彈性滑動的效果。通過不斷地重繪View,每一次重繪距離滑動起始時間會有一個時間間隔,通過這個時間間隔Scroller計算出View當前的滑動位置,知道位置後就可以通過scrollTo方法完成View的滑動。View的每一次重繪都會導致View進行小幅度的滑動,多次的小幅度滑動組成了彈性滑動。
1.3.2 使用動畫
動畫本身就是一種漸近過程。
//100ms內將一個View從原始位置向右移動100個像素 ObjectAnimator.ofFloat(targetView,"translationX",0,100).setDuration(100).start();
模仿Scroller實現彈性滑動:
final int startX = 0; final int deltaX = 100; final ValueAnimator animator = ValueAnimator.ofInt(0, 1).setDuration(1000); animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { @Override public void onAnimationUpdate(ValueAnimator animation) { float fraction=animator.getAnimatedFraction(); mButton.scrollTo(startX+(int)(deltaX*fraction),0); } });
1.3.3 使用延時策略
核心思想:
通過發送一系列延時消息從而達到一種漸進式的效果。可以使用Handler或View的postDelayed方法,也可以使用線程的sleep方法。對於postDelayed方法,可以通過其延時發送一個消息,在消息中進行View滑動,連續地發送這種延時消息,就可以實現彈性滑動效果。對於sleep方法,通過在while循環中不斷地滑動View和sleep,也可以實現彈性滑動效果。
示例代碼:
private static final int MESSAGE_SCROLL_TO = 1; private static final int FRAME_COUNT = 30; private static final int DELAYED_TIME = 30; private int mCount = 0; @SuppressLint("HandlerLeak") private Handler mHandler = new Handler() { @Override public void handleMessage(Message msg) { switch (msg.what){ case MESSAGE_SCROLL_TO:{ mCount++; if (mCount<=FRAME_COUNT){ float fraction = mCount/(float)FRAME_COUNT; int scrollX = (int)(fraction*100); mButton.scrollTo(scrollX,0); mHandler.sendEmptyMessageDelayed(MESSAGE_SCROLL_TO,DELAYED_TIME); } break; } default: break; } } };
1.4 View的事件分發機制
1.4.1 點擊事件的傳遞規則
點擊事件,即MotionEvent。點擊事件的事件分發,就是對MotionEvent事件的分發過程,即當MotionEvent產生後,系統需要把這個事件傳遞給一個具體的View,這個過程就是分發過程。
點擊事件的分發過程中三個重要的方法:
- public boolean dispatchTouchEvent(MotionEvent event):進行事件分發。如果事件能夠傳遞給當前View,此方法一定會被調用,返回結果受當前View的onTouchEvent和下級View的dispatchTouchEvent方法影響,表示是否消耗當前事件。
- public boolean onInterceptTouchEvent(MotionEvent ev):判斷是否攔截某個事件。如果當前View攔截了某個事件,那麼在同一個事件蓄力中,此方法不會再被調用,返回結果表示是否攔截當前事件。
- public boolean onTouchEvent(MotionEvent event):處理點擊事件。返回結果表示是否消耗當前事件,如果不消耗,則在同一個事件序列中,當前View不再接收事件。
dispatchTouchEvent、onInterceptTouchEvent、onTouchEvent關係:
@Override public boolean dispatchTouchEvent(MotionEvent event) { boolean consume = false; if (onInterceptTouchEvent(event)){ consume = onTouchEvent(event); }else { consume = getFocusedChild().dispatchTouchEvent(event); } return consume; }
- 對於一個根ViewGroup而言,點擊事件產生後,首先會傳遞給它,這時它的dispatchTouchEvent方法被調用;如果這個ViewGroup的onInterceptTouchEvent方法返回true,表示它要攔截當前事件,接着事件交付給ViewGroup處理(即調用它的onTouchEvent方法),如果這個ViewGroup的onInterceptTouchEvent方法返回false,表示它不攔截當前事件,事件會繼續傳遞給它的子元素,子元素的dispatchTouchEvent方法被調用,如此反覆直到事件被最終處理。
- 當一個View需要處理事件時,如果設置了OnTouchListener,那麼OnTouchListener中的onTouch方法會被回調。如果onTouch方法返回false,則當前View的onTouchEvent方法會被調用;如果返回true,則當前View的onTouchEvent方法不會被調用。View的OnTouchListener優先級比onTouchEvent高。在onTouchEvent中,如果當前設置的有OnClickListener,那麼其onClick方法會被調用,可見OnClickListener優先級最低,位於事件傳遞的尾端。
- 當一個點擊事件產生後,其傳遞順序:Activity->Window->View(頂級View),頂級View再按照事件分發機制去分發事件。
- 如果一個View的onTouchEvent返回false,那麼它的父容器的onTouchEvent將會被調用,以此類推,如果所有元素都不處理這個事件,那麼該事件最終會傳遞給Activity處理,即Activity的onTouchEvent方法會被調用。
事件傳遞機制結論:
- 1**.事件序列**是指從手指接觸屏幕的那一刻起,到手指離開屏幕的那一刻結束,在這個過程中所產生的一系列事件,該事件序列以down事件開始,中間包含數量不定的move事件,最終以up事件結束。
- 2.正常情況下,一個事件序列只能被一個View攔截並消耗。一旦一個元素攔截了某次事件,那麼同一個事件序列的所有事件都會直接交給它處理。
- 3.某個View一旦決定攔截,那麼這一個事件序列都只能由它處理,並且它的onInterceptTouchEvent不會再被調用。
- 4.某個View一旦開始處理事件,如果它不消耗ACTION_DOWN事件(onTouchEvent返回false),那麼同一事件序列中的其他事件都不會再交給它來處理,並且事件將重新交給它的父元素去處理,即父元素的onTouchEvent會被調用。換個說法:事件一旦決定交給一個View處理,它就必須消耗掉,否則,同一事件序列中的其它事件就不交給它處理了。
- 5.如果View不消耗ACTION_DOWN以外的其他事件,那麼這個點擊事件會消失,父元素的onTouchEvent也不會被調用,當前View可以持續接受到後續事件,最終消失的點擊事件會傳遞給Activity處理。
- 6.ViewGroup默認不攔截任何事件。
- 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,通過requestDisallowInterceptTouchEvent方法可以在子元素中干預父元素的事件分發過程,但是ACTION_DOWN事件除外。
1.4.2 事件分發源碼解析
- 見博客:Android事件分發源碼解析
1.5 View的滑動衝突
- 界面中存在內外兩層可以同時滑動的情況,就會產生滑動衝突。
1.5.1 常見的滑動衝突場景
場景一:外部滑動方向和內部滑動方向不一致。
- 主要是將ViewPager和Fragment配合使用所組成的頁面滑動效果,可以通過左右滑動來切換界面,每個界面往往是一個ListView。ViewPager內部處理了這種滑動衝突,使用ViewPager時無需關心。如果使用的是ScrollView而非ViewPager,就需要手動處理滑動衝突。
場景二:外部滑動方向和內部滑動方向一致。
- 系統無法判斷用戶是希望那一層滑動,還是都滑動。
場景三:場景一、場景二嵌套。
1.5.2 滑動衝突的處理規則
- 場景一:用戶左右滑動時,需要讓外部的View攔截點擊事件;上下滑動時,需要讓內部的View攔截點擊事件。我們可以根據特徵來解決滑動衝突:1.可以根據滑動路徑和水平方向所形成的夾角;2.可以根據水平方向和垂直方向上的距離;3.可以根據水平和豎直方向上的速度。
- 場景二:根據業務上的規定製定相應的處理規則。
- 場景三:結合場景一、場景二。
1.5.3 滑動衝突的解決方式
外部攔截法:
點擊事件都先經過父容器的攔截處理,如果父容器需要此事件就攔截,如果不需要就不攔截。外部攔截法需要重寫父容器的onInterceptTouchEvent方法,在其中做相應的攔截即可:
//僞代碼 @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; 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; }
針對不同滑動衝突,只需修改父容器需要當前點擊事件的這個條件,其他不需也不能修改。對於ACTION_DOWN事件,父容器必須返回false(即不攔截),如果父容器攔截了ACTION_DOWN事件,後續的ACTION_MOVE和ACTION_UP事件都會交由父容器處理,無法傳遞給子元素。對於ACTION_MOVE事件,根據需要決定是否攔截,如果需要就返回true,不需要就返回false。對於ACTION_UP事件,需要返回false,該事件本身不存在太多意義。
補充:假設事件交由子元素處理,如果父容器在ACTION_UP時返回true,就會導致子元素無法接收到ACTION_UP事件,這時,子元素onClick事件無法觸發。由於父容器的特殊性,一旦開始攔截任何一個事件,後續事件都會交由其處理,即使onInterceptTouchEvent方法在ACTION_UP時返回false,ACTION_UP事件也會作爲最後一個事件傳遞給父容器。
內部攔截法:
父容器不攔截任何事件,所有的事件都傳遞給子元素,如果子元素需要此事件就直接消耗掉,否則就交由父容器進行處理。
這種方法和Android中的事件分發機制不一致, 需要配合requestDisallowInterceptTouchEvent方法使用:
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 = y - mLastY; if (父容器需要此點擊事件) { getParent().requestDisallowInterceptTouchEvent(false); } break; } case MotionEvent.ACTION_UP: { break; } default: break; } mLastX = x; mLastY = y; return super.dispatchTouchEvent(event); }
針對不同滑動衝突,只需修改父容器需要當前點擊事件的這個條件,其他不需也不能修改。子元素需要做處理以外,父元素也要默認攔截除了ACTION_DOWN以外的其他事件,這樣當子元素調用parent.requestDisallowInterceptTouchEvenf(false)方法時,父元素才能繼續攔截所需的事件。
父容器不能攔截ACTION_DOWN事件是因爲ACTION_DOWN事件不受FLAG_DISALLOW_INTERCEPT標記位控制;一旦父容器攔截ACTION_DOWN事件,那麼所有的事件都無法傳遞到子元素中去,內部攔截就無法起作用了。父元素如下修改:
public boolean onInterceptTouchEvent(MotionEvent ev) { int action = ev.getAction(); if (action==MotionEvent.ACTION_DOWN){ return false; }else { return true; } }
1.6 參考資料
- Android開發藝術探索