开篇
上篇已经讲了自定义属性的定义和获取,还有如何在布局文件添加我们的自定义控件。这几乎是自定义控件中必不可少的两步,而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();
}
}