Android 自定义 View 三步骤

自定义 View 三步骤

自定义View三步骤,即:onMeasure()(测量),onLayout()(布局),onDraw()(绘制)。

onMeasure()

首先我们需要弄清楚,自定义 View 为什么需要重新测量。正常情况下,我们直接在 XML 布局文件中定义好 View 的宽高,然后让自定义 View 在此宽高的区域内显示即可。但是为了更好地兼容不同尺寸的屏幕,Android 系统提供了 wrap_content 和 match_parent 属性来规范控件的显示规则。它们分别代表自适应大小和填充父视图的大小,但是这两个属性并没有指定具体的大小,因此我们需要在 onMeasure 方法中过滤出这两种情况,真正的测量出自定义 View 应该显示的宽高大小。

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* 测量
* @param widthMeasureSpec 包含测量模式和宽度信息
* @param heightMeasureSpec 包含测量模式和高度信息
* int型数据,采用二进制,占32个bit。其中前2个bit为测量模式。后30个bit为测量数据(尺寸大小)。
* 这里测量出的尺寸大小,并不是View的最终大小,而是父View提供的参考大小。
*/
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
Log.e("TAG","onMeasure()");

}

MeasureSpec:

  • 测量过程中,系统会将View的LayoutParams根据父容器所施加的规则转换成对应的MeasureSpec,然后再根据这个measureSpec来测量出View的宽高。只是测量宽高,不一定等于实际宽高。
  • MeasureSpec代表一个32位int值(避免过多的对象内存分配),高2位代表SpecMode(测量模式),低30位代表SpecSize(规格大小)。并提供了打包和解包方法。
SpecMode 说明
UNSPECIFIED 父容器没有对当前 View 有任何限制,当前 View 可以取任意尺寸,比如 ListView 中的 item。这种情况一般用于系统内部,表示一种测量的状态。
EXACTLY 父容器已检测出View所需要的精确大小,就是SpecSize所指定的值。它对应于LayoutParams中的Match_parent和具体数值这两种模式。
AT_MOST 父容器指定SpecSize,View不能大于这个值。它对应于LayoutParams中的wrap_content。

MeasureSpec和LayoutParams的对应关系:

  • 在测量时,系统会将LayoutParams在父容器的约束下转换成对应的MeasureSpec,再根据MeasureSpec来确定View测量后宽高。(需要注意的是,决定MeasureSpec的有两点。即LayoutParams和父容器约束)
  • 对于顶级View(DecorView)和普通View,MeasureSpec的转换过程略有不同。除了自身的LayoutParams这点,前者由窗口的尺寸,后者由父容器的MeasureSpec来约束决定。MeasureSpec一定确定,onMeasure中就可以确定View的测量宽高。

当继承 View 或 ViewGroup 时,如果没有复写 onMeasure 方法时,默认使用父类也就是 View 中的实现,View 中的 onMeasure 默认实现如下:

1
2
3
4
5
6
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
// setMeasuredDimension 是一个非常重要的方法,这个方法传入的值直接决定 View 的宽高,也就是说如果调用 setMeasuredDimension(100,200),最终 View 就显示宽 100 * 高 200 的矩形范围。
// getDefaultSize 返回的是默认大小,默认为父视图的剩余可用空间。
setMeasuredDimension(getDefaultSize(getSuggestedMinimumWidth(), widthMeasureSpec),
getDefaultSize(getSuggestedMinimumHeight(), heightMeasureSpec));
}

查看 setMeasuredDimension 方法。其它现有控件的 onMeasure 方法的 super.onMeasure(widthMeasureSpec, heightMeasureSpec); 经过一系列计算,最后也是调用到 setMeasuredDimension 方法。

1
2
3
4
5
6
7
8
9
10
11
12
protected final void setMeasuredDimension(int measuredWidth, int measuredHeight) {
boolean optical = isLayoutModeOptical(this);
if (optical != isLayoutModeOptical(mParent)) {
Insets insets = getOpticalInsets();
int opticalWidth = insets.left + insets.right;
int opticalHeight = insets.top + insets.bottom;

measuredWidth += optical ? opticalWidth : -opticalWidth;
measuredHeight += optical ? opticalHeight : -opticalHeight;
}
setMeasuredDimensionRaw(measuredWidth, measuredHeight);
}

