Android 自定义控件之ViewGroup实例(实现一个简易的Viewpager)

来源:互联网 发布:涉外商标代理人 知乎 编辑:程序博客网 时间:2024/04/19 18:40

一,写在前面     

       如何自定义一个继承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)方法,如下:

@Overrideprotected 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) {//如果没有子元素,则设置宽高大小为0setMeasuredDimension(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)) */@Overrideprotected 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;@Overrideprotected 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),事件具体如何处理是放在该方法中的,代码如下:

@Overridepublic 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) {//页面2scroller.startScroll(hasScrollX, 0, childMeasuredWidth - hasScrollX, 0, 1000);} else if (hasScrollX > childMeasuredWidth || hasScrollX < childMeasuredWidth * 2) {//页面3scroller.startScroll(hasScrollX, 0, 2 * childMeasuredWidth - hasScrollX, 0, 1000);}} else if (xVelocity > 50) {if (hasScrollX < childMeasuredWidth) {//页面1scroller.startScroll(hasScrollX, 0, -hasScrollX, 0, 1000);} else if (hasScrollX > childMeasuredWidth || hasScrollX < childMeasuredWidth * 2) {//页面2scroller.startScroll(hasScrollX, 0, childMeasuredWidth - hasScrollX, 0, 1000);}} else {//不考虑速度//弹起时,判断已滑动距离,选择三个页面中一个展示if (hasScrollX < childMeasuredWidth / 2) {//页面1scroller.startScroll(hasScrollX, 0, -hasScrollX, 0, 1000);} else if (hasScrollX >= childMeasuredWidth / 2 && hasScrollX < childMeasuredWidth * 3 / 2) {//页面2scroller.startScroll(hasScrollX, 0, childMeasuredWidth - hasScrollX, 0, 1000);} else {//页面3scroller.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方法,代码如下:

@Overridepublic 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)对事件进行拦截,代码如下:

@Overridepublic 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()等。


       下面会附上工程代码,有需要哥们可以下载。一起学习,一起进步~

        工程代码下载




         






      


       

     

         



阅读全文
0 0