Android 自定義控件之ViewGroup實例(實現一個簡易的Viewpager)

一,寫在前面     

       如何自定義一個繼承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>


子元素,content.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: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()等。


       下面會附上工程代碼,有需要哥們可以下載。一起學習,一起進步~

        工程代碼下載




         






      


       

     

         



發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章