Android--自定义控件解析(二)

1、前言

在上篇博客中我主要介绍了自定义属性的定义和获取,还有如何在布局文件添加我们的自定义控件。这几乎是自定义控件中必不可少的两步,而onMeasure()、onDraw()方法如果是在我们讲的TopBar这样的只需修改几个属性的控件中使用是可以不做的。onLayout()就更不必说了,它是来设置子View的位置的。所以这篇博客我会仔细讲解这几个方法。

2、onMeasure解析

我们在TopBar中继承的是RelativeLayout,所以已经复写好了View类中的onMeasure方法,所以我们在用的时候不会出现宽高的问题,但是如果继承的是View或ViewGroup的话,就必须复写onMeasure()方法了,我们来看看View中的onMeasure()的代码:

protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    setMeasuredDimension(getDefaultSize(getSuggestedMinimumWidth(), widthMeasureSpec),
            getDefaultSize(getSuggestedMinimumHeight(), heightMeasureSpec));
}

系统帮我们测量的高度和宽度都是MATCH_PARNET,当我们设置明确的宽度和高度时,系统帮我们测量的结果就是我们设置的结果,当我们设置为WRAP_CONTENT,或者MATCH_PARENT系统帮我们测量的结果就是MATCH_PARENT的长度。

所以,当设置了WRAP_CONTENT时,我们需要自己进行测量,即重写onMesure方法,再看看RelativeLayout的代码,这个比较长,就用动图演示一下,大家有兴趣可以自己去看源代码:

大家也不用管代码的功能是什么,只要知道RelativeLayout已经帮我们考虑了很多情况,我们不用太考虑宽高的测量。

对于onMeasure(),首先我们要理解的是widthMeasureSpec, heightMeasureSpec这两个参数,onMeasure()由包含这个View的具体的ViewGroup调用,因此值也是从这个ViewGroup中传入的。

子View的这两个参数,由ViewGroup中的layout_width,layout_height和padding以及View自身的layout_margin共同决定。权值weight也是尤其需要考虑的因素,有它的存在情况可能会稍微复杂点。

了解了这两个参数的来源,还要知道这两个值的作用。拿heightMeasureSpec来说,这个值由高32位和低16位组成,高32位保存的值叫specMode,可以通过MeasureSpec.getMode()获取;低16位为specSize,同样可以由MeasureSpec.getSize()获取。

那么specMode和specSize的作用有是什么呢?要想知道这一点,我们需要知道所有的View的onMeasure()的最后一行都会调用的setMeasureDimension()方法的作用——这个函数调用中传进去的值是View最终的视图大小。也就是说onMeasure()中之前所作的所有工作都是为了最后这一句话服务的。

我们知道在ViewGroup中,给View分配的空间大小并不是确定的,有可能随着具体的变化而变化,而这个变化的条件就是传到specMode中决定的,specMode一共有三种可能:

  • MeasureSpec.EXACTLY:父视图希望子视图的大小应该是specSize中指定的。一般是设置了明确的值或者是MATCH_PARENT。
  • MeasureSpec.AT_MOST:子视图的大小最多是specSize中指定的值,也就是说不建议子视图的大小超过specSize中给定的值。表示子布局限制在一个最大值内,一般为WARP_CONTENT。
  • MeasureSpec.UNSPECIFIED:我们可以随意指定视图的大小,表示子布局想要多大就多大,很少使用。

我们写个例子,打印一下log,这样能让大家更能理解,specMode如何取值,specSize如何计算:

public class CustomView extends View {

    public CustomView(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {

        int heightSize = MeasureSpec.getSize(heightMeasureSpec);
        int heightMode = MeasureSpec.getMode(heightMeasureSpec);
        int widthSize = MeasureSpec.getSize(widthMeasureSpec);
        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
        Log.d("TAG", "---widthSize = " + widthSize + "");
        Log.d("TAG", "---heightSize = " + heightSize + "");
        if(widthMode == MeasureSpec.AT_MOST){
            Log.d("TAG", "---AT_MOST---");
        }
        if(widthMode == MeasureSpec.EXACTLY){
            Log.d("TAG", "---EXACTLY---");
        }
        if(widthMode == MeasureSpec.UNSPECIFIED){
            Log.d("TAG", "---UNSPECIFIED---");
        }
        if(heightMode == MeasureSpec.AT_MOST){
            Log.d("TAG", "---AT_MOST---");
        }
        if(heightMode == MeasureSpec.EXACTLY){
            Log.d("TAG", "---EXACTLY---");
        }
        if(heightMode == MeasureSpec.UNSPECIFIED){
            Log.d("TAG", "---UNSPECIFIED---");
        }
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
    }
}
<?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">