一种情况是:在 XML 中指定的是 wrap_content,但是实际使用的宽高值却是父视图的剩余可用空间,从 getDefaultSize 方法中可以看出是整个屏幕的宽高。解决方法只要复写 onMeasure,过滤出 wrap_content 的情况,并主动调用 setMeasuredDimension 方法设置正确的宽高即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
    @Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
// 判断是 wrap_content 的测量模式
if (MeasureSpec.AT_MOST == widthMode || MeasureSpec.AT_MOST == heightMode){
int measuredWidth = MeasureSpec.getSize(widthMeasureSpec);
int measuredHeight = MeasureSpec.getSize(heightMeasureSpec);
// int size = measuredWidth > measuredHeight ? measuredHeight : measuredWidth;
// 将宽高设置为传入宽高的最小值
int size = Math.min(measuredWidth, measuredHeight);
// 设置 View 实际大小
setMeasuredDimension(size,size);
}
}

ViewGroup 中的 onMeasure

如果自定义的控件是一个容器,onMeasure 方法会更加复杂一些。因为 ViewGroup 在测量自己的宽高之前,需要先确定其内部子 View 的所占大小,然后才能确定自己的大小。比如 LinearLayout 的宽高为 wrap_content 表示由子控件的大小决定,那 LinearLayout 的最终宽度由其内部最大的子 View 宽度决定。

onLayout()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* 布局
* @param changed 当前View的大小和位置改变了
* @param left 左部位置(相对于父视图)
* @param top 顶部位置(相对于父视图)
* @param right 右部位置(相对于父视图)
* @param bottom 底部位置(相对于父视图)
*/
@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
super.onLayout(changed, left, top, right, bottom);
Log.e("TAG","onLayout()");
// 一般在自定义ViewGroup时使用,来定义子View的位置。

}

这里扩展一些View位置相关知识点:

  • View的位置参数:
    由View的四个属性决定;left(左上角横坐标),right(右下角横坐标),top(左上角纵坐标),bottom(右下角纵坐标)。是一种相对坐标,相对父容器。
    四个参数对应View源码中的mLeft等四个成员变量,通过getLeft()等方法来获取。
  • View的宽高和坐标的关系:
    width=right-left;
    height=bottom-top;
  • 从Android3.0开始,新增额外的四个参数:
    x,y,translationX,translationY。前两者是View左上角坐标,后两者是View左上角相对于父容器的偏移量,并且默认值0。和四个基本位置参数一样,也提供了get/set方法。
  • 换算关系如下;
    x=left+translationX;
    y=top+translationY;
    注意;在View平移过程中,top和left表示的是原始左上角的位置信息,值并不会改变。发生改变的是;x,y,translationX,translationY这四个参数。

它是一个抽象方法,也就是说每一个自定义 ViewGroup 都必须主动实现如何排布子 View,具体就是遍历每一个子 View,调用 child.(l, t, r, b) 方法来为每个子 View 设置具体的布局位置。四个参数分别代表左上右下的坐标位置,一个简易的 FlowLayout 实现如下:

在大多数 App 的搜索界面经常会使用 FlowLayout 来展示历史搜索记录或者热门搜索项。
FlowLayout 的每一行上的 item 个数不一定,当每行的 item 累计宽度超过可用总宽度,则需要重启一行摆放 item 项。因此我们需要在 onMeasure 方法中主动的分行计算出 FlowLayout 的最终高度,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
public class FlowLayout extends ViewGroup {

//存放容器中所有的View
private List<List<View>> mAllViews = new ArrayList<List<View>>();
//存放每一行最高View的高度
private List<Integer> mPerLineMaxHeight = new ArrayList<>();

public FlowLayout(Context context) {
super(context);
}

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

public FlowLayout(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}

@Override
protected LayoutParams generateLayoutParams(LayoutParams p) {
super.generateLayoutParams(p);
return new MarginLayoutParams(p);
}

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

@Override
protected LayoutParams generateDefaultLayoutParams() {
return new MarginLayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT);
}

