说起自定义View ,在我们的实际工作中,用的比较多的就是将系统提供的组件进行组合封装,达到我们需要的效果,但是有些却无法做到,笔者之前遇到一个带文字的仿IOS开关的需求就没有办法使用系统的组合封装,这就需要自己绘制一个,包括添加一些动画,今天我们就学习一下绘制一个View的过程。
自定义View主要包含以下内容:
1)布局: onlayout(), onmeasure()
- 显示: onDraw() 其中涉及到了canvas , paint ,matrix, clip , rect ,animation ,line ,path等
3)事件分发:onTouchEvent()
我们常见的自定义View 是继承自View,ViewGroup ,LinearLayout ,RelativeLayout等(本文着重以ViewGroup为例)
绘制流程:
开始 -----》调用View的构造函数(此处完成View的初始化工作)-----》 回调onMeasure()函数(来测试视图的大小)----》onSizeChanged()确定View的大小 -----》onLayout()来确定当前view再其parent中的位置---》onDraw()绘制视图到画布上-----》添加事件
不论是measure测量过程,还是layout布局过程,或者是draw绘制过程,永远都是从树根节点开始测试或计算,一层一层的,一个树枝一个树枝的来完成(树形递归的方式)
1) 先来介绍一下构造函数
自定义View默认是都需要实现三个构造函数,有些则需要实现四个,而这些构造函数仅仅是参数的个数不同而已,我们依次来解释一下这些构造函数:
一个参数的构造函数 : 主要是用于我们在java代码中创建对象时调用;
两个参数的构造函数:主要用于我们在xml中使用自定义view时,我们在加载xml文件时使用(关于这一点我们在下一篇文章XML加载原理中可以明白)
三个参数的构造函数:是用于带有主题Style的构造
四个参数的构造函数:当我们有自己定义属性的构造函数
public class FlowLayout extends ViewGroup {
/**
* TODO java 创建对象使用构造
*/
public FlowLayout(Context context) {
super(context);
}
/**
* TODO xml使用的构造函数
*/
public FlowLayout(Context context, AttributeSet attrs) {
super(context, attrs);
}
/**
* TODO 主题style的构造
*/
public FlowLayout(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
/**
* TODO 自定义属性
*/
public FlowLayout(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
init(context, attrs);
}
/**
* TODO 解析自定义属性
*/
private void init(Context context, AttributeSet attributeSet) {
if (attributeSet != null) {
TypedArray a = context.obtainStyledAttributes(attributeSet, R.styleable.permission_attr);
assert a != null;
String permissionCode = a.getString(R.styleable.permission_attr_permissioncode);
if (StrUtils.isNotEmpty(permissionCode)) {
if (BaseApplication.getApplication().Permissions.indexOf(permissionCode) != -1) {
this.setVisibility(View.VISIBLE);
} else {
this.setVisibility(View.INVISIBLE);
}
}
}
}
}
关于自定义属性:
1)在values目录下添加一个attrs.xml的文件(若已经存在则忽略此步);
2)在attrs.xml中添加 如下的属性名和格式
3)在xml布局文件中使用:
2) 测量回调 onMeasure()
说明:
1)这个回调函数是用来测试自定义view及其child view的大小,包括padding ,margin等信息
2)所有view测试的位置信息都是基于其父亲的,而不是相对于屏幕的
3)大部分的测量都是先测试childview 然后再来度量自己的,但是ViewPager李伟
4)关于getMeasureWidth()与 getWidth()的理解:
getMeasureWidth(): 是在measure()测量过程结束后就可以获取到对应的值,需要使用setMeasureDemension()方法来进行设置
getWidth(): 是在layout()布局过程结束后才能获取到的,通过视图右边的坐标减去左边的坐标计算出来的。
5)关于padding ,margin的理解:
padding :就是视图内部的间距,他会影响视图的宽高,所有在计算视图的大小时需要计算在内
margin: 是视图彼此间的间距,不会影响视图的宽高
6) 理解 EXACTLY、AT_MOST、UNSPECIFIED三个模式
EXACTLY:父亲指定了孩子的大小,不管孩子想要多大,就只给指定的大小
AT_MOST:父亲给孩子一个允许的最大大小,孩子想要多大,就给多大
UNSPECIFIED:父亲不限制孩子的大小,由孩子自己决定
7) onMeasure()的两个参数,是父亲给当前view的两个宽高尺寸
上面是parent Mode 左侧是 childLayoutParams |
EXACTLY | AT_MOST | UNSPECIFIED |
---|---|---|---|
dp/px | EXACTILY | EXACTILY | EXACTILY |
match_parent | EXACTILY | AT_MOST | UNSPECIFIED |
wrap_content | AT_MOST | AT_MOST | UNSPECIFIED |
这个表格其实就是在表达父亲给孩子的测试的规格,然后对应孩子不同的诉求,最终给出的孩子的尺寸规格。
解释:
1)当孩子是dp/px时: 当孩子是指定的大小的,那么不管父亲是什么模式,都是精确模式并使用他自己的大小
- 当孩子是match_parent 时: 当父亲是精准模式,那么孩子也是精准模式,并且孩子的大小是父亲剩余的大小; 当父亲是最大模式,那么孩子也是最大模式,但是孩子的大小不会超过父亲的剩余空间;
3)当孩子是wrap_content时,不管父亲的模式是什么,孩子的模式总是最大的,但是孩子不能超过父亲的剩余空间;
4)UNSPECIFIED 一般是用于系统内部,不用关注此模式
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
// super.onMeasure(widthMeasureSpec, heightMeasureSpec);
clearParams();
//父亲的信息
int paddingLeft = getPaddingLeft();
int paddingRight = getPaddingRight();
int paddingTop = getPaddingTop();
int paddingBottom = getPaddingBottom();
int selfWidth = MeasureSpec.getSize(widthMeasureSpec); //解析的父亲给我的宽度
int selfHeight = MeasureSpec.getSize(heightMeasureSpec); //解析的父亲给我的高度
List lineViews = new ArrayList<>(); //保存一行中的所有的view
int lineWidthUsed = 0; //记录这行已经使用了多宽的size
int lineHeight = 0; // 一行的行高
int parentNeededWidth = 0; // measure过程中,子View要求的父ViewGroup的宽
int parentNeededHeight = 0; // measure过程中,子View要求的父ViewGroup的高
int childCount = getChildCount();
for (int i = 0; i < childCount; i++) {
View childView = getChildAt(i);
if (childView.getVisibility() != View.GONE) {
LayoutParams childLP = childView.getLayoutParams();
//测量child
int childWidthMeasureSpec = getChildMeasureSpec(widthMeasureSpec, paddingLeft + paddingRight, childLP.width);
int childHeightMeasureSpec = getChildMeasureSpec(heightMeasureSpec, paddingTop + paddingBottom, childLP.height);
childView.measure(childWidthMeasureSpec, childHeightMeasureSpec);
//获取child的width ,height
int childMeasureWidth = childView.getMeasuredWidth();
int childMeasureHeight = childView.getMeasuredHeight();
//换行处理
if (childMeasureWidth + lineWidthUsed + mHorizontalSpacing > selfWidth) {
parentNeededWidth = Math.max(parentNeededWidth, lineWidthUsed + mHorizontalSpacing);
parentNeededHeight = parentNeededHeight + lineHeight + mVerticalSpacing;
allLines.add(lineViews);
lineHeights.add(lineHeight);
lineViews = new ArrayList<>();
lineWidthUsed = 0;
lineHeight = 0;
}
//将child添加到一行视图数组中去
lineViews.add(childView);
lineWidthUsed += childMeasureWidth + mHorizontalSpacing;
lineHeight = Math.max(lineHeight, childMeasureHeight);
if (i == childCount - 1) {
parentNeededWidth = Math.max(parentNeededWidth, lineWidthUsed + mHorizontalSpacing);
parentNeededHeight = parentNeededHeight + lineHeight + mVerticalSpacing;
allLines.add(lineViews);
lineHeights.add(lineHeight);
}
}
}
//配置自己的宽高
int width_mode = MeasureSpec.getMode(parentNeededWidth);
int height_mode = MeasureSpec.getMode(parentNeededHeight);
int realParentWidth = width_mode == MeasureSpec.EXACTLY ? selfWidth : parentNeededWidth;
int realParentHeigt = height_mode == MeasureSpec.EXACTLY ? selfHeight : parentNeededHeight;
setMeasuredDimension(realParentWidth, realParentHeigt);
}
3) onSizeChanged()
上一步测量完每一个view的大小的时候,如果view的大小有变化,在这里完成view大小的修改,并且确定view的最终的大小,然后进入下一步布局环节。
4) onLayout()
这里我们就需要开始布局我们的视图了,会根据上一步确定的布局的大小,并按照父视图的左上角作为坐标原点,开始树形递归的方式绘制每一个View及其Child.
protected void onLayout(boolean changed, int l, int t, int r, int b) {
int lineCount = allLines.size();
int curL = getPaddingLeft();
int curT = getPaddingTop();
for (int i = 0; i < lineCount; i++) {
List lineViews = allLines.get(i);
int lineHeight = lineHeights.get(i);
for (int i1 = 0; i1 < lineViews.size(); i1++) {
View childView = lineViews.get(i1);
int left = curL;
int top = curT;
int right = left + childView.getMeasuredWidth();
int bottom = top + childView.getMeasuredHeight();
childView.layout(left, top, right, bottom);
curL = right + mHorizontalSpacing;
}
curT = curT + lineHeight + mVerticalSpacing;
curL = getPaddingLeft();
}
}
5) onDraw()
在上一步的layout布局完成后,就进入到绘制阶段,会根据上一步布局的坐标位置,在画布上绘制每一个视图。这个onDraw()的参数就是用来绘制view本身的,他给我们提供了绘制线,文字,各种图形等操作的方法。
首先我们需要一个Paint对象,他保存着如何绘制的样式颜色等信息;
然后canvas 来控制如何绘制图形,canvas 与paint一起完成了我们试图的绘制工作。
下面我们来看看绘制一下仿苹果开关的onDraw方法的实现
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
// 绘制背景图
//canvas.drawBitmap(bgBitmap, 0, 0, paint);
paint.setStrokeWidth(2);
paint.setAntiAlias(true);
paint.setStyle(Paint.Style.FILL);
if (currState) {
paint.setColor(ctx.getResources().getColor(R.color.black_999));
//外部
//左边的圆
canvas.drawCircle((float) (miniHeight * 0.5), (float) (miniHeight * 0.5), (float) (miniHeight * 0.5), paint);
//右边的圆
canvas.drawCircle((float) (miniWidth - miniHeight * 0.5), (float) (miniHeight * 0.5), (float) (miniHeight * 0.5), paint);
canvas.drawRect((float) (miniHeight * 0.5), 0, (float) (miniWidth - miniHeight * 0.5), miniHeight, paint);
//内部
paint.setColor(ctx.getResources().getColor(R.color.blut_theme_app));
canvas.drawCircle((float) (miniHeight * 0.5), (float) (miniHeight * 0.5), (float) (miniHeight * 0.5)-2, paint);
canvas.drawCircle((float) (miniWidth - miniHeight * 0.5), (float) (miniHeight * 0.5), (float) (miniHeight * 0.5)-2 , paint);
canvas.drawRect((float) (miniHeight * 0.5), 2, (float) (miniWidth - miniHeight * 0.5), miniHeight - 2, paint);
} else {
//外部
//左边的圆
paint.setColor(ctx.getResources().getColor(R.color.black_999));
canvas.drawCircle((float) (miniHeight * 0.5), (float) (miniHeight * 0.5), (float) (miniHeight * 0.5), paint);
canvas.drawCircle((float) (miniWidth - miniHeight * 0.5), (float) (miniHeight * 0.5), (float) (miniHeight * 0.5), paint);
canvas.drawRect((float) (miniHeight * 0.5), 0, (float) (miniWidth - miniHeight * 0.5), miniHeight, paint);
//内部
paint.setColor(ctx.getResources().getColor(R.color.white));
canvas.drawCircle((float) (miniHeight * 0.5), (float) (miniHeight * 0.5), (float) (miniHeight * 0.5)-2 , paint);
canvas.drawCircle((float) (miniWidth - miniHeight * 0.5), (float) (miniHeight * 0.5), (float) (miniHeight * 0.5) - 2, paint);
canvas.drawRect((float) (miniHeight * 0.5), 2, (float) (miniWidth - miniHeight * 0.5), miniHeight -2, paint);
}
float textWidth = paint.measureText(showText);
Paint.FontMetrics fontMetrics = paint.getFontMetrics();
float fontTotalHeight = fontMetrics.bottom - fontMetrics.top;
float newY = getMeasuredHeight() / 2 - fontMetrics.descent + (fontMetrics.descent - fontMetrics.ascent) / 2;
float baseX = 0;
if (!currState) {
paint.setColor(ctx.getResources().getColor(R.color.default_font_color));
baseX = (float) ((getMeasuredWidth() - textWidth) - getPaddingRight() - miniHeight * 0.4);
} else {
paint.setColor(ctx.getResources().getColor(R.color.white));
baseX = (float) (0 + getPaddingLeft() + miniHeight * 0.4);
}
canvas.drawText(showText, baseX, newY, paint);
// 绘制滑动图片
if (currState) {
paint.setColor(ctx.getResources().getColor(R.color.blut_theme_app));
canvas.drawCircle((float) (slideLeft + miniHeight * 0.5), (float) (miniHeight * 0.5), (float) (miniHeight * 0.5 )-4, paint);
paint.setColor(ctx.getResources().getColor(R.color.black_999));
canvas.drawCircle((float) (slideLeft + miniHeight * 0.5), (float) (miniHeight * 0.5), (float) (miniHeight * 0.5 )- 6, paint);
} else {
paint.setColor(ctx.getResources().getColor(R.color.white));
canvas.drawCircle((float) (slideLeft + miniHeight * 0.5), (float) (miniHeight * 0.5), (float) (miniHeight * 0.5 )-4, paint);
paint.setColor(ctx.getResources().getColor(R.color.black_999));
canvas.drawCircle((float) (slideLeft + miniHeight * 0.5), (float) (miniHeight * 0.5), (float) (miniHeight * 0.5 ) - 6, paint);
}
}
6)onTouchEvent()
当我们自定义的View需要与用户交互时,这时我们需要给自定义的View添加一些触摸事件,就需要重写该方法,我们可以通过还方法的参数获取到当前用户触摸点的坐标信息,然后处理相关的点击事件。
说明:
1)get()和getRaw()的区别
getX(),getY()获取的是触摸点在其所在组件中的坐标系坐标
getRawX()、getRawY()获取的是触摸点相对于屏幕左上角的坐标系坐标
public boolean onTouchEvent(MotionEvent event) {
// super 注释掉以后,onclick事件,就失效了,因为,点击这个动作,也是从onTouchEvent 方法中解析出来,符合一定的要求,就是一个点击事件
// 系统中,如果发现,view产生了up事件,就认为,发生了onclick动作,就行执行listener.onClick方法
super.onTouchEvent(event);
/*
* 点击切换开关,与触摸滑动切换开关,就会产生冲突
* 我们自己规定,如果手指在屏幕上移动,超过15个象素,就按滑动来切换开关,同时禁用点击切换开关的动作
*/
if (isScroll) {
return true;
}
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
System.out.println("MotionEvent.ACTION_DOWN");
downX = lastX = (int) event.getX(); // 获得相对于当前view的坐标
// event.getRawX(); // 是相对于屏幕的坐标
// down 事件发生时,肯定不是滑动的动作
isSliding = false;
break;
case MotionEvent.ACTION_MOVE:
System.out.println("MotionEvent.ACTION_MOVE");
// 获得距离
int disX = (int) (event.getX() - lastX);
// 改变滑动图片的左边界
slideLeft += disX;
flushView();
// 为lastX重新赋值
lastX = (int) event.getX();
// 判断是否发生滑动事件
if (Math.abs(event.getX() - downX) > 15) { // 手指在屏幕上滑动的距离大于15象素
isSliding = true;
}
break;
case MotionEvent.ACTION_UP:
System.out.println("MotionEvent.ACTION_UP");
// 只有发生了滑动,才执行以下代码
if (isSliding) {
// 如果slideLeft > 最大值的一半 当前是开状态
// 否则就是关的状态
if (slideLeft > slideLeftMax / 2) { // 开状态
currState = true;
} else {
currState = false;
}
flushState();
}
break;
}
return true;
}
以上我们学习了自定义view里面几个比较重要的方法,分别是做什么的,也了解了一下回绘制的流程,包括一些注意点,和使用的方法。这也仅仅是个入门了,更多漂亮的view 就需要各位看官自由发挥了。好了,今天就到这里了。