一,寫在前面
如何自定義一個繼承ViewGroup的控件呢?在實現的過程中涉及哪些知識點?需要注意哪些地方呢?接下來以一個簡易的ViewPager來展示繼承ViewGroup的自定義控件。做出來是這樣一個效果圖,如下:
完成一個這樣的效果:水平方向由SimpleViewPager處理,豎直方向由ListView處理,SimpleViewPager有三個子元素->ListView。快速水平方向滑動時,可以進行翻頁;慢速水平滑動時,若滑動頁超過一半,則進行另一頁,否則回到原頁面。
自定義控件ViewGroup,需要了解View的測量,佈局,繪製流程。在前面博文Android自定義控件之測量onMeasure 中,從源碼角度對測量流程進行了分析;佈局流程相對比較簡單,在繼承ViewGroup時,在onLayout中設置子元素的佈局即可;繪製流程常用於繼承View的自定義控件。還需要了解Android事件分發的機制,從而去解決滑動衝突。在前面兩篇博文Android事件分發機制之ViewGroup ,Android事件處理之View$dispatchTouchEvent(ev)中對事件分發機制從源碼角度進行了解析。接下里,直接看代碼,並作分析。
二,實例展示之onMeasure
首先看重寫的onMeasure(w,h)方法,如下:
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
//先測量子控件,再測量自己;
measureChildren(widthMeasureSpec, heightMeasureSpec);
//獲取寬高的模式,大小
int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSpecSize = MeasureSpec.getSize(widthMeasureSpec);
int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec);
int heightSpecSize = MeasureSpec.getSize(heightMeasureSpec);
//獲取子view的個數
int childCount = getChildCount();
if (childCount == 0) {
//如果沒有子元素,則設置寬高大小爲0
setMeasuredDimension(0, 0);
return;
}
//獲取子View的寬,高
View childAt = getChildAt(0);
int childMeasuredWidth = childAt.getMeasuredWidth();
int childMeasuredHeight = childAt.getMeasuredHeight();
//分四種情況討論
if (widthSpecMode == MeasureSpec.AT_MOST && heightSpecMode == MeasureSpec.AT_MOST) {
//寬度設置爲3個子view寬度相加,高度設置爲一個子View高度
setMeasuredDimension(childMeasuredWidth * 3, childMeasuredHeight);
} else if (widthSpecMode == MeasureSpec.AT_MOST) {
//寬度設置爲3個子View寬度相加;
//高度爲exactly模式,直接取測量高度大小即可(分析ViewGroup$getChildMeasureSpec源碼可知)
setMeasuredDimension(childMeasuredWidth * 3, heightSpecSize);
} else if (heightSpecMode == MeasureSpec.AT_MOST){
//寬度爲exactly模式,直接取測量的寬度值;高度爲一個子View高度
setMeasuredDimension(widthSpecSize, childMeasuredHeight);
} else {
//寬高都是exactly模式,則直接使用父view給的建議值大小
setMeasuredDimension(widthSpecSize, heightSpecSize);
}
}
查看FrameLayout$onMeasure(w,h)可知,該方法做了兩件事,先測量子元素,再測量自己,測量邏輯見前面提到的博文,後面闡述會直接給結論,不再提供源碼角度的詳細分析。SimpleViewPager中有三個ListView,且這三個子元素的寬高都是一樣的,於是我們調用ViewGroup$measureChildren(w,h)方法測量三個子元素,如果子元素寬高各不相同,ViewGroup還提供了ViewGroup$measureChild(View child,int w,int h),以及measureChildWithMargins(View child,int w, int widthUsed, int h, int heightUsed)方法,分別一個個測量子元素。
那SimpleViewPager如何測量自己呢?分析FrameLayout的onMeasure(w,h)可知,測量自己需要判斷specMode,然後取值。於是,判斷寬高的測量模式,分爲4種情況,不需要考慮UNSPECIFIED模式,那麼寬高的測量模式只可能有AT_MOST和EXACTLY兩種,總共4種情況。那麼,分別爲AT_MOST和EXACTLY兩種情況時,如何設置測量大小?分析FrameLayout源碼,查看View$resolveSizeAndState方法可知:在exactly時,分別取建議寬高測量大小即可;在at_most時,寬度的大小取三個子元素的寬度之和,高的大小取一個子元素的高度。最後,調用View$setMeasuredDimension方法設置測量大小值。
注意:分析FrameLayout的onMeasure(w,h)源碼可知,容器控件測量自己大小時,與子控件測量大小,容器控件的padding,子控件的margin有關。在這裏,僅僅是與子控件的測量寬高有關,並沒有討論容器控件的padding,子控件margin的影響。若完善一個繼承自ViewGroup的自定義控件,就要考慮到這些影響了,這裏只展示一個簡單的自定義ViewGroup。
三,實例展示之onLayout
繼續看重寫的onLayout方法如下:
/* 執行到onLayout方法,說明layout已經在執行中了,那麼給自己設置佈局的操作已經完成,
onLayout只需要給子控件設置佈局。(見源碼View$layout(l,t,r,b)) */
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
//設置子元素left初始值
int left = 0;
View childAt = getChildAt(0);
childMeasuredWidth = childAt.getMeasuredWidth();
int childMeasuredHeight = childAt.getMeasuredHeight();
//給所有的子控件設置佈局
int childCount = getChildCount();
for (int i = 0; i < childCount; i++) {
View child = getChildAt(i);
if (child.getVisibility() == View.GONE) {
continue;
}
//子view的上下左右的值,均是相對於父控件的
child.layout(left, 0, left + childMeasuredWidth, childMeasuredHeight);
left += childMeasuredWidth;
}
}
只需要設置子元素的佈局,上面註釋很清楚了。需要注意的是:容器控件給子元素設置佈局時,參考FrameLayout$onLayout方法源碼可知,影響因素還有容器控件的padding,以及子控件的margin。若需要完善繼承ViewGroup的自定義控件的佈局流程,需要考慮到這些影響,這裏只展示一個簡單的自定義ViewGroup。
四,使用SimpleViewPager
現在可以在xml文件中使用SimpleViewPager控件了,如下activity_main.xml:
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity" >
<com.example.mysimpleviewpager.view.SimpleViewPager
android:id="@+id/svp"
android:layout_width="wrap_content"
android:layout_height="match_parent" />
</RelativeLayout>
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical" >
<TextView
android:id="@+id/tv"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="page"
android:background="#f12"
android:textColor="#fff"
android:textSize="24sp"
android:gravity="center_horizontal"
/>
<ListView
android:id="@+id/lv"
android:layout_width="match_parent"
android:layout_height="match_parent"></ListView>
</LinearLayout>
ListView的條目佈局,lv_item.xml文件如下:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#fff"
android:orientation="vertical" >
<TextView
android:id="@+id/tv_item"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="14sp"
android:textColor="#00f"
android:padding="10dp"
android:text="hhhhhhh"/>
</LinearLayout>
MainActivity文件如下:
public class MainActivity extends Activity {
private SimpleViewPager svp;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
initView();
}
public void initView() {
svp = (SimpleViewPager) findViewById(R.id.svp);
//向SimpleViewPager添加3個寬高相同的ListView子元素
for (int i = 0; i < 3; i++) {
View v = View.inflate(this, R.layout.content, null);
TextView tv = (TextView) v.findViewById(R.id.tv);
tv.setText("頁面" + (i+1));
//給ListView填數據
initListView(v);
//獲取手機屏幕寬度
int widthPixels = getWindowWidth();
ViewGroup.LayoutParams params = new ViewGroup.LayoutParams(widthPixels, LayoutParams.MATCH_PARENT);
//用java代碼,將view放入SimpleViewPager中
svp.addView(v, params);
}
}
private int getWindowWidth() {
DisplayMetrics outMetrics = new DisplayMetrics();
getWindowManager().getDefaultDisplay().getMetrics(outMetrics);
int widthPixels = outMetrics.widthPixels;
return widthPixels;
}
private void initListView(View v) {
ListView lv = (ListView) v.findViewById(R.id.lv);
ArrayList<String> datas = new ArrayList<String>();
for (int i = 0; i < 30; i++) {
datas.add("item" + i);
}
ArrayAdapter<String> adapter = new ArrayAdapter<String>(this, R.layout.lv_item, R.id.tv_item, datas);
lv.setAdapter(adapter);
}
}
MainActivity不再分析,看註釋應該比較好理解啦!
五,滑動翻頁效果的實現
到這裏,子元素就添加到SimpleViewPager中了,接下來處理水平滑動時,實現SimpleViewPager翻頁效果了,重寫onTouchEvent(ev),事件具體如何處理是放在該方法中的,代碼如下:
@Override
public boolean onTouchEvent(MotionEvent event) {
vTracker.addMovement(event);
int newX = (int) event.getX();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
//因爲沒有攔截,正常情況action_down不會執行,導致lastX值爲0,需要在攔截時給lastX設置初始值
break;
case MotionEvent.ACTION_MOVE:
//觸摸移動多少,控件就滑動多少
int disx = newX - lastX;
//到達左邊界,右邊界時
if (getScrollX() < 0 || getScrollX() > childMeasuredWidth * 2) {
break;
}
this.scrollBy(- disx, 0);//注意方向,加負號
break;
case MotionEvent.ACTION_UP:
//在彈起後,若超出邊界,使View滑動到邊界
if (getScrollX() < 0) {
this.scrollTo(0, 0);
break;
}
if (getScrollX() > childMeasuredWidth * 2) {
this.scrollTo(childMeasuredWidth * 2, 0);
break;
}
//手指彈起後,要完成一個頁面切換的滑動
int hasScrollX = this.getScrollX();
vTracker.computeCurrentVelocity(1000);//單位爲1000ms滑動的像素點
float xVelocity = vTracker.getXVelocity();
//速度大於50px/1000ms時,根據方向,展示下一個頁面
if (xVelocity < - 50) {
if (hasScrollX < childMeasuredWidth) {
//頁面2
scroller.startScroll(hasScrollX, 0, childMeasuredWidth - hasScrollX, 0, 1000);
} else if (hasScrollX > childMeasuredWidth || hasScrollX < childMeasuredWidth * 2) {
//頁面3
scroller.startScroll(hasScrollX, 0, 2 * childMeasuredWidth - hasScrollX, 0, 1000);
}
} else if (xVelocity > 50) {
if (hasScrollX < childMeasuredWidth) {
//頁面1
scroller.startScroll(hasScrollX, 0, -hasScrollX, 0, 1000);
} else if (hasScrollX > childMeasuredWidth || hasScrollX < childMeasuredWidth * 2) {
//頁面2
scroller.startScroll(hasScrollX, 0, childMeasuredWidth - hasScrollX, 0, 1000);
}
} else {
//不考慮速度
//彈起時,判斷已滑動距離,選擇三個頁面中一個展示
if (hasScrollX < childMeasuredWidth / 2) {
//頁面1
scroller.startScroll(hasScrollX, 0, -hasScrollX, 0, 1000);
} else if (hasScrollX >= childMeasuredWidth / 2 && hasScrollX < childMeasuredWidth * 3 / 2) {
//頁面2
scroller.startScroll(hasScrollX, 0, childMeasuredWidth - hasScrollX, 0, 1000);
} else {
//頁面3
scroller.startScroll(hasScrollX, 0, 2 * childMeasuredWidth - hasScrollX, 0, 1000);
}
}
invalidate();
break;
default:
break;
}
lastX = newX;
return true;
}
當事件爲action_down時,ViewGroup不會攔截事件,否則接下里的事件序列只能由ViewGroup處理。因此action_down事件不會交給SimpleViewPager處理,導致lastX值爲0,需要在攔截時給lastX設置初始值。簡單解釋下MotionEvent$getX(),獲取的值是觸摸的位置相對於SimpleViewPager左上角頂點的距離。
當事件爲action_move時,滑動不能超過左右邊界,於是做了getScrollX() < 0 || getScrollX() > childMeasuredWidth * 2這樣的判斷。簡單解釋下View$getScrollX(),可以理解爲物理中的:總位移(包括多個事件序列中,相對於未滑動時的位移),所以getScrollX()的值處在0~childMeasuredWidth * 2之間。而View$scrollBy(x,y)指的是某一小段時間內,滑動的距離,有方向。注意:getScrollX(),scrollBy(x,y)的方向是手機屏幕滑動的方向,而手指滑動的方向(也是控件滑動方向)與手機屏幕滑動方向相反。因此,手指往左滑,scrollBy(x,y)的x的值是正數,往右滑是負數。
當事件爲action_up事件時,若擡起的時候SimpleViewPager超出邊界,則調用View$scrollTo(x,y)將SimpleViewPager滑動到邊界位置。需要了解的是,scrollTo(x,y)方法是將控件滑動到某一個位置(x,y),x,y的值與getScrollX(),getScrollY()相同。在手指擡起後,一般當前界面顯示兩個頁面各一部分,需要利用彈性滑動的方式處理SimpleViewPager控件,確定界面最終顯示哪一頁。這裏設定規則是這樣的:快速水平方向滑動時,可以進行翻頁;慢速水平滑動時,若滑動頁超過一半,則進行另一頁,否則回到原頁面。具體代碼如上。
接下來,介紹下速度跟蹤器VelocityTracker的用法以及Scroller如何實現彈性滑動的,這樣上面代碼就很好理解了。
VelocityTracker基本用法:
1,VelocityTracker對象初始化:VelocityTracker.obtain();
2,將MotionEvent對象添加到速度跟蹤器中處理,vTracker.addMovement(event);
3,計算速度,調用方法vTracker.computeCurrentVelocity(1000);表示1000ms劃過的像素。若傳入參數值爲1,表示1ms劃過的像素。
4,取出x/y方向的速度值,以x爲例,調用方法vTracker.getXVelocity();值得注意是,正數代表控件右滑,負數代表控件左滑,與,scrollBy(x,y)方向相反。
Scroller實現彈性滑動:
1,初始化Scroller:scroller = new Scroller(context);
2,調用scroller.startScroll(int startX, int startY, int dx, int dy, int duration);
startX:指水平方向已經滑動的偏移量,有方向,就是View$getScrollX()的值,startY同理。
dx:指彈性滑動期間,水平方向上滑動的偏移量,有方向;正數表示控件左滑,負數表示控件右滑。(實際指手機屏幕滑動方向) ---dy同理
duration:指彈性滑動期間,手動設置的時間,單位ms。設置越長,那麼滑動越緩慢(滑動的偏移量不變情況下)
3,調用View$invalidate():它會調用Draw()方法,Draw()又會調用computeScroll方法。
4,重寫computeScroll方法:對view進行真實的滑動操作,並不斷重複該操作。
重寫computeScroll方法,代碼如下:
@Override
public void computeScroll() {
if (scroller.computeScrollOffset()) {
int currX = scroller.getCurrX();
this.scrollTo(currX, 0);
postInvalidate();
}
}
查看scroller.computeScrollOffset()源碼,有這樣一段:if (mFinished) { return false;}。即,當彈性滑動結束時,返回false。那麼,當scroller.computeScrollOffset()返回true時,繼續執行滑動操作。事實上,該方法進行相關計算後,對字段mCurrX,mCurrY設置值。查看scroller.getCurrX()源碼如下:
/**
* Returns the current X offset in the scroll.
*
* @return The new X offset as an absolute distance from the origin.
*/
public final int getCurrX() {
return mCurrX;
}
mCurrX的值爲View相對於未滑動時的偏移量,於是調用View$scrollTo(currX, 0);完成View的真是滑動操作。最後調用View$postInvalidate()方法,重複上面的操作,直到scroller.computeScrollOffset()返回false,滑動結束。
六,滑動衝突的處理
demo開發到這裏,還有一個問題,我們希望SimpleViewPager處理水平方向的滑動,它的子控件ListView處理豎直方向的滑動。但是,由於SimpleViewPager的onInterceptTouchEvent方法默認返回false,也就是不攔截。那麼觸摸事件最後會繼續傳遞下去,直到有容器控件想攔截,或者一直傳遞到view,這裏會遵循事件分發機制去處理該事件,具體分析可以參考文章前面提到的blog。實際上,運行程序後,發現SimpleViewPager無法左右滑動,ListView可以正常上下滑動。這裏涉及到滑動衝突了,於是重寫onInterceptTouchEvent(ev)對事件進行攔截,代碼如下:
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
boolean intercept = false;
//x,y值均相對於view的左上角頂點位置
int newX = (int) ev.getX();
int newY = (int) ev.getY();
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN:
//不能攔截action_down事件,否則action_move/up事件不會向子元素傳遞
//(這裏直接給結論,原因見源碼ViewGroup$dispatchTouchEvent(ev))
Log.e("wang", "onInterceptTouchEvent_action_down");
intercept = false;
break;
case MotionEvent.ACTION_MOVE:
int disX = newX - oldX;
int disY = newY - oldY;
//當觸摸移動水平方向距離>豎直方向時,攔截事件
if (Math.abs(disX) > Math.abs(disY)) {
Log.e("wang", "onInterceptTouchEvent_action_move");
intercept = true;
} else {
intercept = false;
}
break;
case MotionEvent.ACTION_UP:
//如果攔截了up事件,那麼子元素無法正常處理up事件;但並不影響SimpleViewPager處理up事件
//(這裏直接給結論,原因見ViewGroup$dispatchTouchEvent(ev)的源碼)
Log.e("wang", "onInterceptTouchEvent_action_up");
intercept = false;
break;
default:
break;
}
lastX = newX;
Log.e("wang", "oninter_lastX:" + lastX);
oldX = newX;
oldY = newY;
return intercept;
}
相信代碼裏註釋已經解釋很清楚了,順便提一下,若不在適當時機調用ViewGroup$requestDisallowInterceptTouchEvent(boolean)去修改mGroupFlags的值,不管子控件前面有沒有消費action_down/move事件,父控件都可以決定是否攔截action_move/up事件,也就是調用onInterceptTouchEvent(ev)。注意:action_down事件,父控件肯定會調用onInterceptTouchEvent(ev),不管mGroupFlags的值修改與否。這裏直接提出了事件分發的一些結論,有疑惑的哥們,可以閱讀文章Android事件分發機制之ViewGroup
值得一提的是,上面處理滑動衝突的方式屬於外部攔截法,這種方式比較符合Android事件分發機制的正向思維,還有一種內部攔截法,兩種方式都有自己的用武之地。至於用哪種取決於實際開發中需求,有的必須要使用外部攔截法(例如本篇文章,如果要使用內部攔截法,需要自定義一個ListView,這樣很沒有必要),也有的必須要使用內部攔截法。兩種都可以方便使用的情況,憑個人喜好吧。開心最重要,嘻嘻(#^.^#)
本篇文章實現的demo儘管並不是高大上,需要掌握的東西還是比較多的。原理上需要掌握比如:View的測量,佈局,繪製流程;事件分發機制。偏向工具型的類比如:Scroller,VelocityTracker等。View的一些相關的方法比如:scrollBy(x,y),scrollTo(x,y),getScrollX()/getScrollY()等。
下面會附上工程代碼,有需要哥們可以下載。一起學習,一起進步~