/**
* 测量控件的宽和高
*
* onMeasure 方法的主要目的有 2 个:
* 1.调用 measureChild 方法递归测量子 View;
* 2.通过叠加每一行的高度,计算出最终 FlowLayout 的最终高度 totalHeight。
*/
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
//获得宽高的测量模式和测量值
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);
int heightSize = MeasureSpec.getSize(heightMeasureSpec);
int heightMode = MeasureSpec.getMode(heightMeasureSpec);

//获得容器中子View的个数
int childCount = getChildCount();
//记录每一行View的总宽度
int totalLineWidth = 0;
//记录每一行最高View的高度
int perLineMaxHeight = 0;
//记录当前ViewGroup的总高度
int totalHeight = 0;
for (int i = 0; i < childCount; i++) {
View childView = getChildAt(i);
//对子View进行测量
measureChild(childView, widthMeasureSpec, heightMeasureSpec);
MarginLayoutParams lp = (MarginLayoutParams) childView.getLayoutParams();
//获得子View的测量宽度
int childWidth = childView.getMeasuredWidth() + lp.leftMargin + lp.rightMargin;
//获得子View的测量高度
int childHeight = childView.getMeasuredHeight() + lp.topMargin + lp.bottomMargin;
if (totalLineWidth + childWidth > widthSize) {
//统计总高度
totalHeight += perLineMaxHeight;
//开启新的一行
totalLineWidth = childWidth;
perLineMaxHeight = childHeight;
} else {
//记录每一行的总宽度
totalLineWidth += childWidth;
//比较每一行最高的View
perLineMaxHeight = Math.max(perLineMaxHeight, childHeight);
}
//当该View已是最后一个View时,将该行最大高度添加到totalHeight中
if (i == childCount - 1) {
totalHeight += perLineMaxHeight;
}
}
//如果高度的测量模式是EXACTLY,则高度用测量值,否则用计算出来的总高度(这时高度的设置为wrap_content)
heightSize = heightMode == MeasureSpec.EXACTLY ? heightSize : totalHeight;
setMeasuredDimension(widthSize, heightSize);
}

//摆放控件
//1.表示该ViewGroup的大小或者位置是否发生变化
//2.3.4.5.控件的位置
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {

mAllViews.clear();
mPerLineMaxHeight.clear();

//存放每一行的子View
List<View> lineViews = new ArrayList<>();
//记录每一行已存放View的总宽度
int totalLineWidth = 0;

//记录每一行最高View的高度
int lineMaxHeight = 0;

/*************遍历所有View,将View添加到List<List<View>>集合中***************/
//获得子View的总个数
int childCount = getChildCount();
for (int i = 0; i < childCount; i++) {
View childView = getChildAt(i);
MarginLayoutParams lp = (MarginLayoutParams) childView.getLayoutParams();
int childWidth = childView.getMeasuredWidth() + lp.leftMargin + lp.rightMargin;
int childHeight = childView.getMeasuredHeight() + lp.topMargin + lp.bottomMargin;
if (totalLineWidth + childWidth > getWidth()) {
mAllViews.add(lineViews);
mPerLineMaxHeight.add(lineMaxHeight);
//开启新的一行
totalLineWidth = 0;
lineMaxHeight = 0;
lineViews = new ArrayList<>();
}
totalLineWidth += childWidth;
lineViews.add(childView);
lineMaxHeight = Math.max(lineMaxHeight, childHeight);
}
//单独处理最后一行
mAllViews.add(lineViews);
mPerLineMaxHeight.add(lineMaxHeight);
/************遍历集合中的所有View并显示出来*****************/
//表示一个View和父容器左边的距离
int mLeft = 0;
//表示View和父容器顶部的距离
int mTop = 0;

for (int i = 0; i < mAllViews.size(); i++) {
//获得每一行的所有View
lineViews = mAllViews.get(i);
lineMaxHeight = mPerLineMaxHeight.get(i);
for (int j = 0; j < lineViews.size(); j++) {
View childView = lineViews.get(j);
MarginLayoutParams lp = (MarginLayoutParams) childView.getLayoutParams();
int leftChild = mLeft + lp.leftMargin;
int topChild = mTop + lp.topMargin;
int rightChild = leftChild + childView.getMeasuredWidth();
int bottomChild = topChild + childView.getMeasuredHeight();
//四个参数分别表示View的左上角和右下角
childView.layout(leftChild, topChild, rightChild, bottomChild);
mLeft += lp.leftMargin + childView.getMeasuredWidth() + lp.rightMargin;
}
mLeft = 0;
mTop += lineMaxHeight;
}

}
}