    <LinearLayout
        android:layout_width="200dp"
        android:layout_height="300dp"
        android:layout_marginTop="30dp"
        android:background="@android:color/darker_gray"
        android:paddingTop="20dp">

        <com.ht.animator.CustomView
            android:layout_width="150dp"
            android:layout_height="match_parent"
            android:layout_marginTop="15dp"
            android:background="@android:color/holo_red_light"
            android:paddingTop="10dp" />

    </LinearLayout>
</LinearLayout>

log的输出为:

可以看到specMode的值的确如我介绍的那样,layout_height和layout_width的值为match_parent或精确的值则对应的specMode为EXACTLY,wrap_content没有测试但就是AT_MOST,大家可以自己试试。这个值与父视图的layout_width是没有关系的。

有点要注意的就是,xml中用的单位是dp,log中得到的单位是px,我所使用的虚拟机的dpi为360,所以屏幕密度为2.0,只需要进行简单的换算即得px = 2.0 * dp。

widthSize = 2.0 * layout_width = 300;
heightSize = layout_height(LinearLayout) * 2.0 - paddingTop(LinearLayout) * 2.0 - layout_marginTop * 2.0 = 600 - 40 - 30 = 530。

影响heightSize的因素为:父视图的layout_height和paddingTop以及自身的layout_marginTop。但是我们不要忘记有weight时的影响。

3、TopBar测量

这篇博客的例子同样是自定义TopBar,不过这次我们就不继承RelativeLayout了,这意味着我们必须重写它的onMeasure()。不过在写onMeasure()方法之前,我们要先了解测量都需要用到什么样的参数。我觉得首先肯定是各个控件的宽高,然后我们有可能为我们的控件在父布局中设置margin,父布局中也会设置padding,所以我们要计算的应该就是这几个参数的值。对于我们这个例子,我们只需要ViewGroup能够支持margin即可,那么我们直接使用系统的MarginLayoutParams。

@Override
public ViewGroup.LayoutParams generateLayoutParams(AttributeSet attrs)
{
    return new MarginLayoutParams(getContext(), attrs);
}

重写父类的该方法,返回MarginLayoutParams的实例,这样就为我们的ViewGroup指定了其LayoutParams为MarginLayoutParams。我们就可以直接使用margin了。

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {

    int widthMode = MeasureSpec.getMode(widthMeasureSpec);
    int heightMode = MeasureSpec.getMode(heightMeasureSpec);
    int sizeWidth = MeasureSpec.getSize(widthMeasureSpec);
    int sizeHeight = MeasureSpec.getSize(heightMeasureSpec);

    measureChildren(widthMeasureSpec, heightMeasureSpec);

    int width = 0;
    int height = 0;

    int cCount = getChildCount();

    int cWidth = 0;
    int cHeight = 0;
    MarginLayoutParams cParams = null;

    for (int i = 0; i < cCount; i++) {
        View childView = getChildAt(i);
        cWidth = childView.getMeasuredWidth();
        cHeight = childView.getMeasuredHeight();
        cParams = (MarginLayoutParams) childView.getLayoutParams();

        width += cWidth + cParams.leftMargin + cParams.rightMargin;
        height = Math.max(height, cHeight + cParams.topMargin + cParams.bottomMargin);
    }

    setMeasuredDimension((widthMode == MeasureSpec.EXACTLY) ? sizeWidth
                         : width, (heightMode == MeasureSpec.EXACTLY) ? sizeHeight
                         : height);
}

首先获取该ViewGroup父容器为其设置的计算模式和尺寸,大多情况下,只要不是wrap_content,父容器都能正确的计算其尺寸。所以我们自己需要计算如果设置为wrap_content时的宽和高,那就是通过其childView的宽和高来进行计算。

通过ViewGroup的measureChildren()方法为其所有的孩子设置宽和高,此行执行完成后,childView的宽和高都已经正确的计算过了。

根据childView的宽和高,以及margin,计算ViewGroup在wrap_content时的宽和高。

最后,如果宽高属性值为wrap_content,则设置为我们计算的值,否则为其父容器传入的宽和高。

因为我们要完成的是TopBar,所以我们都是设置为match_parent,这种写法,仅供演示。

4、onLayout

onLayout方法是ViewGroup中子View的布局方法,用于放置子View的位置。放置子View很简单,只需在重写onLayout方法,然后获取子View的实例,调用子View的layout方法实现布局。在实际开发中,一般要配合onMeasure测量方法一起使用。

onLayout方法:

@Override
protected abstract void onLayout(boolean changed,
            int l, int t, int r, int b);

该方法在ViewGroup中定义是抽象函数,继承该类必须实现onLayout方法,而ViewGroup的onMeasure并非必须重写的。View的放置都是根据一个矩形空间放置的,onLayout传下来的l,t,r,b分别是放置父控件的矩形可用空间(除去margin和padding的空间)的左上角的left、top以及右下角right、bottom值。

layout:

public void layout(int l, int t, int r, int b);

该方法是View的放置方法,在View类实现。调用该方法需要传入放置View的矩形空间左上角left、top值和右下角right、bottom值。这四个值是相对于父控件而言的。例如传入的是(10, 10, 100, 100),则该View在距离父控件的左上角位置(10, 10)处显示,显示的大小是宽高是90(参数r,b是相对左上角的),这有点像绝对布局。我们确定了子View的位置也就是l,t,r,b四个值后就用这个方法把子View放到那去。

平常开发所用到RelativeLayout、LinearLayout、FrameLayout…这些都是继承ViewGroup的布局。这些布局的实现都是通过都实现ViewGroup的onLayout方法,只是实现方法不一样而已。

在自定义View中,onLayout配合onMeasure方法一起使用,可以实现自定义View的复杂布局。自定义View首先调用onMeasure进行测量,然后调用onLayout方法,动态获取子View和子View的测量大小,然后进行layout布局。

@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {

    int cCount = getChildCount();
    int cWidth = 0;
    int cHeight = 0;
    MarginLayoutParams cParams = null;

    int cl = 0, ct = 0, cr = 0, cb = 0;

    for (int i = 0; i < cCount; i++) {
        View childView = getChildAt(i);
        cWidth = childView.getMeasuredWidth();
        cHeight = childView.getMeasuredHeight();
        cParams = (MarginLayoutParams) childView.getLayoutParams();

        switch (i) {
            case 0:
                cl = cParams.leftMargin;
                ct = cParams.topMargin;
                break;
            case 1:
                cl = getWidth() - cWidth - cParams.rightMargin;
                ct = cParams.topMargin;
                break;
        }
        cr = cl + cWidth;
        cb = cHeight + ct;
        childView.layout(cl, ct, cr, cb);
    }
}

因为还要讲解onDraw方法,所以这个例子我只使用了两个Button,打算在ViewGroup的中间去主动绘制文字。

这个逻辑是很清晰的,我们要实现的TopBar,一个Button在最左边,另一个则在最右边,知道位置自然很好获取。

写到这里,布局已经可以出现一些东西了,让我们运行看看结果是不是如我们所想:

可以看到我们定义的两个Button已经到指定的地方了,最后就是绘制我们的文字。

5、onDraw

onDraw方法其实反而是最常用的自定义时的方法,因为就像我在上篇博客所说的,系统提供的控件已经能满足大部分效果,我们大多数是不满意它的样子,而要改变它的样子自然要重写onDraw方法。

使用onDraw方法,首先要对Canvas和Paint这两个类有所料解,它们一个是画布,一个是画笔,它们的使用是多种多样的,这里我就不提啦,有兴趣的朋友可以去查阅相关资料。

我们要绘制的是比较简单的,实现的效果就是在ViewGroup的中间绘制文字,我们需要的就是中间的那一块矩形。

private Rect mBound;
private Paint mPaint;

public CustomTopBar(Context context, AttributeSet attrs) {
    super(context, attrs);
    init(context, attrs);

    setBackgroundColor(0xFFF59563);

    mPaint = new Paint();
    mPaint.setTextSize(titleTextSize);
    mBound = new Rect();
    layout();
}

@Override
protected void onDraw(Canvas canvas) {
    mPaint.getTextBounds(title, 0, title.length(), mBound);
    mPaint.setColor(Color.YELLOW);
    canvas.drawRect(0, 0, getMeasuredWidth(), getMeasuredHeight(), mPaint);

    mPaint.setColor(titleTextColor);
    canvas.drawText(title, getWidth() / 2 - mBound.width() / 2, getHeight() / 2 + mBound.height() / 2, mPaint);
}

mBound就是我们为文字设置的矩形,我们将测量好的控件绘制颜色,在中间绘制文字相信大家都能看懂。

<?xml version="1.0" encoding="utf-8"?>
<com.ht.animator.CustomTopBar xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:lht="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    lht:leftBackground="@android:color/holo_blue_bright"
    lht:leftTextColor="#FFFFFF"
    lht:leftText="Back"
    lht:rightBackground="@android:color/holo_blue_bright"
    lht:rightTextColor="#FFFFFF"
    lht:rightText="More"
    lht:title="自定义标题"
    lht:titleTextColor="#000000"
    lht:titleTextSize="20sp">

</com.ht.animator.CustomTopBar>

都设置好了,就看看我们运行的结果吧。

6、用户交互

一个精美的布局是我们自定义控件所要达到的基础,但是我们可不能满足与此,我们还需要给它添加用户交互,否则就算界面再漂亮也只是空壳子而已。

我们的这个例子没有onTouch方法的处理,不过有两个Button,我们可以为它们的点击事件设置回调方法,让大家看看如何使用回调。

我们不可能每次要修改点击事件就去文件中修改代码,应该在调用这个控件的时候为里面的Button添加事件,我们可以写个像onClick的接口回调。

private topbarClickListener listener;

public interface topbarClickListener {
    public void leftClick();
    public void rightClick();
}

public void setOnTopbarClickListener(topbarClickListener listener) {
    this.listener = listener;
}

Button的setOnClickListener是触发点击事件,真正实现点击之后内容的是new OnClickListener()这个匿名内部类,所以我们仿照它写,定义了topbarClickListener这个接口,里面的方法分别实现左右Button的点击逻辑,然后就是写个对外的方法setOnTopbarClickListener()。这样我们就实现了我们的回调方法啦。

leftButton.setOnClickListener(new OnClickListener() {
    @Override
    public void onClick(View v) {
        listener.leftClick();
    }
});

rightButton.setOnClickListener(new OnClickListener() {
    @Override
    public void onClick(View v) {
        listener.rightClick();
    }
});

设置好就在我们的Activity中调用:

public class TopBarActivity extends AppCompatActivity {

