自定义View,也可以称为自定义控件,通过自定义View可以使得控件实现各种定制的效果。
实现自定义View,需要掌握View的底层工作原理,比如View的测量过程、布局流程以及绘制流程,除此之外,还需要掌握View常见的回调方法。而对于那些具有滑动效果的自定义View,我们还需要处理View的滑动,如果遇到滑动冲突则需要处理相应的滑动冲突。
下面是View的常见回调方法:
- 构造方法
- onAttach
- onVisibilityChanged
- onDetach
- onFinishInflate
- onSizeChanged
- onMeasure
- onLayout
- onTouchEvent
自定义控件的实现手段可简要分为四种类:
- 继承View重写onDraw方法,这种方法主要是用于实现一些不规则的效果,采用这种方式需要自己支持wrap_content,并且处理padding。
- 继承ViewGroup派生特殊的Layout,这种方法主要是用于实现自定义的布局,当某种效果看起来像是几个View组合在一起时,可以采用这种方法来实现。采用这种方法是需要合理处理ViewGroup的测量和布局这两个过程,并同时处理子元素的测量和布局过程。
- 继承特定的View,用于拓展已有的View的功能。
- 继承特定的ViewGroup(如LinearLayout、RelativeLayout),其适用情形和方法2 类似。
在自定义View中需要的注意点:
应当遵守Android标准控件的规范(如命名、可配置、事件处理、状态保存及恢复等)
- 命名表意明确
- 控件属性可以在XML中配置
- 让View支持wrap_content和padding(下文会具体讲到)
- 在View中尽量不使用Handler,因为View中自带post系列的方法。
- 自定义View的内存泄漏问题(如果有线程或者动画,需要及时停止)
- View的滑动冲突(在View带有滑动嵌套的情形,需要处理好滑动冲突)
- 具有一定的交互性,如按下、点击等
- 自定义View内部实现状态保存和恢复的机制
- 兼容性
下面主要从View的基本知识、View的绘制过程讲一下View的工作原理。
1.从Activity中的View结构讲起
每个Activity都含有一个Window对象,而这个Window对象一般都是PhoneWindow。PhoneWindow将以DecorView设置为整个应用窗口的根View。DecorView作为窗口界面的顶层视图,封装了一些窗口操作的通用方法。可以这么说,DecorView将要显示的具体内容呈现在了PhoneWindow中,这里面的所有的View的监听事件都是通过WindowManagerService来接收的,并通过Activity对象来回调相应的onClickListenr。
在显示上,将屏幕分成两部分,一个是TitleView,另一个是ContentView,这个ContentView想必大家都很熟悉,它是一个ID为content的FrameLayout,activity_main就是设置在这样一个FrameLayout中。
如下图1 和图2 所示:
图1
图2
View的绘制流程:
图3
如上图所示,performTraversals会依次调用performMeasure、performLayout、performDraw三个方法,这三个方法分别完成顶级View的measure、layout、draw这三大流程。
其中在performMeasure中又会调用measure,接着在measure中调用onMeasure方法,在onMeasure中会对所有的子元素进行measure过程,这个时候measure流程就从父容器传递到子元素中了,即完成依次measure操作,接着子元素进行同样的measure过程,如此方法直至完成整个View树的遍历。同理,performLayout和performDraw的传递流程和performmeasure是类似的(performDraw的传递过程是在draw方法中的dispatchDraw完成的,并无实质区别)。
measure过程决定了View的宽高,measure完成后,可以通过getMeasuredWidth和getMeasuredHeight方法来获取到View测量后的宽高,在几乎所有的情况下它都等同于View的最终高度,但特殊情况除外。Layout过程确定了View的四个顶点的坐标和实际的View的宽高,完成以后,可以通过getTop、getBottom、getLeft、getRight来得到四个顶点的位置,并可以通过getWidth和getHeight来得到View的最终宽高。Draw过程决定了View的显示,只有draw方法完成后,View的内容才会显示在屏幕上。
2.如何完成测量过程呢?
Android系统提供了一个MeasureSpec类,通过它可以帮助我们测量View。MeasureSpec是一个32位的int值,其中高2位为测量的模式,低30位为测量的大小。
EXACTLY:精确值模式, 当我们将空间的layout_width或者layout_height属性指定为具体值时,或者指定为match_parent属性时,系统使用的是EXACTLY。
AT_MOST:最大值模式,当空间的layout_width属性或者layout_height属性为wrap_content时,控件大小一般随着空间的子控件或者内容的变化而变化,此时,控件的尺寸只要不超过父控件允许的最大尺寸即可。
UNSPECIFIED:不指定其测量大小,通常情况下在绘制自定义View时才会使用它。
在view的测量过程中,系统会将LayoutParams在父容器的约束下转换为对应的MeasureSpec,然后根据这个MeasureSpec来确定View测量后的宽高。MeasureSpec由父容器和LayoutParams共同决定。
对于DecorView,其MeasureSpec由窗口的尺寸和其自身的LayoutParams决定
对于普通View,其MeasureSpec由父容器的MeasureSpec和自身的LayoutParams决定
当View的LayoutParams采用精确值时,不管父容器的MeasureSpec是什么,View的MeasureSpec模式都是EXACTLY,并且大小遵循LayoutParms的大小。
当View的宽高是match_parent模式,view的MeasureSpec模式遵循父容器的MeasureSpec模式。
当View的宽高是wrap_content,不管父容器的模式是EXACTLY还是AT_MOST,View的模式都是AT_MOST并且大小不超过父容器的剩余空间。
下面分别简要讲一下View的measure过程和ViewGroup的measure过程。
1)View的measure过程:
参考源码:
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { setMeasuredDimension(getDefaultSize(getSuggestedMinimumWidth(), widthMeasureSpec), getDefaultSize(getSuggestedMinimumHeight(), heightMeasureSpec));}
图4
由上述源码可知,在调用onMeasure方法时会调用setMeasuredDimension方法,在这个方法中会传入其宽高。由此可知,在自定义View中,需要重新定义view的宽和高。
View类默认的onMeasure方法只支持EXACTLY模式,如果在自定义控件的时候不重写onMeasure方法,就只能使用EXACTLY模式。控件可以相应你指定的具体宽高值或者match_parent属性,如果要让自定义View支持wrap_content属性,则必须要重写onMeasure方法,否则在布局中使用wrap_content就相当于使用match_parent)
2)ViewGroup的measure过程:
对于ViewGroup而言,除了完成自己的measure过程,还要遍历去调用所有子元素的measure方法,各个子元素再递归执行这个过程。
ViewGroup是一个抽象类,它没有重写View的onMeasure方法,但它提供了一个measureChildren的方法,在measureChildren方法中它会遍历ViewGroup中的子元素,并调用measureChild方法,对子元素进行measure。measureChild的思想就是取出子元素的LayoutParams,然后通过getChildMeasureSpec来创建子元素的measureSpec,最后将子元素的measureSpec传递给measure方法就能完成测量,如下图所示:
图5
正如前面提到的ViewGroup是一个抽象类,它没有重写onMeasure方法,其测量过程中的onMeasure需要其子类去具体实现。如LinearLayout、RelativeLayout。不同的ViewGroup子类的布局特性不同,这也导致其测量细节不同。
下面简要了解一下LinearLyaout和RelativeLayout的onMeasure实现
1)LinearLayout的Measure实现:
LinearLayout的布局方向有两种,所以LinearLayout会根据mOrientation来分别调用measureVertical或者是measureHorizontal。以水平布局为例,
遍历所有的view,跳过为null或者属性为View.GONE的,加上分割线宽度mDividerWidth和左右margin,计算所有View的childWidth之和mTotalLength,统计所有View的weight和totalWeight,并且对子view进行测量。
2)RelativeLayout的Measure实现:
当第一次执行onMeasure或者requestLayout后,需要调用sortChildren方法,根据添加顺序对所有的子view进行排序,横着一次,竖着一次,然后对两个序列进行检查,通过依赖图静态类中的getSortedViews方法根据依赖关系进行排序。
之后在onMeasure中,对子view进行遍历,即对两个序列进行分别遍历。
首先是横向遍历,调用mSortedHorizontalChildren,获取RelativeLayout.layoutParams,并依次调用方法,计算控件的横向位置及mLeft和mRight,然后横向测量子View,接下去根据前面的结果很想摆放子View,如果此时父RelativeLayout的宽度是WRAP_CONTENT,会在此时对宽高进行修正。
横向完毕后进行垂直排列的View序列进行上述操在,步骤大致相同,在此处会对子view进行measure时就会正确的测量,之后的操作就是对父RelativeLayout的宽高等属性进行再次修正。
从上面的分析中,一个最明显的不同就是RelativeLayout在进行measure过程中需要进行两次遍历,而LinearLayout则只需要一次遍历过程。
此外,需要注意的是,在某些极端情况下,系统可能需要调用多次measure才能确定最终的测量宽高,在这种情况下,在onMeasure方法中拿到的测量高很可能是不准确的。所以最好在onLayout方法中获取View的高宽。
3.如何获取View的宽和高
(1)调用onWindowFocusChanged方法(焦点变化),这个时候View已经初始化完毕,这个时候去获取View的宽高是没有问题的。然而当频繁进行onResume和onPause,onWindowFocusChanged方法也会被频繁调用。
(2)调用view.post(runnable)
通过post将一个Runnable投递到消息队列的尾部,然后等待Looper调用此Runnable,view也已经初始化好了。
(3)ViewTreeObserver
使用ViewTreeObserver的众多回调可以使用这个功能,如OnGlobalLayoutListener,当View树的状态发生改变或者View树的View的可见性发生改变时,OnGlobalLayoutListener会被回调,需要注意的是,伴随着View树状态的改变,onGlobalLayoutListener会被回调多次。
(4)View.measure(int widthMeasureSpec,int heightMeasureSpec)
- match_parent 不能
- 具体值和wrap_content可以。
4.Layout过程
Layout过程用于ViewGroup确定子元素的位置,当ViewGroup的位置被确定后,它在onLayout中会遍历所有的子元素,并调用其layout方法,在layout方法中onLayout方法又会被调用。
layout方法首先通过setFrame方法俩设置view的四个顶点的位置,接着调用onLayout方法,确定子元素的位置。
由于onLayout的实现同样与布局有关,因此View和ViewGroup均没有实现onLayout方法。
5.draw过程
- 将View绘制到屏幕上,大概的几个步骤:
1.绘制背景background.draw(canvas)
2.绘制自己(onDraw)
3.绘制children(dispatchDraw)
4.绘制装饰(onDrawScrollBars)
- View的绘制过程是通过dispatchDraw来实现的,它会遍历所有子元素的draw方法。
- 如果一个View不需要绘制任何内容,那么设置setWillNotDraw为true后,系统会进行相应的优化;ViewGroup默认为true,如果我们的自定义ViewGroup需要通过onDraw来绘制内容的时候,需要显示的关闭它。