这样一个自定义布局就定义好了,接下来可以 根据需要添加相应样式的子 View。

onDraw()

onDraw 方法接收一个 Canvas 类型的参数。Canvas 可以理解为一个画布,在这块画布上可以绘制各种类型的 UI 元素。

1
2
3
4
5
6
7
8
9
/**
* 绘制
* @param canvas 画布
*/
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
Log.e("TAG","onDraw()");
}

系统提供了一系列 Canvas 操作方法,如下:

1
2
3
4
5
6
7
void drawRect(RectF rect,Paint paint): // 绘制矩形区域
void drawOval(RectF oval,Paint paint): // 绘制椭圆
void drawCircle(float cx,float cy,float radius,Paint paint): // 绘制圆形
void drawArc(RectF oval,float startAngle,float sweepAngle,boolean useCenter,Paint paint): // 绘制弧形
void drawPath(Path path,Paint paint): // 绘制 Path 路径
void drawLine(float startX,float startY,float stopX,float stopY,Paint paint): // 绘制连线
void drawPoint(float x,float y,Paint paint): // 绘制点

Paint

Canvas 中每一个绘制操作都需要传入一个 Paint 对象。Paint 就相当于一个画笔,因为 Canvas(画布)本身只是呈现的一个载体,真正绘制出来的效果则取决于Paint(画笔)。可以通过设置画笔的各种属性,来实现不同绘制效果。

1
2
3
4
5
6
7
8
setStyle(Style style): // 设置绘制模式
setColor(int color) : // 设置颜色
setAlpha(int a): // 设置透明度
setShader(Shader shader): // 设置 Paint 的填充效果
setStrokeWidth(float width): // 设置线条宽度
setTextSize(float textSize): // 设置文字大小
setAntiAlias(boolean aa): // 设置抗锯齿开关
setDither(boolean dither): // 设置防抖动开关

例如 canvas.drawCircle(centerX, centerY, r, paint); 是在坐标 centerX 和 centerY 处绘制一个半径为 r 的圆,但具体圆是什么样子的则由 paint 来决定。

示例:绘制一个简易的圆形进度条控件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
public class PieImageView extends View {

private static final int MAX_PROGRESS = 100;
private Paint mArcPaint;
private RectF mBound;
private Paint mCirclePaint;
private int mProgress = 0;

public PieImageView(Context context) {
this(context, null, 0);
}

public PieImageView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}

public PieImageView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init();
}

public void setProgress(@IntRange(from = 0, to = MAX_PROGRESS) int mProgress) {
this.mProgress = mProgress;
ViewCompat.postInvalidateOnAnimation(this);
}

private void init() {
mArcPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mArcPaint.setStyle(Paint.Style.FILL_AND_STROKE);
mArcPaint.setStrokeWidth(dpToPixel(0.1f, getContext()));
mArcPaint.setColor(Color.RED);

mCirclePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mCirclePaint.setStyle(Paint.Style.STROKE);
mCirclePaint.setStrokeWidth(dpToPixel(2, getContext()));
mCirclePaint.setColor(Color.argb(120, 0xff, 0xff, 0xff));
mBound = new RectF();
}

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
// 判断是wrap_content的测量模式。
// 如果没做处理,将 PieImageView 的宽高设置为 wrap_content(也就是自适应),PieImageView 不会正常显示。它会占满屏幕空间。
if (MeasureSpec.AT_MOST == widthMode || MeasureSpec.AT_MOST == heightMode) {
int measuredWidth = MeasureSpec.getSize(widthMeasureSpec);
int measuredHeight = MeasureSpec.getSize(heightMeasureSpec);
// 将宽高设置为传入宽高的最小值
int size = measuredWidth > measuredHeight ? measuredHeight : measuredWidth;
// 调用setMeasuredDimension设置View实际大小
setMeasuredDimension(size, size);
}
}

