概要
View
是 Widget
组件最基本的类,主要负责两个事情 ,绘制和事件处理。
View 的事件流程
onFinishInflate() -> onAttachedToWindow()->onMeausre()->onSizeChanged()->onLayout()->onDraw()
这只是一个简单流程,其中onMeasure()
和 onLayout()
可能需要进行多次。
View.measure()
View.onMeasure()
方法默认实现代码
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
setMeasuredDimension(getDefaultSize(getSuggestedMinimumWidth(), widthMeasureSpec),
getDefaultSize(getSuggestedMinimumHeight(), heightMeasureSpec));
}
由于 View
的 measure()
方法是 final
类型的,因此 View
子类并不能 override
这个方法,而是 override onMeasure()
这个方法。
View.onMeasure()
决定了 View
的测量宽和高,即 getMeasuredWidth()
和 getMeasuredHeght()
,也就是说只有在 onMeasure()
方法后,调用这两个方法才有效。
在 onMeasure()
中 需要调用 setMeasuredDimension()
设置测量的尺寸。 如果 View
的子类 override
了 onMeasure()
,要么调用 super.onMeasure()
使用系统默认的测量,要么就调用setMeasuredDimension()
自己来设置测量的大小。这两种方法先其一,不然会有异常。
先看看系统的默认实现是怎么回事呢?再回头看上面贴出的系统的实现代码,把焦点定位到 getDefaultSize()
方法上。
public static int getDefaultSize(int size, int measureSpec) {
int result = size;
int specMode = MeasureSpec.getMode(measureSpec);
int specSize = MeasureSpec.getSize(measureSpec);
switch (specMode) {
case MeasureSpec.UNSPECIFIED:
result = size;
break;
case MeasureSpec.AT_MOST:
case MeasureSpec.EXACTLY:
result = specSize;
break;
}
return result;
}
MeasureSpec.UNSPECIFIED
这个比较特殊,它是 ViewGroup
用来决定 child view
需要的尺寸,例如,一个 LinearLayout
可以调用 child View
的 measure()
方法,但是传入的参数中,高度设置为 UNSPECIFIED
,宽度设置为 240 pixels
,这是为了检测在240 pixels
宽度下,child View
想要的高度。从上面的代码中可以看到,如果 parent
给的参数是 UNSPECIFIED
,那么 child view
给出的测量值为 getSuggestedMinimumXXX()
,也就是设置的 android:minWidth / minHeight
的值,而一般不用关心MeasureSpec.UNSPECIFIED
。
重点就是 MeasureSpec.AT_MOST 和 Measure.EXACTLY
, 这两种情况返回的 size
是从 prent
传过来的 MeasureSpec
中获得的。
为了搞清楚这个 size
到底是多大,做个实验。
CustomView
自定义一个 View
,CustomView.java
/**
* Created by David on 2017/1/20.
*/
public class CustomView extends View {
private int mIndex;
private static final String TAG = "david";
public CustomView(Context context) {
this(context, null);
}
public CustomView(Context context, AttributeSet attrs) {
super(context, attrs);
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
mIndex++;
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int heightSize = MeasureSpec.getSize(heightMeasureSpec);
Log.d(TAG, "父View给的建议: 宽 = " + widthSize + " , 高 = " + heightSize);
if (widthMode == MeasureSpec.AT_MOST) {
Log.d(TAG, mIndex + " widthMode AT_MOST");
} else if (widthMode == MeasureSpec.EXACTLY) {
Log.d(TAG, mIndex + " widthMode EXACTLY");
}
if (heightMode == MeasureSpec.AT_MOST) {
Log.d(TAG, mIndex + " heightMode AT_MOST");
} else if (heightMode == MeasureSpec.EXACTLY) {
Log.d(TAG, mIndex + " heightMode EXACTLY");
}
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
}
}
现在把 CustomView
应用到布局中,并给它一个精确的 dp 值。
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
android:id="@+id/activity_main"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context="com.ckt.customviewtest.MainActivity">
<com.ckt.customviewtest.CustomView
android:background="@color/colorAccent"
android:layout_width="200dp"
android:layout_height="200dp"/>
</RelativeLayout>
效果如下
宽高为固定的DP值
打印Log为
01-16 13:47:16.231 9940-9940/com.ckt.customviewtest D/david: 父View给的建议: 宽 = 300 , 高 = 660
01-16 13:47:16.231 9940-9940/com.ckt.customviewtest D/david: 1 widthMode EXACTLY
01-16 13:47:16.231 9940-9940/com.ckt.customviewtest D/david: 1 heightMode AT_MOST
01-16 13:47:16.232 9940-9940/com.ckt.customviewtest D/david: 父View给的建议: 宽 = 300 , 高 = 300
01-16 13:47:16.232 9940-9940/com.ckt.customviewtest D/david: 2 widthMode EXACTLY
01-16 13:47:16.232 9940-9940/com.ckt.customviewtest D/david: 2 heightMode EXACTLY
从Log中看,如果给 CustomView
200dp
的宽和高,它最终得到了实际宽高为 300px
(测试手机为 hdpi,200dp = 200*1.5 px),这与预期结果一致。
而如果把 CustomView 的宽高改为 match_parent
<com.ckt.customviewtest.CustomView
android:background="@color/colorAccent"
android:layout_width="match_parent"
android:layout_height="match_parent"/>
效果为
match_parent这与预期的效果也是一样的。
但是如果 CustomView
的宽高改为 wrap_content
<com.ckt.customviewtest.CustomView
android:background="@color/colorAccent"
android:layout_width="wrap_content"
android:layout_height="wrap_content"/>
效果为
wrap_contentwrap_content
还是填充整个布局,这就有点搞事情了。
所以总结下,如果调用系统的默认实现super.onMeasure(widthMeasureSpec, heightMeasureSpec);
那么需要注意属性为 wrap_content
的情况。
一个设计良好的 View
不应该出现与开发都不一致的现象,那么现在如何用 setMeasuredDimension()
来设置测量的宽高?
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
Log.d(TAG, "onMeasure");
int widthSize = MeasureSpec.getSize(widthMeasureSpec);
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int heightSize = MeasureSpec.getSize(heightMeasureSpec);
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
// 测量的宽和高
int measuredWidth, measuredHeight;
// 设置期望的宽和高
int desiredWidth = 300; // px
int desiredHeight = 300; // px
if (widthMode == MeasureSpec.EXACTLY) {
// 如果父View传入的是EXACTLY就用父View给的大小
measuredWidth = widthSize;
} else if (widthMode == MeasureSpec.AT_MOST) {
// 如果父View给的是AT_MOST就与期望的值比较取最小值
measuredWidth = Math.min(widthSize, desiredWidth);
} else {
// 如果父View给的是UNSPECIFIED就只给给出期望的值
measuredWidth = 300;
}
if (heightMode == MeasureSpec.EXACTLY) {
measuredHeight = heightSize;
} else if (heightMode == MeasureSpec.AT_MOST) {
measuredHeight = Math.min(heightSize, desiredHeight);
} else {
measuredHeight = desiredHeight;
}
// 设置测量的宽和高
setMeasuredDimension(measuredWidth, measuredHeight);
}
代码中直接给出了期望的宽和高desiredWidth
和 desiredHeight
为 300px
,实际中我们可能需要根据绘制内容的大小来决定需要的宽和高。例如,想要在 View
中显示一些文字,当设置为 wrap_content
的时候,我们期望 View
正好是文字显示的大小,那么,我们需要在 onMeasure()
中测量文字大小,来给出相应的需要的宽和高。
View.onLayout()
View.onLayout()
是在 View.layout()
中调用,然而在 View.layout(
) 中已经把 View
给定位了。那么 View.onLayout()
有何叼用?下面是 View.onLayout()
的默认实现。
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
}
空方法!好像并没有任何叼用(个人愚见)。 自定义 View
的话,通常不会 override
这个方法。
那么 View.layout()
过后,可以得到什么呢? 从onLayout()
中参数left
, top
, right
, bootom
可以猜到,layout()
后可以获得 View 的实际宽和高,也就是 getWidth()
, getHeight()
,当然还包含 getLeft()
, getTop()
,getRight()
, getBottom()
方法。
View.onDraw()
View.onDraw()
是在 View.draw()
中调用的,先看下 View.onDraw()
方法
protected void onDraw(Canvas canvas) {
}
空方法!也就是说你想绘制什么就自己来。那么 View.draw()
又做了什么呢 ,它其实给我们做了一些基本的工作,例如,绘制 background
,scrollbars
。所以在 onDraw()
中只需要绘制自己想要的内容即可,其它的交给系统。
例子
从上面的分析,可以知道,如果自定义一个 View
,需要 override 的最基本的方法有 onMeasure()
和 onDraw()
。
有了这个分析,举个简单的例子,画个圆。
public class CustomView extends View {
private static final String TAG = "david";
private Paint mPaint;
public CustomView(Context context) {
this(context, null);
}
public CustomView(Context context, AttributeSet attrs) {
super(context, attrs);
init();
}
/**
* 初始化操作
*/
private void init() {
mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mPaint.setColor(Color.BLACK);
mPaint.setStyle(Paint.Style.STROKE);
mPaint.setStrokeWidth(5);
}
@Override
protected void onFinishInflate() {
super.onFinishInflate();
Log.d(TAG, "onFinishInflate");
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
Log.d(TAG, "onMeasure");
int widthSize = MeasureSpec.getSize(widthMeasureSpec);
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int heightSize = MeasureSpec.getSize(heightMeasureSpec);
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
// 测量的宽和高
int measuredWidth, measuredHeight;
// 设置期望的宽和高
int desiredWidth = 300; // px
int desiredHeight = 300; // px
if (widthMode == MeasureSpec.EXACTLY) {
// 如果父View传入的是EXACTLY就用父View给的大小
measuredWidth = widthSize;
} else if (widthMode == MeasureSpec.AT_MOST) {
// 如果父View给的是AT_MOST就与期望的值比较取最小值
measuredWidth = Math.min(widthSize, desiredWidth);
} else {
// 如果父View给的是UNSPECIFIED就只给给出期望的值
measuredWidth = 300;
}
if (heightMode == MeasureSpec.EXACTLY) {
measuredHeight = heightSize;
} else if (heightMode == MeasureSpec.AT_MOST) {
measuredHeight = Math.min(heightSize, desiredHeight);
} else {
measuredHeight = desiredHeight;
}
// 设置测量的宽和高
setMeasuredDimension(measuredWidth, measuredHeight);
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
Log.d(TAG, "onSizeChanged");
}
@Override
protected void onDraw(Canvas canvas) {
Log.d(TAG, "onDraw");
// 绘制一个圆
canvas.drawCircle(getWidth() / 2, getHeight() / 2, Math.min(getWidth() / 2, getHeight() / 2), mPaint);
}
@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
super.onLayout(changed, left, top, right, bottom);
Log.d(TAG, "onLayout");
}
@Override
protected void onAttachedToWindow() {
super.onAttachedToWindow();
Log.d(TAG, "onAttachedToWindow");
}
}
效果如下
画一个圆Padding
上面画了一个简单的圆,再思考一个问题,日常中我们喜欢搞个 padding
。 如果在 XML
文件中设置 padding
,会发现并没有效果。why? 因为自定义 View 的时候,我们其实并没有考虑 padding
这个因素啊,这是 Bug
中的大Bug
,改~
首先在 onDraw()
中,期望的宽和高要把 padding
考虑进去
// 设置期望的宽和高
int desiredWidth = 300 + getPaddingLeft() + getPaddingRight(); // px
int desiredHeight = 300 + getPaddingTop() + getPaddingBottom(); // px
在 onDraw()
中,绘制的时候,也要把 padding
考虑进去
@Override
protected void onDraw(Canvas canvas) {
Log.d(TAG, "onDraw");
// 绘制一个圆
float cx = (getWidth() - getPaddingLeft() - getPaddingRight()) / 2 + getPaddingLeft();
float cy = (getHeight() - getPaddingTop() - getPaddingBottom()) / 2 + getPaddingTop();
float radius = Math.min((getWidth() - getPaddingLeft() - getPaddingRight()) / 2, (getHeight() - getPaddingTop() - getPaddingBottom()) / 2);
canvas.drawCircle(cx, cy, radius, mPaint);
}
ok,现在设置一下 padding
属性
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.ckt.customviewtest.CustomView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@color/colorAccent"
android:paddingBottom="10dp"
android:paddingLeft="1dp"
android:paddingRight="5dp"
android:paddingTop="1dp"/>
</RelativeLayout>
看下效果
加入Padding的效果View的位置
onLayout()
是个空方法,View
的位置如何确定呢? 其实 View
只支持 padding
,并不支持 margin
。细思下,为何?这不是 ViewGroup
例如 LinearLayout
该干的事吗?所以 View
定位这个事,在 XML 中使用相应的布局的属性即可,例如 RelativeLayout
可以设置android:layout_centerInParent
来让 View
居中,当然也可以设置 margin
自由定位。
抽取属性
为何要抽取属性出来自定义? 可以方便用 XML
属性来控制 View
。
如何自定义属性
- 在res/values/ 目录下新建一个
attrs.xml
文件,名字可以随意取。并在文件中加入<declare-styleable>
节点,并在其中定义属性 - 在
XML
中定义属性的值 - 在运行时获取自定义属性
- 应用到自定义
View
中
按照上面的步骤来,首先在 res/values/ 目录下新建一个文件 custom_attrs.xml
,并加入自定义属性
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="CustomView">
<attr name="circleColor" format="color"/>
</declare-styleable>
</resources>
然后在运行时获取属性
public CustomView(Context context, AttributeSet attrs) {
super(context, attrs);
TypedArray a = context.getResources().obtainAttributes(attrs, R.styleable.CustomView);
int count = a.getIndexCount();
for (int i = 0; i < count; i++) {
mCircleColor = a.getColor(R.styleable.CustomView_circleColor, 0xFF352574);
}
a.recycle();
init();
}
如果细心的人会注意到 context.getResources().obtainAttributes()
,我并不是用的 context.getTheme().obtainStyledAttributes()
或者 context.obtainStyledAttributes()
。 当然,后面两者是等价的,而第一个,只是获取这个项目的 Resources
,后面两个包括 Theme
中定义的属性,例如,与 RecyclerView
有关的一个类 DividerItemDecoration.java
中获取属性代码
private static final int[] ATTRS = new int[]{ android.R.attr.listDivider };
final TypedArray a = context.obtainStyledAttributes(ATTRS);
mDivider = a.getDrawable(0);
如果换成context.getResources().obtainAttributes()
是获取为 null
的。
还要提醒一下,TypeArray 是一个共享资源,所以用完要回收。
最后一步就是使用了自定义属性了
private void init() {
mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
// 使用自定义属性
mPaint.setColor(mCircleColor);
mPaint.setStyle(Paint.Style.STROKE);
mPaint.setStrokeWidth(5);
}
对外暴露接口
自定义的属性只能在 XML
中做初始化的操作,当然我们最好把这个属性设置一个 getter
和 setter
方法。
public int getCircleColor() {
return mCircleColor;
}
public void setCircleColor(int circleColor) {
mCircleColor = circleColor;
invalidate();
}
注意,在 setCircleColor()
中调用了 invalidate()
,因为需要重绘,而在 onDraw() 中
,我们需要应用外部设置的属性,所以在 onDraw()
中要设置下 Paint
的 Color
protected void onDraw(Canvas canvas) {
Log.d(TAG, "onDraw");
// 应用自定义属性
mPaint.setColor(mCircleColor);
// 绘制一个圆
float cx = (getWidth() - getPaddingLeft() - getPaddingRight()) / 2 + getPaddingLeft();
float cy = (getHeight() - getPaddingTop() - getPaddingBottom()) / 2 + getPaddingTop();
float radius = Math.min((getWidth() - getPaddingLeft() - getPaddingRight()) / 2, (getHeight() - getPaddingTop() - getPaddingBottom()) / 2);
canvas.drawCircle(cx, cy, radius, mPaint);
}
事件处理
有时候,我们自定义的 View
要响应用户事件来改变View
的外观 或(和) 行为,如果只是改变外观 ,调用 invalidate()
即可,它会调用 onDraw()
。如果涉及到位置的改变就需要调用 requestLayout()
,它会在适当的时候调用 onDraw()
。 当然 requestLayout()
用的最多的地方还是 自定义ViewGroup
中,在下篇文章中再细说。