    private boolean flag = true;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.test);

        final CustomTopBar topbar = (CustomTopBar) findViewById(R.id.topbar);
        topbar.setOnTopbarClickListener(new CustomTopBar.topbarClickListener() {
            @Override
            public void leftClick() {
                Toast.makeText(TopBarActivity.this, "LEFT", Toast.LENGTH_SHORT).show();
            }

            @Override
            public void rightClick() {
                Toast.makeText(TopBarActivity.this, "RIGHT", Toast.LENGTH_SHORT).show();
            }
        });
    }
}

这样我们就可以在外面也可以对自定义控件中的View做操作啦,更改功能也不需要去修改自定义控件中代码,提升了代码复用。

同样我们也可以在控件中提供方法,去修改那些子View的属性,更方便我们对自定义控件的操控,这里写个设置可见的例子。

public void setVisible(boolean flag) {
    if (flag) {
        leftButton.setVisibility(View.VISIBLE);
    } else {
        leftButton.setVisibility(View.GONE);
    }
}
private boolean flag = true;
...
@Override
public void rightClick() {
    flag = !flag;
    topbar.setVisible(flag);
}

以上就是我这篇博客的全部内容啦。

结束语:本文仅用来学习记录,参考查阅。

已标记关键词 清除标记
相关推荐
©️2020 CSDN 皮肤主题: 编程工作室 设计师:CSDN官方博客 返回首页