@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
int min = Math.min(w, h);
int max = w + h - min;
int r = Math.min(w, h) / 3;
mBound.set((max >> 1) - r, (min >> 1) - r, (max >> 1) + r, (min >> 1) + r);
}

@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
if (mProgress != MAX_PROGRESS && mProgress != 0) {
float mAngle = mProgress * 360f / MAX_PROGRESS;
canvas.drawArc(mBound, 270, mAngle, true, mArcPaint);
canvas.drawCircle(mBound.centerX(), mBound.centerY(), mBound.height() / 2, mCirclePaint);
}
}

private float scale = 0;

private int dpToPixel(float dp, Context context) {
if (scale == 0) {
scale = context.getResources().getDisplayMetrics().density;
}
return (int) (dp * scale);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
public class PieImageActivity extends AppCompatActivity {

PieImageView pieImageView;

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

// pieImageView = findViewById(R.id.pieImageView);
// pieImageView.setProgress(45);
}
}

示例

效果图:

完整代码:

activity_main.xml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
android:orientation="vertical"
android:gravity="center"
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/darker_gray"
tools:context="com.example.xwxwaa.myapplication.MainActivity">

<!--记录两个问题-->
<!--1.这里的父布局是LinearLayout-->
<!--如果换成RelativeLayout,效果还有问题。-->
<!--2.MyCustomViewGroup的宽高如果是wrap_content-->
<!--则子View的宽高设置成match_parent无效。-->

<com.example.xwxwaa.myapplication.MyCustomViewGroup
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@color/colorPrimaryDark"
android:layout_marginLeft="2dp"
android:layout_marginTop="2dp"
android:paddingRight="5dp"
android:paddingBottom="5dp">

<TextView
android:layout_marginLeft="2dp"
android:layout_marginTop="2dp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="自定义View"
android:background="@color/colorAccent"/>

<!--app为命名空间,为了使用自定义属性-->
<com.example.xwxwaa.myapplication.MyCustomView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginLeft="2dp"
android:layout_marginTop="2dp"
android:paddingRight="5dp"
android:paddingBottom="5dp"
app:default_size="100dp"
app:default_color="@color/colorPrimaryDark"
/>
</com.example.xwxwaa.myapplication.MyCustomViewGroup>

</LinearLayout>

MyCustomView.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
public class MyCustomView extends View{

private int defaultSize;
private int defaultColor;
private Paint paint ;

/**
* 需要两个构造参数
* @param mContext
*/
public MyCustomView(Context mContext){
super(mContext);
init();
}

public MyCustomView(Context mContext, AttributeSet attributeSet){
super(mContext,attributeSet);
// 通过它,取出在xml中,由命名空间定义的属性值
// 第二个参数就是我们在styles.xml文件中的<declare-styleable>标签
// 即属性集合的标签,在R文件中名称为R.styleable+name
TypedArray a = mContext.obtainStyledAttributes(attributeSet, R.styleable.MyCustomView);

//第一个参数为属性集合里面的属性,R文件名称:R.styleable+属性集合名称+下划线+属性名称
//第二个参数为,如果没有设置这个属性,则设置的默认的值
defaultSize = a.getDimensionPixelSize(R.styleable.MyCustomView_default_size, 100);
defaultColor = a.getColor(R.styleable.MyCustomView_default_color,Color.BLUE);

//最后将TypedArray对象回收
a.recycle();

init();
}
private void init(){
// 初始化Paint
paint = new Paint();
paint.setColor(defaultColor);
paint.setStyle(Paint.Style.STROKE);//设置圆为空心
paint.setStrokeWidth(3.0f);//设置线宽
}
/**
* 测量
* @param widthMeasureSpec 包含测量模式和宽度信息
* @param heightMeasureSpec 包含测量模式和高度信息
* int型数据占32个bit。其中前2个bit为测量模式。后30个bit为测量数据(尺寸大小)。
* 这里测量出的尺寸大小,并不是View的最终大小,而是父View提供的参考大小。
*/
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
// 定义宽高尺寸
int width = getSize(widthMeasureSpec);
int height = getSize(heightMeasureSpec);

// 实现一个正方形,取小值
int sideLength =Math.min(width,height);

// 设置View宽高
setMeasuredDimension(sideLength,sideLength);
}

