众所周知,自定义组件的第一步是继承android.view.View类,然后重写其中一些方法来实现自定义功能。在官网文档说明自定义view的部分列出了一些方法。我理解是一些比较常用的需要复写的方法,因此本篇就来详解下这些方法,包括构造器、onDraw、onMeasure、事件响应等。View类中的方法和域是非常多的,其他的以后找机会再详解。
View的构造器
View (Context context)
Simple constructor to use when creating a view from code.
也就是说,这个构造函数可以在代码新建view的时候使用,而无法在xml中使用。下面来自定义一个只有此构造函数的view,然后在复写onDraw(之后细讲)来写行字:
class MyView extends View {
public MyView(Context context) {
super(context);
}
@Override
public void onDraw(Canvas c){
Paint p = new Paint();
p.setColor(Color.BLUE);
p.setTextSize(50);
c.drawText("自定义view",50,50,p);
}
}
之后在代码中生成一个MyView并加入当前界面:
ViewGroup.LayoutParams p = findViewById(R.id.container).getLayoutParams();
p.height = ViewGroup.LayoutParams.WRAP_CONTENT;
p.width = ViewGroup.LayoutParams.MATCH_PARENT;
this.addContentView(new MyView(this),p);
效果如下:
而如果将onDraw中的内容去掉,界面会变为空白。而假如在xml中使用这个自定义view,在运行时会报:
java.lang.RuntimeException: Unable to start activity ComponentInfo{com.tgtpp.themandsytle/com.tgtpp.themandsytle.MainActivity}: android.view.InflateException: Binary XML file line #21: Binary XML file line #21: Error inflating class com.tgtpp.themandsytle.MainActivity.MyView
因此仅有这个构造函数,那么这个View默认没有任何样式,只能通过编写代码来令其展示内容与样式,且只能在代码中调用。
View (Context context, AttributeSet attrs)
Constructor that is called when inflating a view from XML. This is called when a view is being constructed from an XML file, supplying attributes that were specified in the XML file. This version uses a default style of 0, so the only attribute values applied are those in the Context's Theme and the given AttributeSet.
使用这个构造函数后,便可在xml中使用这个自定义view。为MyView增加构造函数:
public class MyView extends View {
public MyView(Context context) {
super(context);
}
public MyView(Context context, AttributeSet attributeSet){
super(context, attributeSet);
}
@Override
public void onDraw(Canvas c){
super.onDraw(c);
Paint p = new Paint();
p.setColor(Color.BLUE);
p.setTextSize(50);
c.drawText("自定义view",50,50,p);
}
}
然后在xml中引用此类:
<com.tgtpp.themandsytle.MyView
android:layout_width="match_parent"
android:layout_height="wrap_content"
/>
此时已经可以在android studio的xml的design界面看到这个view的预览了,令自定义view可以用于xml的好处就是可以方便的设置属性。在xml中为此View添加background:
<com.tgtpp.themandsytle.MyView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="#00ffff"
/>
效果如下:
可见成功添加了背景色,但是也可以注意到,虽然layout_height设置为wrap_content,但是高度并非是想象中包围文字的。想达到此效果,还应更详细地重写其他函数。而至于新增的AttributeSet参数,看了下相关的使用还是比较复杂的,应该是为了可以将xml中设置的属性传递给View,一般来似乎不需要处理。因此之后有时间再细看这个。
View (Context context, AttributeSet attrs, int defStyleAttr)
Perform inflation from XML and apply a class-specific base style from a theme attribute. This constructor of View allows subclasses to use their own base style when they are inflating. For example, a Button class's constructor would call this version of the super class constructor and supply R.attr.buttonStyle for defStyleAttr; this allows the theme's button style to modify all of the base view attributes (in particular its background) as well as the Button class's attributes.
既然官方文档里举了Button的例子,那么就来看下Button的源码:
@RemoteView
public class Button extends TextView {
public Button(Context context) {
this(context, null);
}
public Button(Context context, AttributeSet attrs) {
this(context, attrs, com.android.internal.R.attr.buttonStyle);
}
public Button(Context context, AttributeSet attrs, int defStyleAttr) {
this(context, attrs, defStyleAttr, 0);
}
public Button(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
}
@Override
public CharSequence getAccessibilityClassName() {
return Button.class.getName();
}
}
可以看到是在第二个构造函数中,向父类传递了com.android.internal.R.attr.buttonStyle作为第三个参数。如果看过我上一篇博文的,可能会对buttonStyle有些印象:在应用的theme中重新设定buttonStyle,可以使得全局button的样式改变。参考Button实现方式,将com.android.internal.R.attr.buttonStyle传入自定义View:
public class MyView extends View {
public MyView(Context context) {
super(context);
}
public MyView(Context context, AttributeSet attributeSet){
super(context, attributeSet, com.android.internal.R.attr.buttonStyle);
}
@Override
public void onDraw(Canvas c){
super.onDraw(c);
}
}
然而却报了错:Error:(22, 60) 错误: 程序包com.android.internal.R不存在
,网上查询说这是个隐藏的类,看来不能直接使用。由于buttonStyle是一个属性,因此我们也自定义一个属性:
<declare-styleable name="MyView">
<attr name="defaultStyleAttr" format="reference" />
</declare-styleable>
这里顺便插一句,我这里declare-styleable的name写的是我自定义View的名称,android studio提示和官网相关教程都是写的对应自定义类的名称。事实上不对应view的名称也是可以正常编译,在本篇case中也能正常运行。其影响就是,在xml中使用自定义类时,无法在其中使用自定义的这个属性。反之,和view对应起来,就可以通过app:属性名引用到:
<com.tgtpp.themandsytle.MyView
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:defaultStyleAttr=""
/>
回到本次主题,属性定义好,在我们的类中使用:
public class MyView extends View {
public MyView(Context context) {
super(context);
}
public MyView(Context context, AttributeSet attributeSet){
super(context, attributeSet, R.attr.defaultStyleAttr);
}
@Override
public void onDraw(Canvas c){
super.onDraw(c);
}
}
目前属性定义了,还需要找个地方赋值:
- 官方说明提到了theme,那么就先尝试在应用主题中赋值,这里暂时借用button的默认样式Widget.Button:
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
<!-- Customize your theme here. -->
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
<item name="defaultStyleAttr">@android:style/Widget.Button</item>
</style>
在xml中使用自定义界面并设置合适的长宽:
<com.tgtpp.themandsytle.MyView
android:layout_width="50dp"
android:layout_height="50dp"
/>
界面效果如下,成功展示了在主题中设置的默认样式:
- 不在theme中设置,而是在使用view的时候设置此属性:
<com.tgtpp.themandsytle.MyView
android:layout_width="50dp"
android:layout_height="50dp"
app:defaultStyleAttr="@android:style/Widget.Button"
/>
然而却报错:Failed to find style 'defaultStyleAttr' in current theme
。看来必须要在主题中设置这个属性才可以。
总结下:View的第三个构造函数的第三个参数,接收一个在当前主题中指定的样式,以此方式令所有View的实例具有相同的默认样式。若想自定义,需在自定义属性后,在当前主题中为此自定义属性赋值,然后将此属性的ID传入View的构造函数的第三个参数。这样自定义View便可如Button等具有默认样式。
最后尝试下如果只有第三个构造函数会怎样。结果报错:
java.lang.RuntimeException: Unable to start activity ComponentInfo{com.tgtpp.themandsytle/com.tgtpp.themandsytle.MainActivity}: android.view.InflateException: Binary XML file line #22: Binary XML file line #22: Error inflating class com.tgtpp.themandsytle.MyView
看来希望可以正确inflate自定义View必须要有前两个构造函数,第三个构造函数是类似Button那样使用的。
View (Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes)
Perform inflation from XML and apply a class-specific base style from a theme attribute or style resource. This constructor of View allows subclasses to use their own base style when they are inflating.
也就是直接传入一个style的id,使得自定义View使用这个样式。这里随便找一个样式R.style.Widget_AppCompat_Button传入,注意到这个构造函数只支持api 21以上:
public class MyView extends View {
public MyView(Context context) {
super(context);
}
@TargetApi(21)
public MyView(Context context, AttributeSet attributeSet){
super(context, attributeSet, 0, R.style.Widget_AppCompat_Button);
}
@Override
public void onDraw(Canvas c){
super.onDraw(c);
}
}
然后在xml中使用MyView:
<com.tgtpp.themandsytle.MyView
android:layout_width="50dp"
android:layout_height="50dp"
/>
效果如下:
优先级
至此我们可以看到,View的构造函数的后三个参数都可以决定此view的样式,但是它们之间是有优先级的,如官网所说:
When determining the final value of a particular attribute, there are four inputs that come into play:
1.Any attribute values in the given AttributeSet.
2.The style resource specified in the AttributeSet (named "style").
3.The default style specified by defStyleAttr.
4.The default style specified by defStyleRes.
5.The base values in this theme.
Each of these inputs is considered in-order, with the first listed taking precedence over the following ones. In other words, if in the AttributeSet you have supplied <Button * textColor="#ff000000"> , then the button's text will always be black, regardless of what is specified in any of the styles.
思考
那么如果利用这四个构造函数优雅地自定义view呢?我想可以这样:先自定义一个默认样式,将id传递个第四个defStyleRes参数,这样view就有了默认样式。然后自定义attr,并将此id传递给第三个参数defStyleAttr,这样如果其他人使用这个自定义view,需要根据主题更改样式,就可以直接在theme中重定义此attr即可。使用第四个参数的好处是在一些简单的应用下,不需再编写theme。可惜如果需要支持低版本api,是无法这样做的。
Drawing
onDraw(android.graphics.Canvas)
这个接口提供了一个Canvas,也就是一个画布,可以在其上绘制想要绘制的内容。为了不发散太多,就不细讲这个类了。上文有一个写字的例子可以参考下。现在来看下这边绘制的内容和View及样式的关系。依旧使用R.style.Widget_AppCompat_Button作为默认样式,现在在onDraw中随意画个图片:
@Override
public void onDraw(Canvas c){
super.onDraw(c);
Bitmap b = BitmapFactory.decodeResource(getResources(),R.mipmap.ic_launcher);
c.drawBitmap(b,0,0,null);
}
在xml里调大MyView,并设置下margin:
<com.tgtpp.themandsytle.MyView
android:layout_width="200dp"
android:layout_height="200dp"
android:layout_margin="30dp"
/>
效果如下:
可以看到,设置的样式还在,图片也成功地绘制在了其上,因此二者应该是互不影响,系统先绘制样式,之后在其上再绘制onDraw内容。而绘制的坐标是基于MyView的。在xml中缩小MyView的长宽到30dp,效果如下:
注意到绘制的图片并没有随之缩小,因此onDraw中的绘制不会像设置的style那样自动适配view,还需手动进行相关设置。
Layout
void onSizeChanged (int w, int h, int oldw, int oldh)
当第一次确定当前view的大小或者大小改变时,会调用到此函数。w、h表示当前长宽,oldw、oldh表示之前长宽,第一次确定大小时这两个值为0。如果只是想简单控制view的绘制内容,用这个即可。这边几个参数的单位都是像素。
void onLayout (boolean changed, int left, int top, int right, int bottom)
当第一次确定当前view的大小或者大小改变时,会调用到此函数。与onSizeChanged不同的是,这个函数提供的left\top\right\bottom分别是相对于父view的左上和右下的坐标。比如前文MyView长宽为30dp,magin为30dp,在dpi为480的情况下,传入的left\top\right\bottom分别是90\90\180\180,单位为像素。
void onMeasure (int widthMeasureSpec, int heightMeasureSpec)
想要更精确地控制,则可使用此函数。两个参数是“packed”,即一个整数表示很多值,需要解析才能获得有意义的值。
View.MeasureSpec
使用此类来解析onMeasure中的两个参数。可以解析出两个内容:size和mode。size就是长宽的值,mode描述了view的parent对其的限制,包括AT_MOST\EXACTLY\UNSPECIFIED(在某个范围内想多大多大\指定了具体值\没有任何限制)。
针对前文MyView长宽为30dp的情况,调用onMeasure并解析,方式就是将onMeasure中获得的参数传入View.MeasureSpec的getSize、getMode等函数中:
@Override
public void onMeasure (int widthMeasureSpec, int heightMeasureSpec){
super.onMeasure(widthMeasureSpec,heightMeasureSpec);
Log.i("TEST", "width:size,mode,string:"+View.MeasureSpec.getSize(widthMeasureSpec)+","+View.MeasureSpec.getMode(widthMeasureSpec)+","+View.MeasureSpec.toString(widthMeasureSpec));
Log.i("TEST", "height:size,mode,string:"+View.MeasureSpec.getSize(heightMeasureSpec)+","+View.MeasureSpec.getMode(heightMeasureSpec)+","+View.MeasureSpec.toString(heightMeasureSpec));
}
结果如下:
Event processing
这边事件处理其实包括了对于多种硬件设备的处理。对于目前占绝大多数的触屏智能机,处理点击事件使用:
boolean onTouchEvent (MotionEvent event)
而对于那种老式有硬件键盘的设备,提供了对按键的响应,并且文档中特别说明,此响应并不一定对软键盘起作用,不要用它们处理软键盘的点击事件:
boolean onKeyDown (int keyCode, KeyEvent event)
boolean onKeyUp (int keyCode, KeyEvent event)
这里还有对“trackball”事件的处理,也就是那种滚轮样设备:
boolean onTrackballEvent (MotionEvent event)
Focus
void onFocusChanged (boolean gainFocus, int direction, Rect previouslyFocusedRect)
focus就是类似那种输入框出现的时候会直接有输入提示在那边,这就是获得了焦点。在onCreate中使用requestFocus令MyView获得焦点,然后打印onFocusChanaged的三个参数:
@Override
public void onFocusChanged (boolean gainFocus,
int direction,
Rect previouslyFocusedRect){
super.onFocusChanged(gainFocus,direction,previouslyFocusedRect);
Log.i("TEST", "gainFocurs,direction,rect:"+gainFocus+","+direction+","+previouslyFocusedRect);
}
然而奇怪的是,打印结果总是gainFocus先是true,然后马上变为false,而previouslyFocusedRect一直为null:
06-23 18:36:21.482 30740-30740/com.tgtpp.themandsytle I/TEST: gainFocurs,direction,rect:true,130,null
06-23 18:36:21.487 30740-30740/com.tgtpp.themandsytle I/TEST: gainFocurs,direction,rect:false,0,null
尝试在xml中为MyView添加android:focusable="true"
,没有效果;添加android:focusableInTouchMode="true"
后,focus不再变为false。应该是因为在touch模式下不能requestFocus,所以会自动置focus为false。而previouslyFocusedRect多次尝试,一直为null,这个问题之后有空再研究。
void onWindowFocusChanged (boolean hasWindowFocus)
经试验,在应用被完全展现出来的时候会被调用且hasWindowFocus为true;缩小或消失会被调用且hasWindowFocus为false。
Attaching
当view和window相连之前和之后调用如下方法,一般说明view已经拥有或失去了绘制的空间:
void onAttachedToWindow ()
void onDetachedFromWindow ()
当windows可见性变化时调用如下方法:
void onDetachedFromWindow ()