Android--自定义控件方法解析

转自Android–自定义控件解析(二)

开篇

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

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的代码即可明白。


对于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一共有三种可能:

1.MeasureSpec.EXACTLY:父视图希望子视图的大小应该是specSize中指定的。一般是设置了明确的值或者是MATCH_PARENT。

2.MeasureSpec.AT_MOST:子视图的大小最多是specSize中指定的值,也就是说不建议子视图的大小超过specSize中给定的值。表示子布局限制在一个最大值内,一般为WARP_CONTENT。

3.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.yourPkgName.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>

在这里插入图片描述
可以看到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时的影响。

TopBar测量

本文的例子同样是自定义TopBar,不过这次我们就不继承RelativeLayout了,继承ViewGroup,这意味着我们必须重写它的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();
        LayoutParams params = childView.getLayoutParams();
        
        cParams = new ViewGroup.MarginLayoutParams(params);

        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,这种写法,仅供演示。

onLayout

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

@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值。

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();
      LayoutParams params = childView.getLayoutParams();
 
        cParams = new ViewGroup.MarginLayoutParams(params);

        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已经到指定的地方了,最后就是绘制我们的文字。

onDraw

onDraw方法其实反而是最常用的自定义View的方法,因为就像我在上篇博客所说的,系统提供的控件已经能满足大部分效果,我们大多数是不满意它的样子,而要改变它的样子自然要重写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.yourPkgName.CustomTopBar xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:lht="http://schemas.android.com/apk/res-auto"
  android:id="@+id/top_bar"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    app:leftButtonBackground="@android:color/holo_blue_dark"
    app:title="一个标题"
    app:leftButtonText="返回"
    app:leftButtonTextColor="@android:color/white"
    app:rightButtonBackground="@android:color/holo_green_dark"
    app:rightButtonText="更多"
    app:rightButtonTextColor="@android:color/white"
    app:titleTextColor="@android:color/holo_purple"
    app:titleTextSize="28dp">

</com.yourPkgName.CustomTopBar>

都设置好了,就看看我们运行的结果:
在这里插入图片描述

用户交互

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

我们的这个例子没有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);
}

附:
attrs.xml

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <declare-styleable name="TopBar">
        <attr name="title" format="string"/>
        <attr name="titleTextSize" format="dimension"/>
        <attr name="titleTextColor" format="color"/>
        <attr name="leftButtonText" format="string"/>
        <attr name="leftButtonTextColor" format="color"/>
        <attr name="leftButtonBackground" format="color|reference"/>
        <attr name="rightButtonText" format="string"/>
        <attr name="rightButtonTextColor" format="color"/>
        <attr name="rightButtonBackground" format="color|reference"/>
    </declare-styleable>
</resources>

完整的TopBar代码

public class TopBar extends ViewGroup {
    private static final String TAG = "TopBar";
    private Button leftButton, rightButton;
    private Rect mBound;
    private Paint mPaint;
    private String leftBtnText, rightBtnText, title;
    private int leftBtnTextColor, rightBtnTextColor, titleTextColor;
    private Drawable leftBtnBg, rightBtnBg;
    private float titleTextSize;
    private TopBarClickListener topBarClickListener;

    public void setTopBarClickListener(TopBarClickListener topBarClickListener) {
        this.topBarClickListener = topBarClickListener;
    }

    public TopBar(Context context, AttributeSet attrs) {
        super(context, attrs);
        setBackgroundColor(0xFFF59563);
        mPaint = new Paint();
        mPaint.setTextSize(50f);
        mPaint.setAntiAlias(true);
        mBound = new Rect();
        init(context, attrs);
        layout();
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        mPaint.getTextBounds(title, 0, title.length(), mBound);
        mPaint.setTextSize(titleTextSize);
        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);
    }

    private void init(Context context, AttributeSet attrs) {
        TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.TopBar);
        leftBtnText = ta.getString(R.styleable.TopBar_leftButtonText);
        leftBtnTextColor = ta.getColor(R.styleable.TopBar_leftButtonTextColor, 0);
        leftBtnBg = ta.getDrawable(R.styleable.TopBar_leftButtonBackground);

        rightBtnText = ta.getString(R.styleable.TopBar_rightButtonText);
        rightBtnTextColor = ta.getColor(R.styleable.TopBar_rightButtonTextColor, 0);
        rightBtnBg = ta.getDrawable(R.styleable.TopBar_rightButtonBackground);

        title = ta.getString(R.styleable.TopBar_title);
        titleTextColor = ta.getColor(R.styleable.TopBar_titleTextColor, 0);
        titleTextSize = ta.getDimension(R.styleable.TopBar_titleTextSize, 20);
        ta.recycle();

        leftButton = new Button(context);
        leftButton.setText(leftBtnText);
        leftButton.setTextColor(leftBtnTextColor);
        leftButton.setBackground(leftBtnBg);

        rightButton = new Button(context);
        rightButton.setText(rightBtnText);
        rightButton.setTextColor(rightBtnTextColor);
        rightButton.setBackground(rightBtnBg);

    }

    private void layout() {
        addView(leftButton);
        addView(rightButton);
        leftButton.setOnClickListener(new OnClickListener() {
            @Override
            public void onClick(View v) {
                if (topBarClickListener != null)
                    topBarClickListener.leftClick();
            }
        });
        rightButton.setOnClickListener(new OnClickListener() {
            @Override
            public void onClick(View v) {
                if (topBarClickListener != null)
                    topBarClickListener.rightClick();
            }
        });
    }


    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        //后进行布局 l:左left,t:上top,r:右right,b:底部bottom
        int cCount = getChildCount();
        Log.e(TAG, "onLayout:cCount " + cCount);
        int cWidth = 0;
        int CHeight = 0;
        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();
            LayoutParams params = childView.getLayoutParams();
            MarginLayoutParams cParams = new ViewGroup.MarginLayoutParams(params);
            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);
        }

    }


    @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);
        Log.e(TAG, "onMeasure:sizeHeight== " + sizeHeight);
        measureChildren(widthMeasureSpec, heightMeasureSpec);
        int width = 0;
        int height = 0;
        int cCount = getChildCount();
        int cWidth = 0;
        int cHeight = 0;
        //计算wrap_content时的子控件宽高
        for (int i = 0; i < cCount; i++) {
            View childView = getChildAt(i);
            cWidth = childView.getMeasuredWidth();
            cHeight = childView.getMeasuredHeight();
            LayoutParams params = childView.getLayoutParams();
            MarginLayoutParams cParams = new ViewGroup.MarginLayoutParams(params);
            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);

    }

    public interface TopBarClickListener {
        void leftClick();

        void rightClick();
    }

}

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值