private int getSize(int measureSpec){
int mySize = defaultSize;

// 可通过下面的方法,来获取测量模式和尺寸大小。
// 注意这里的specSize值单位是px,而我们xml中一般为dp。
int specMode = MeasureSpec.getMode(measureSpec);
int specSize = MeasureSpec.getSize(measureSpec);

switch (specMode){
case MeasureSpec.UNSPECIFIED:
// 父容器不对View有任何限制,这种情况一般用于系统内部,表示一种测量的状态。
// 一般也不需要我们处理。。可以看ScrollView或列表相关组件。
Log.e("TAG","测量模式;MeasureSpec.UNSPECIFIED");
break;
case MeasureSpec.EXACTLY:
// 父容器已检测出View所需要的精确大小,就是SpecSize所指定的值。
// 当xml中,宽或高指定为match_parent或具体数值,会走这里。
mySize = specSize;
Log.e("TAG","测量模式;MeasureSpec.EXACTLY");
break;
case MeasureSpec.AT_MOST:
// View的尺寸大小,不能大于父View指定的SpecSize。
// 当xml中,宽或高指定为wrap_content时,会走这里。
mySize = specSize/2;
Log.e("TAG","测量模式;MeasureSpec.AT_MOST");
break;
default:
break;
}
return mySize;
}
/**
* 绘制
* @param canvas 画布
*/
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
// 接下来绘制一个正圆。
// 需要知道,圆的半径,和圆点坐标。
int r = getMeasuredWidth() ;
int centerX ;
int centerY ;

int paddingL = getPaddingLeft();
int paddingT = getPaddingTop();
int paddingR = getPaddingRight();
int paddingB = getPaddingBottom();

// 计算View减去padding后的可用宽高
int canUsedWidth = r - paddingL - paddingR;
int canUsedHeight = r - paddingT - paddingB;

// 圆心坐标
centerX = canUsedWidth / 2 + paddingL;
centerY = canUsedHeight / 2 + paddingT;
// 取两者最小值作为圆的直径
int minSize = Math.min(canUsedWidth, canUsedHeight);
// 绘制一个圆
canvas.drawColor(Color.WHITE);//设置画布颜色
canvas.drawCircle(centerX,centerY,minSize / 2,paint);
}
/**
* 布局
* @param changed 当前View的大小和位置改变了
* @param left 左部位置(相对于父视图)
* @param top 顶部位置(相对于父视图)
* @param right 右部位置(相对于父视图)
* @param bottom 底部位置(相对于父视图)
*/
@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
super.onLayout(changed, left, top, right, bottom);
// 一般在自定义ViewGroup时使用,来定义子View的位置。

}
}
MyCustomViewGroup.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
public class MyCustomViewGroup extends ViewGroup{

// 内边距
private int paddingL ;
private int paddingT ;
private int paddingR ;
private int paddingB ;
// 外边距
private int marginL;
private int marginT;
private int marginR;
private int marginB;

public MyCustomViewGroup (Context mContext){
super(mContext);

}

public MyCustomViewGroup(Context mContext, AttributeSet attributeSet){
super(mContext,attributeSet);

}


@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
// 宽高的测量模式和尺寸
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int heightSize = MeasureSpec.getSize(heightMeasureSpec);
// 获取内边距
paddingL = getPaddingLeft();
paddingT = getPaddingTop();
paddingR = getPaddingRight();
paddingB = getPaddingBottom();
// 初始化外边距,因为测量不止一次。
marginL = 0;
marginT = 0;
marginR = 0;
marginB = 0;

// 测量所有子View的宽高。它会触发每个子View的onMeasure()。
// measureChildren(widthMeasureSpec,heightMeasureSpec);

// measureChild是对单个View进行测量。
int childCount = getChildCount();
for (int i = 0; i < childCount; i++) {
View childView = getChildAt(i);
MarginLayoutParams lp = (MarginLayoutParams) childView.getLayoutParams();
measureChild(childView, widthMeasureSpec, heightMeasureSpec);
marginL = Math.max(0,lp.leftMargin);//在本例中找出最大的左边距
marginT += lp.topMargin;//在本例中求出所有的上边距之和
marginR = Math.max(0,lp.rightMargin);//在本例中找出最大的右边距
marginB += lp.bottomMargin;//在本例中求出所有的下边距之和
}

if (childCount == 0){
// 没有子View
setMeasuredDimension(0,0);
}else {
// 最大宽度,加上内外边距
int viewGroupWidth = paddingL + getChildMaxWidth() + paddingR +marginL+marginR;
// 高度之和,加上内外边距
int viewGroupHeight = paddingT + getChildTotalHeight() + paddingB+marginT+marginB;
// 选小值
int resultWidth = Math.min(viewGroupWidth, widthSize);
int resultHeight = Math.min(viewGroupHeight, heightSize);

if (widthMode == MeasureSpec.AT_MOST && heightMode == MeasureSpec.AT_MOST){
// 如果父布局宽高都是wrap_content,只会走这个方法。
// 宽高都是包裹内容,用于处理ViewGroup的wrap_content情况
setMeasuredDimension(resultWidth,resultHeight);
}else if (widthMode == MeasureSpec.AT_MOST){
// 宽度是包裹内容
setMeasuredDimension(resultWidth,heightSize);
}else if (heightMode == MeasureSpec.AT_MOST){
// 高度是包裹内容
setMeasuredDimension(widthSize,resultHeight);
}
// 这里如果没进上面的条件判断中,super.onMeasure()会调用setMeasuredDimension()的,默认占满剩余可用空间。
}
}

