Android UI —— 自定义组件

众所周知,自定义组件的第一步是继承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);

效果如下:


构造函数1使用

而如果将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"
 />

效果如下:


自定义view加背景

可见成功添加了背景色,但是也可以注意到,虽然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"
/>

界面效果如下,成功展示了在主题中设置的默认样式:


构造函数3效果
  • 不在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"
/>

效果如下:


构造函数4效果

优先级

至此我们可以看到,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"
/>

效果如下:

onDraw1

可以看到,设置的样式还在,图片也成功地绘制在了其上,因此二者应该是互不影响,系统先绘制样式,之后在其上再绘制onDraw内容。而绘制的坐标是基于MyView的。在xml中缩小MyView的长宽到30dp,效果如下:


onDraw2

注意到绘制的图片并没有随之缩小,因此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));
    }

结果如下:

QQ截图20170623162518.png

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 ()

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,378评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,356评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,702评论 0 342
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,259评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,263评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,036评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,349评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,979评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,469评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,938评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,059评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,703评论 4 323
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,257评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,262评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,485评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,501评论 2 354
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,792评论 2 345

推荐阅读更多精彩内容