在Android中的任何一個佈局、任何一個控制元件其實都是直接或間接繼承自View的,因此View是一個很重要的概念。本篇將深入學習View,內容如下:
- View事件體系
- View位置引數
- View的觸控
- View的滑動
- View事件分發機制
- View滑動衝突
- View工作原理
- View工作流程
- 自定義View
簡介:在Android的世界中View是所有控制元件的基類,其中也包括ViewGroup在內,ViewGroup是代表著控制元件的集合,其中可以包含多個View控制元件。 從某種角度上來講Android中的控制元件可以分為兩大類:View與ViewGroup。通過ViewGroup,整個介面的控制元件形成了一個樹形結構,上層的控制元件要負責測量與繪製下層的控制元件,並傳遞互動事件。 在每棵控制元件樹的頂部都存在著一個ViewParent物件,它是整棵控制元件樹的核心所在,所有的互動管理事件都由它來統一排程和分配,從而對整個檢視進行整體控制。
一.View事件體系
1.View的位置引數
a.Android座標系:以螢幕的左上角為座標原點,向右為x軸增大方向,向下為y軸增大方向。
b.View的位置由它的四個頂點決定,分別對應View的四個屬性:top、left、right、bottom。其中left是左上角的橫座標,right是右下角的橫座標,top是左上角的縱座標,bottom是右下角的縱座標。注意這些座標是相對於view父容器而言,是一種相對的座標。具體關係見下圖:
因此,View的寬高和座標關係:width = right - left,height = top - bottom。
可利用View的get方法獲取上述屬性,如:
- left = getLeft();
- right = getRight();
- top = getTop();
- bottom = getBottom();
- width=getWidth();
- height=getHeight();
c.從android3.0開始,View增加了額外幾個引數:x,y,translationX、translationY。其中x和y是View左上角的座標,translationX和translationY是View左上角相對於父容器的偏移量,它們預設值是0。這些引數也是相對於View父容器。具體關係見下圖:
- 存在關係:x = left + translationX,y = top + translationY
- 由此可見,x和left不同體現在:left是View的初始座標,在繪製完畢後就不會再改變;而x是View偏移後的實時座標,是實際座標。y和top的區別同理。
類似地,安卓也提供了相應的get/set方法。需要注意的是,在onCreate()方法裡無法獲取到View的座標引數,這是因為此時View還未開始繪製,全部座標引數將都是0。
推薦閱讀:Android應用座標系統全面詳解
2.觸控系列
a.MotionEvent:是手指觸控螢幕鎖產生的一系列事件。典型事件有:
- ACTION_DOWN:手指剛接觸螢幕
- ACTION_MOVE:手指在螢幕上滑動
- ACTION_UP:手指在螢幕上鬆開的一瞬間
事件列:從手指接觸螢幕至手指離開螢幕,這個過程產生的一系列事件 任何事件列都是以DOWN事件開始,UP事件結束,中間有無數的MOVE事件。如圖:
通過MotionEvent 物件可以得到觸控事件的x、y座標。其中通過getX()、getY()可獲取相對於當前view左上角的x、y座標;通過getRawX()、getRawY()可獲取相對於手機螢幕左上角的x,y座標。具體關係見下圖:
b.TouchSlop:系統所能識別的被認為是滑動的最小距離。即當手指在螢幕上滑動時,如果兩次滑動之間的距離小於這個常量,那麼系統就不認為你是在進行滑動操作。
該常量和裝置有關,可用它來判斷使用者的滑動是否達到閾值,獲取方法:ViewConfiguration.get(getContext()).getScaledTouchSlop()。
c.VelocityTracker:速度追蹤,用於追蹤手指在滑動過程中的速度,包括水平和豎直方向的速度。
使用過程:首先在view的onTouchEvent方法中追蹤當前單擊事件的速度:
VelocityTracker velocityTracker = VelocityTracker.obtain();//例項化一個VelocityTracker 物件
velocityTracker.addMovement(event);//新增追蹤事件
複製程式碼
接著在ACTION_UP事件中獲取當前的速度。注意這裡計算的是1000ms時間間隔移動的畫素值,假設畫素是100,即速度是每秒100畫素。另外,手指逆著座標系的正方向滑動,所產生的速度為負值,順著正反向滑動,所產生的速度為正值。
velocityTracker .computeCurrentVelocity(1000);//獲取速度前先計算速度,這裡計算的是在1000ms內
float xVelocity = velocityTracker .getXVelocity();//得到的是1000ms內手指在水平方向從左向右滑過的畫素數,即水平速度
float yVelocity = velocityTracker .getYVelocity();//得到的是1000ms內手指在水平方向從上向下滑過的畫素數,垂直速度
複製程式碼
最後,當不需要使用它的時候,需要呼叫clear方法來重置並回收記憶體:
velocityTracker.clear();
velocityTracker.recycle();
複製程式碼
推薦閱讀:Android常用觸控類分析:MotionEvent 、 ViewConfiguration、VelocityTracker
d.GestureDetector:手勢檢測,用於輔助檢測使用者的單擊、滑動、長按、雙擊等行為。
使用過程:建立一個GestureDetecor物件並實現OnGestureListener介面,根據需要實現單擊等方法:
GestureDetector mGestureDetector = new GestureDetector(this);//例項化一個GestureDetector物件
mGestureDetector.setIsLongpressEnabled(false);// 解決長按螢幕後無法拖動的現象
複製程式碼
接著,接管目標view的onTouchEvent方法,在待監聽view的onTouchEvent方法中新增如下實現:
boolean consume = mGestureDetector.onTouchEvent(event);
return consume;
複製程式碼
然後,就可以有選擇的實現OnGestureListener和OnDoubleTapListener中的方法了。
建議:如果只是監聽滑動操作,建議在onTouchEvent中實現;如果要監聽雙擊這種行為,則使用GestureDetector 。
推薦閱讀:Android手勢檢測——GestureDetector全面分析
3.滑動系列
a.實現View滑動三種辦法:
①通過View本身提供的scrollTo/scrollBy方法
- 兩者區別:scrollBy是內部呼叫了scrollTo的,它是基於當前位置的相對滑動;而scrollTo是絕對滑動,因此如果利用相同輸入引數多次呼叫scrollTo()方法,由於View初始位置是不變只會出現一次View滾動的效果而不是多次。
- 注意:兩者都只能對view內容進行滑動,而不能使view本身滑動。
mScrollX和mScrollY分別表示View在X、Y方向的滾動距離。mScrollX:View的左邊緣減去View的內容的左邊緣;mScrollY:View的上邊緣減去View的內容的上邊緣。從右向左滑動,mScrollX為正值,反之為負值;從下往上滑動,mScrollY為正值,反之為負值。(更直觀感受:檢視下一張照片或者檢視長圖時手指滑動方向為正)
②通過動畫給View施加平移效果:主要通過改變View的translationX和translationY引數來實現。可用view動畫,也可以採用屬性動畫,如果使用屬性動畫的話,為了能夠相容3.0以下版本,需要採用開源動畫庫nineoldandroids。注意View動畫的View移動只是位置移動,並不能真正的改變view的位置,而屬性動畫可以。
③通過改變View的LayoutParams使得View重新佈局:比如將一個View向右移動100畫素,向右,只需要把它的marginLeft引數增大即可,程式碼見下:
MarginLayoutParams params = (MarginLayoutParams) btn.getLayoutParams();
params.leftMargin += 100;
btn.requestLayout();// 請求重新對View進行measure、layout
複製程式碼
三種方式對比:
- scrollTo/scrollBy:操作簡單,適合對view內容滑動。非平滑
- 動畫:操作簡單,主要適用於沒有互動的view和實現複雜的動畫效果
- 改變LayoutParams:操作稍微複雜,適用於有互動的view。非平滑
b.實現View彈性滑動三種方法:
①使用Scroller:
- 與scrollTo/scrollBy不同:scrollTo/scrollBy過程是瞬間完成的,非平滑;而Scroller則有過渡滑動的效果。
- 注意:Scoller本身無法讓View彈性滑動,它需要和View的computerScroller方法配合使用。
Scroller慣用程式碼:
Scroller scroller = new Scroller(mContext); //例項化一個Scroller物件
private void smoothScrollTo(int dstX, int dstY) {
int scrollX = getScrollX();//View的左邊緣到其內容左邊緣的距離
int scrollY = getScrollY();//View的上邊緣到其內容上邊緣的距離
int deltaX = dstX - scrollX;//x方向滑動的位移量
int deltaY = dstY - scrollY;//y方向滑動的位移量
scroller.startScroll(scrollX, scrollY, deltaX, deltaY, 1000); //開始滑動
invalidate(); //重新整理介面
}
@Override//計算一段時間間隔內偏移的距離,並返回是否滾動結束的標記
public void computeScroll() {
if (scroller.computeScrollOffset()) {
scrollTo(scroller.getCurrX(), scroller.getCurY());
postInvalidate();//通過不斷的重繪不斷的呼叫computeScroll方法
}
}
複製程式碼
其中startScroll原始碼如下,可見它並沒有進行實際的滑動操作,而是通過後續invalidate()方法去做滑動動作。
public void startScroll(int startX,int startY,int dx,int dy,int duration){
mMode = SCROLL_MODE;
mFinished = false;
mDuration = duration;//滑動時間
mStartTime = AnimationUtils.currentAminationTimeMills();//開始時間
mStartX = startX;//滑動起點
mStartY = startY;//滑動起點
mFinalX = startX + dx;//滑動終點
mFinalY = startY + dy;//滑動終點
mDeltaX = dx;//滑動距離
mDeltaY = dy;//滑動距離
mDurationReciprocal = 1.0f / (float)mDuration;
}
複製程式碼
- 具體過程:在MotionEvent.ACTION_UP事件觸發時呼叫startScroll方法->馬上呼叫invalidate/postInvalidate方法->會請求View重繪,導致View.draw方法被執行->會呼叫View.computeScroll方法,此方法是空實現,需要自己處理邏輯。具體邏輯是:先判斷computeScrollOffset,若為true(表示滾動未結束),則執行scrollTo方法,它會再次呼叫postInvalidate,如此反覆執行,直到返回值為false。如圖所示:
- 原理:Scroll的computeScrollOffset()根據時間的流逝動態計算一小段時間裡View滑動的距離,並得到當前View位置,再通過scrollTo繼續滑動。即把一次滑動拆分成無數次小距離滑動從而實現彈性滑動。
推薦閱讀: 站在原始碼的肩膀上全解Scroller工作機制
②通過動畫:動畫本身就是一種漸近的過程,故可通過動畫來實現彈性滑動。方法是:
ObjectAnimator.ofFloat(targetView,"translationX",0,100).setDuration(100).start();//在100ms內使得View從原始位置向右平移100畫素
複製程式碼
③使用延時策略:通過傳送一系列延時資訊從而達到一種漸近式的效果,具體可以通過Handler和View的postDelayed方法,也可使用執行緒的sleep方法。
對彈性滑動完成總時間有精確要求的使用場景下,使用延時策略是一個不太合適的選擇。
推薦閱讀:View滑動與實現滑動的幾種方法
4.View事件分發機制
a.事件分發本質:就是對MotionEvent事件分發的過程。即當一個MotionEvent產生了以後,系統需要將這個點選事件傳遞到一個具體的View上。(關於MotionEvent介紹見本篇2.a)
b.點選事件的傳遞順序:Activity(Window) -> ViewGroup -> View
c.需要的三個主要方法:
-
dispatchTouchEvent:進行事件的分發(傳遞)。返回值是 boolean 型別,受當前onTouchEvent和下級view的dispatchTouchEvent影響
-
onInterceptTouchEvent:對事件進行攔截。該方法只在ViewGroup中有,View(不包含 ViewGroup)是沒有的。一旦攔截,則執行ViewGroup的onTouchEvent,在ViewGroup中處理事件,而不接著分發給View。且只呼叫一次,所以後面的事件都會交給ViewGroup處理。
-
onTouchEvent:進行事件處理。
事件分發過程圖:
- 事件分發是逐級下發的,目的是將事件傳遞給一個View。
- ViewGroup一旦攔截事件,就不往下分發,同時呼叫onTouchEvent處理事件。
推薦閱讀:Android事件分發機制詳解(原始碼)
5.View滑動衝突
a.產生原因:
- 一般情況下,在一個介面裡存在內外兩層可同時滑動的情況時,會出現滑動衝突現象。
b.可能場景:
- 外部滑動和內部滑動方向不一致:如ViewPager巢狀ListView(實際這麼用沒問題,因為ViewPager內部已處理過)。
- 外部滑動方向和內部滑動方向一致:如ScrollView巢狀ListView(實際上也已被解決)。
- 上面兩種情況的巢狀
c.處理規則:
- 對場景一:當使用者左右/上下滑動時讓外部View攔截點選事件,當使用者上下/左右滑動時讓內部View攔截點選事件。即根據滑動的方向判斷誰來攔截事件。關於判斷是上下滑動還是左右滑動,可根據滑動的距離或者滑動的角度去判斷。
- 對場景二:一般從業務上找突破點。即根據業務需求,規定何時讓外部View攔截事件何時由內部View攔截事件。
- 對場景三:相對複雜,可同樣根據需求在業務上找到突破點。
d.解決方式:
- 法一:外部攔截法
- 含義:指點選事件都先經過父容器的攔截處理,如果父容器需要此事件就攔截,否則就不攔截。
- 方法:需要重寫父容器的onInterceptTouchEvent方法,在內部做出相應的攔截。以下是虛擬碼:
//重寫父容器的攔截方法
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://對於ACTION_DOWN事件必須返回false,一旦攔截後續事件將不能傳遞給子View
intercepted = false;
break;
case MotionEvent.ACTION_MOVE://對於ACTION_MOVE事件根據需要決定是否攔截
if (父容器需要當前事件) {
intercepted = true;
} else {
intercepted = flase;
}
break;
}
case MotionEvent.ACTION_UP://對於ACTION_UP事件必須返回false,一旦攔截子View的onClick事件將不會觸發
intercepted = false;
break;
default : break;
}
mLastXIntercept = x;
mLastYIntercept = y;
return intercepted;
}
複製程式碼
- 法二:內部攔截法:
- 含義:指父容器不攔截任何事件,而將所有的事件都傳遞給子容器,如果子容器需要此事件就直接消耗,否則就交由父容器進行處理。
- 方法:需要配合requestDisallowInterceptTouchEvent方法。以下是子View的dispatchTouchEvent方法的虛擬碼:
public boolean dispatchTouchEvent ( MotionEvent event ) {
int x = (int) event.getX();
int y = (int) event.getY();
switch (event.getAction) {
case MotionEvent.ACTION_DOWN:
parent.requestDisallowInterceptTouchEvent(true);//為true表示禁止父容器攔截
break;
case MotionEvent.ACTION_MOVE:
int deltaX = x - mLastX;
int deltaY = y - mLastY;
if (父容器需要此類點選事件) {
parent.requestDisallowInterceptTouchEvent(false);
}
break;
case MotionEvent.ACTION_UP:
break;
default :
break;
}
mLastX = x;
mLastY = y;
return super.dispatchTouchEvent(event);
}
複製程式碼
除子容器需要做處理外,父容器也要預設攔截除了ACTION_DOWN以外的其他事件,這樣當子容器呼叫parent.requestDisallowInterceptTouchEvent(false)方法時,父元素才能繼續攔截所需的事件。因此,父View需要重寫onInterceptTouchEvent方法:
public boolean onInterceptTouchEvent (MotionEvent event) {
int action = event.getAction();
if(action == MotionEvent.ACTION_DOWN) {
return false;
} else {
return true;
}
}
複製程式碼
內部攔截法要求父容器不能攔截ACTION_DOWN的原因:由於該事件並不受FLAG_DISALLOW_INTERCEPT(由requestDisallowInterceptTouchEvent方法設定)標記位控制,一旦ACTION_DOWN事件到來,該標記位會被重置。所以一旦父容器攔截了該事件,那麼所有的事件都不會傳遞給子View,內部攔截法也就失效了。
推薦閱讀:一文解決Android View滑動衝突
二.View工作原理
1.View工作流程:measure測量->layout佈局->draw繪製
- measure確定View的測量寬高
- layout確定View的最終寬高和四個頂點的位置
- draw將View 繪製到螢幕上
- 對應onMeasure()、onLayout()、onDraw()三個方法。
具體過程:
- ViewRoot對應於ViewRootImpl類,它是連線WindowManager和DecorView的紐帶。
- View的繪製流程是從ViewRoot和performTraversals開始。
- performTraversals()依次呼叫performMeasure()、performLayout()和performDraw()三個方法,分別完成頂級 View的繪製。
- 其中,performMeasure()會呼叫measure(),measure()中又呼叫onMeasure(),實現對其所有子元素的measure過程,這樣就完成了一次measure過程;接著子元素會重複父容器的measure過程,如此反覆至完成整個View樹的遍歷。layout和draw同理。過程圖如下:
補充閱讀:瞭解ViewRoot和DecorView
a.measure過程:確定測量寬高
先來理解MeasureSpec:
- 作用:通過寬測量值widthMeasureSpec和高測量值heightMeasureSpec決定View的大小
- 組成:一個32位int值,高2位代表SpecMode(測量模式),低30位代表SpecSize( 某種測量模式下的規格大小)。
- 三種模式:
- UNSPECIFIED:父容器不對View有任何限制,要多大有多大。常用於系統內部。
- EXACTLY(精確模式):父檢視為子檢視指定一個確切的尺寸SpecSize。對應LyaoutParams中的match_parent或具體數值。
- AT_MOST(最大模式):父容器為子檢視指定一個最大尺寸SpecSize,View的大小不能大於這個值。對應LayoutParams中的wrap_content。
- 決定因素:值由子View的佈局引數LayoutParams和父容器的MeasureSpec值共同決定。具體規則見下圖:
現在,分別討論兩種measure過程:
①View的measure:只有一個原始的View,通過measure()即可完成測量。過程圖見下:
從getDefaultSize()中可以看出,直接繼承View的自定義View需要重寫onMeasure()並設定wrap_content時的自身大小,否則效果相當於macth_parent。解決上述問題的典型程式碼:
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec,heightMeasureSpec);
int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSpecSize = MeasureSpec.getSize(widthMeasureSpec);
int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec);
int heightSpecSize = MeasureSpec.getSize(heightMeasureSpec);
//分析模式,根據不同的模式來設定
if(widthSpecMode == MeasureSpec.AT_MOST && heightSpecMode == MeasureSpec.AT_MOST){
setMeasuredDimension(mWidth,mHeight);
}else if(widthSpecMode == MeasureSpec.AT_MOST){
setMeasuredDimension(mWidth,heightSpecSize);
}else if(heightSpecMode == MeasureSpec.AT_MOST){
setMeasuredDimension(widthSpecSize,mHeight);
}
}
複製程式碼
補充閱讀:為什麼你的自定義View wrap_content不起作用
②ViewGroup的measure:除了完成ViewGroup自身的測量外,還會遍歷去呼叫所有子元素的measure方法。
ViewGroup中沒有重寫onMeasure(),而是提供measureChildren()。
圖片來源:自定義View Measure過程
b.layout過程:確定View的最終寬高和四個頂點的位置
- 大致流程:從頂級View開始依次呼叫layout(),其中子View的layout()會呼叫setFrame()來設定自己的四個頂點(mLeft、mRight、mTop、mBottom),接著呼叫onLayout()來確定其座標,注意該方法是空方法,因為不同的ViewGroup對其子View的佈局是不相同的。
圖片來源:自定義View Layout過程
c.draw過程:繪製到螢幕
繪製順序:
- 繪製背景:background.draw(canvas)
- 繪製自己:onDraw(canvas)
- 繪製children:dispatchDraw(canvas)
- 繪製裝飾:onDrawScrollBars(canvas)
注意:Vew有一個特殊的方法setWillNotDraw(),該方法用於設定 WILL_NOT_DRAW 標記位(其作用是當一個View不需要繪製內容時,系統可進行相應優化)。預設情況下View是沒有這個優化標誌的(設為true)。
圖片來源:自定義View Draw過程
推薦閱讀:對View工作流程的理解(原始碼)
2.自定義View
a.自定義View的型別:
b.特別提醒:
最後,因為自定義View內容非常多,這裡不再展開。最重要的是實踐,就是現在帶著理論基礎開始實戰吧~
希望這篇文章對你有幫助~