/**
* 获取所有子View的最大宽度
* @return
*/
private int getChildMaxWidth(){
int count = getChildCount();
int maxWidth = 0;
for (int i = 0; i < count; i++) {
View child = getChildAt(i);
if (child.getMeasuredWidth() > maxWidth){
maxWidth = child.getMeasuredWidth();
}
}
return maxWidth;
}

/**
* 获取所有子View的高度之和
* @return
*/
private int getChildTotalHeight(){
int count = getChildCount();
int totalHeight = 0;
for (int i = 0; i < count; i++) {
View view = getChildAt(i);
totalHeight += view.getMeasuredHeight();
}
return totalHeight;
}


@Override
protected void onLayout(boolean c, int l, int t, int r, int b) {
int count = getChildCount();
int coordHeight = paddingT;
for (int i = 0; i < count; i++) {
View child = getChildAt(i);
MarginLayoutParams lp = (MarginLayoutParams)child.getLayoutParams();
int width = child.getMeasuredWidth();
int height = child.getMeasuredHeight();
int coordWidth = paddingL+ lp.leftMargin;
coordHeight += lp.topMargin;
child.layout(coordWidth,coordHeight,coordWidth+width,coordHeight+height);
coordHeight+=height+lp.bottomMargin;
}
}

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

attrs.xml
1
2
3
4
5
6
7
8
9
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!--自定义属性-->
<declare-styleable name="MyCustomView">
<!--dimension是一个包含单位(dp、sp、px等)的尺寸,可用于定义视图的宽度、字号等。-->
<attr name="default_size" format="dimension" />
<attr name="default_color" format="color" />
</declare-styleable>
</resources>

优化

比如重复绘制,还有大图长图优化。

加载长图大图优化:

  • 压缩图片
  • 沿着对角线缩放
  • 加载屏幕能够看见的区域
  • 复用上一个 bitmap 区域的内存
  • 处理滑动

对覆盖区域的 View ,一定要避免不要重复绘制。比如竞技棋牌类型的 APP 。打斗地主的时候,很多扑克都是覆盖的,那么就不能每张图片进行绘制,一定要先计算显示的区域,把不需要的截取,然后在绘制。


备注

参考资料:

Android 开发艺术探索

拉钩教育-Android 工程师进阶 34 讲

传送门GitHub

欢迎关注微信公众号:非也缘也