为什么要自定义控件
- 特定的显示风格
- 处理特有的用户交互
- 优化我们的布局
- 封装等...
如何自定义控件
- 自定义属性的声明与获取
- 测量onMeasure
- 绘制onDraw
- 状态的存储与恢复
步骤一、自定义属性声明与获取
- 分析需要的自定义属性
- 在res/valus/attrs.xml定义声明
- 在layout xml文件中进行使用
- 在View的构造方法中进行获取
实现步骤:
1.新建TestView继承View 实现其构造方法(下面只实现一个)
public TestView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
}
2.在value目录下新建 attrs.xml文件(可以自定义名字) 自定义属性
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!--声明 name一般为自定义View的name-->
<declare-styleable name="TestView">
<!--字符串-->
<attr name="test_string" format="string"></attr>
<!--像素(px) 1dp=2px-->
<attr name="test_dimension" format="dimension"></attr>
<!--布尔值-->
<attr name="test_boolean" format="boolean"></attr>
<!--整形-->
<attr name="test_integer" format="integer"></attr>
<!--枚举类型:当变量只有几种固定的值时-->
<attr name="test_enum" format="enum">
<enum name="top" value="1"></enum>
<enum name="bottom" value="2"></enum>
</attr>
</declare-styleable>
</resources>
3.在布局文件中使用自定义的布局
在根部局中添加 myview 一般是app 也可以自定义
xmlns:myview="http://schemas.android.com/apk/res-auto"
完整代码:
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:myview="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<com.demo.customview.TestView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true"
myview:test_boolean="true"
myview:test_enum="bottom"
myview:test_integer="100"
myview:test_dimension="100px"
myview:test_string="哇哇哇"
/>
</RelativeLayout>
4.在构造当中获取属性的值
方法1:直接通过typeArray 对象获取
public class TestView extends View {
public TestView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
//加载在attr中自定义控件的属性
TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.TestView);
//方法1:拿定义的属性值(属性name,默认值)
boolean test_boolean = ta.getBoolean(R.styleable.TestView_test_boolean, false);
float test_dimension = ta.getDimension(R.styleable.TestView_test_dimension, 100);
String test_string = ta.getString(R.styleable.TestView_test_string);
int test_integer = ta.getInteger(R.styleable.TestView_test_integer, 1);
int test_enum = ta.getInt(R.styleable.TestView_test_enum, 1);
Log.e("TAG", test_boolean + " , "
+ test_dimension + " , "
+ test_string + " , "
+ test_integer + " , "
+ test_enum);
//回收掉
ta.recycle();
}
}
打印结果: 和在布局文件中设置的一致
12-10 12:55:06.213 1604-1604/? E/TAG: true , 100.0 , 哇哇哇 , 100 , 2
方法2:先获取在布局文件中一共设置属性的数目,如果有设置该属性则负责,否则使用原来定义的值
//方法2:区别方法1:例如设置默认字符串 mText="def"
//然后在 mText=ta.getString(R.styleable.TestView_test_string);
//如果布局文件中没有设置该属性,则返回结果为null 原来的def就不见了
//方法2可以实现如果没有设置,则显示默认的值
//方法1如果想实现方法2的效果 也可以设置默认值
//拿到在布局文件中设置的属性个数,没设置的不算
int count = ta.getIndexCount();
for (int i = 0; i < count; i++) {
//拿到对应位置的属性名称
int index = ta.getIndex(i);
switch (index) {
case R.styleable.TestView_test_string:
mText = ta.getString(R.styleable.TestView_test_string);
break;
}
}
Log.e("TAG-2", test_boolean + " , "
+ test_dimension + " , "
+ mText + " , "
+ test_integer + " , "
+ test_enum);
布局文件修改:不定义test_string 属性
<com.demo.customview.TestView
android:layout_width="300dp"
android:layout_height="300dp"
android:layout_centerInParent="true"
android:background="@color/colorPrimaryDark"
myview:test_boolean="true"
myview:test_enum="bottom"
myview:test_integer="100"
myview:test_dimension="100px"
/>
打印:
- 方法2:会根据布局中是否设置了该属性的值,如果有则赋值,如果没有,则使用原来设置的值
- 方法1:会自动更新ta.getString 的值,则为null
12-10 13:04:59.921 4315-4315/? E/TAG-1: true , 100.0 , null , 100 , 2
12-10 13:04:59.922 4315-4315/? E/TAG-2: true , 100.0 , def , 100 , 2
步骤二、自定义View的测量(onMeasure)
- EXACTLY(固定值 例如:100dp) , AT_MOST(最多不多过父布局 wrap_content) , UNSPECIFIED (不确定 滑动布局)
- MeasureSpec
- setMeasuredDimension
- requestLayout()(刷新重新测量)
上面函数实现了:
- 接受父控件传入的高度 heightMeasureSpec
- 通过MeasureSpec类获取mode 和size
- 如果是布局设定为固定高度则直接返回size
- 如果不是,如果是At_MOST 则需要取小于size的高度(不能大于父控件的size)返回
getMeasuredWidth和getWidth的区别
View的getWidth()和getMeasuredWidth()有什么区别吗?
View的高宽是由View本身和Parent容器共同决定的。
getMeasuredWidth()
和getWidth()
分别对应于视图绘制的measure和layout阶段。
getMeasuredWidth()
获取的是View原始的大小,也就是这个View在XML文件中配置或者是代码中设置的大小。getWidth()
获取的是这个View最终显示的大小,这个大小有可能等于原始的大小,也有可能不相等。比如说,在父布局的
onLayout()
方法或者该View的onDraw()
方法里调用measure(0, 0)
,二者的结果可能会不同(measure
中的参数可以自己定义)。
实现步骤:
1、重写OnMeasure方法
- MeasureSpec.EXACTLY:精确模式,尺寸的值是多少,那么这个组件的长或宽就是多少
- MeasureSpec.AT_MOST:最大模式:同时父控件给出一个最大空间,不能超过这个值
- MeasureSpec.UNSPECIFIED:未指定模式,当前组件,可得到的空间不受限制
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
/**
* 宽度测量
* 1.拿到mode
* 2.判断属于哪种mode
* 3.得到具体的测量值
*/
//拿到父控件传入宽度的mode和size
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);
//设置测量宽度
int width = 0;
//如果设置的是确定的模式
if (widthMode == MeasureSpec.EXACTLY) {
//则测量的宽度为确定的宽度
width = widthSize;
} else {
//所需要的宽度 如果有设置padding
int needWidth = MeasureWidth() + getPaddingLeft() + getPaddingRight();
if (widthMode == MeasureSpec.AT_MOST) {
//取较小值 因为不能大于size
needWidth = Math.min(needWidth, widthSize);
} else {//否则就是UNSPECIFIED
//你测量多大,就有多大
width = needWidth;
}
}
/**
* 高度测量 同上
*/
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int heightSize = MeasureSpec.getSize(heightMeasureSpec);
int height = 0;
if (heightMode == MeasureSpec.EXACTLY) {
height = heightSize;
} else {
int needHeight = MeasureHeight() + getPaddingBottom() + getPaddingTop();
if (heightMode == MeasureSpec.AT_MOST) {
height = Math.min(needHeight, heightSize);
} else {
height = needHeight;
}
}
//设置测量像素
setMeasuredDimension(width, height);
}
/**
* 返回空间的高
* @return
*/
private int MeasureHeight() {
return 0;
}
/**
* 返回空间的宽
* @return
*/
private int MeasureWidth() {
return 0;
}
步骤三、绘制onDraw
- 1、绘制内容区域
- 2、invalidate() , postInvalidate();
- 3、Canvas.drawXXX
- 4、translate、rotatescale、skew
- 5、save()、restore()
具体步骤
重写onDraw方法
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
initPaint();
//画一个圆形 以视图的中心为圆点 半径为宽度/2
canvas.drawCircle(getWidth() / 2, getHeight() / 2, getWidth() / 2 - mPaint.getStrokeWidth() / 2, mPaint);
//画一条直线
canvas.drawLine(0, getHeight() / 2, getWidth(), getHeight() / 2, mPaint);
//画一条直线
canvas.drawLine(getWidth() / 2, 0, getWidth() / 2, getHeight(), mPaint);
//画字
canvas.drawText(mText, 0, mText.length(), 0, getHeight() / 2, mPaint);
}
/**
* 初始化画笔
*/
private void initPaint() {
//1.定义画笔
mPaint = new Paint();
//设置画笔的style 空心的
mPaint.setStyle(Paint.Style.STROKE);
//设置画笔颜色(红色)
mPaint.setColor(0xFFFF0000);
//设置画笔的大小
mPaint.setStrokeWidth(6);
//设置字体大小
mPaint.setTextSize(100);
}
效果:
步骤4、状态的存储与恢复
首先把布局的背景色去掉来进行测试
我们知道当视图被中断的时候再回来会被重建(例如屏幕旋转 它会重新执行onCreate方法)
为了保存在重建之前要保存状态,等oncreate之后拿到上一次保存的状态 就实现了状态的存储和恢复
- 1、onSaveInstanceState
- 2、onRestoreInstanceState
例子:没有进行状态存储之前 不会保存
重写以下方法:
/**
* 点击视图更新画面
*
* @param event
* @return
*/
@Override
public boolean onTouchEvent(MotionEvent event) {
mText = "8888";
//View 重绘 回调onDraw
invalidate();
//返回true代
return true;
}
/**
* 状态保存
*
* @return
*/
@Nullable
@Override
protected Parcelable onSaveInstanceState() {
Bundle bundle = new Bundle();
//控件的字符串 之前定义的
bundle.putString(KEY_TEXT, mText);
//存父控件的状态
bundle.putParcelable(INSTANCE, super.onSaveInstanceState());
return bundle;
}
/**
* 状态恢复
*
* @param state
*/
@Override
protected void onRestoreInstanceState(Parcelable state) {
if (state instanceof Bundle) {
Bundle b = (Bundle) state;
//恢复父类的状态
Parcelable parcelable = b.getParcelable(INSTANCE);
super.onRestoreInstanceState(parcelable);
//恢复子类的状态
mText = b.getString(KEY_TEXT);
return;
}
super.onRestoreInstanceState(state);
}
invalidate();
View(非容器类) 调用invalidate方法只会重绘自身,ViewGrounp调用则会重绘整个View树
之后:可以保存了
如果你发现还是不可以保存:那么你一定是忘记给控件添加id了!! 要在xml布局中添加id 因为系统是根据id来保存状态的
结合上述四个步骤:实现案例
1、在attrs.xml文件中添加 自定义RoundProgressBar的属性
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="RoundProgressBar">
<attr name="color" format="color"></attr>
<attr name="line_width" format="dimension"></attr>
<attr name="radius" format="dimension"></attr>
<!--也可以直接使用系统自带的属性-->
<attr name="android:progress"></attr>
<attr name="android:textSize"></attr>
</declare-styleable>
<!--声明-->
<declare-styleable name="TestView">
.....
</declare-styleable>
</resources>
2.布局文件
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:myview="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<com.demo.customview.RoundProgressBar
android:id="@+id/progressbar"
android:layout_width="100dp"
android:layout_height="100dp"
android:background="@color/colorPrimary"
android:padding="10dp"
android:layout_centerInParent="true" />
</RelativeLayout>
3.看一张图片
然后解决高度问题: 参考:Android自定义View绘制真正的居中文本
先了解一下Android是怎么样绘制文字的,这里涉及到几个概念,分别是文本的top,bottom,ascent,descent,baseline。
Baseline是基线,在android中文字的绘制都是从Baseline处开始的Baseline往上至字符“最高处”的距离我们称之为ascent(上坡度)
Baseline往下至字符“最低处”的距离我们称之为descent(下坡度);
leading(行间距)则表示上一行字符的descent到该行字符的ascent之间的距离;
Baseline是基线,Baseline以上是负值,以下是正值,因此 ascent是负值, descent是正值。
也可以通过
- int a=mPaint.ascent() 拿到Ascent—Baseline的距离
- int b=mPaint.desent()拿到Baseline—Desent的距离
- b-a 即为Ascent——Desent的距离
4.代码实现(仔细看注解)
RoundProgressBar.java 自定义视图
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Rect;
import android.graphics.RectF;
import android.os.Bundle;
import android.os.Parcelable;
import android.support.annotation.Nullable;
import android.util.AttributeSet;
import android.util.TypedValue;
import android.view.View;
/**
* <p>文件描述:<p>
* <p>作者:Mr-Donkey<p>
* <p>创建时间:2018/12/10 12:31<p>
* <p>更改时间:2018/12/10 12:31<p>
* <p>版本号:1<p>
*/
public class RoundProgressBar extends View {
//定义成员变量
private int mRadius; //半径
private int mColor;//颜色
private int mLineWidth;//线宽
private int mTextSize;//字体大小
private int mProgress;//进度
private Paint mPaint;
private RectF mProgressCicleRectf;
private Rect bound;
private int textHeight;
private String text;
public RoundProgressBar(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
//加载在attr中自定义控件的属性
TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.RoundProgressBar);
mRadius = (int) ta.getDimension(R.styleable.RoundProgressBar_radius, dp2px(30));
mColor = ta.getColor(R.styleable.RoundProgressBar_color, 0xffff0000);
mLineWidth = (int) ta.getDimension(R.styleable.RoundProgressBar_line_width, dp2px(3));
mTextSize = (int) ta.getDimension(R.styleable.RoundProgressBar_android_textSize, dp2px(16));
mProgress = ta.getInt(R.styleable.RoundProgressBar_android_progress, 30);
//初始化画笔
initPaint();
//回收掉
ta.recycle();
}
/**
* 将dp转成px的方法
*
* @param dpVal
* @return
*/
private float dp2px(int dpVal) {
return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dpVal, getResources().getDisplayMetrics());
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
/**
* 宽度测量
* 1.拿到mode
* 2.判断属于哪种mode
* 3.得到具体的测量值
*/
//拿到父控件传入宽度的mode和size
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);
//设置测量宽度
int width = 0;
//如果设置的是确定的模式
if (widthMode == MeasureSpec.EXACTLY) {
//则测量的宽度为确定的宽度
width = widthSize;
} else {
//所需要的宽度 如果有设置padding
int needWidth = MeasureWidth() + getPaddingLeft() + getPaddingRight();
if (widthMode == MeasureSpec.AT_MOST) {
//取较小值 因为不能大于size
needWidth = Math.min(needWidth, widthSize);
} else {//否则就是UNSPECIFIED
//你测量多大,就有多大
width = needWidth;
}
}
/**
* 高度测量 同上
*/
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int heightSize = MeasureSpec.getSize(heightMeasureSpec);
int height = 0;
if (heightMode == MeasureSpec.EXACTLY) {//指明宽度高度 width=xxdp
height = heightSize;
} else {
int needHeight = MeasureHeight() + getPaddingBottom() + getPaddingTop();
if (heightMode == MeasureSpec.AT_MOST) { //wrap_content时
height = Math.min(needHeight, heightSize);
} else {
height = needHeight;
}
}
//因为是圆形,假设用户传入宽高不等,肯定是不行的
//所以取他们俩的最小值,保持宽高一定
width = Math.min(width, height);
//设置测量像素
setMeasuredDimension(width, width);
}
/**
* 返回控件需要的高
*
* @return
*/
private int MeasureHeight() {
return mRadius * 2;
}
/**
* 返回控件需要的宽
*
* @return
*/
private int MeasureWidth() {
return mRadius * 2;
}
/**
* 先进行onSizeChanged 再ondraw
*
* @param w
* @param h
* @param oldw
* @param oldh
*/
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
//内部圆形矩形区域
mProgressCicleRectf = new RectF(0, 0, w - getPaddingLeft() - getPaddingRight(), h - getPaddingTop() - getPaddingBottom());
//拿到字的高度
bound = new Rect();
}
/**
* 画布绘制
* ondraw方法中尽可能不要new对象和进行复杂的操作
* 可以放到onSizeChanged中进行
*
* @param canvas
*/
@Override
protected void onDraw(Canvas canvas) {
//空心圆
mPaint.setStyle(Paint.Style.STROKE);
//设置内部圆画笔的宽度
mPaint.setStrokeWidth(mLineWidth * 1.0f / 4);
//绘制圆
int width = getWidth();
int height = getHeight();
//圆心x,y,半径r,画笔
//半径要减去画笔的宽度的一半 和padding的距离
canvas.drawCircle(width / 2, height / 2,
width / 2 - getPaddingLeft() - mPaint.getStrokeWidth() / 2,
mPaint);
//绘制外部圆 圆弧
//重写设置画笔宽度
mPaint.setStrokeWidth(mLineWidth);
canvas.save();
//移动绘制坐标的圆点让(0,0)变成(getPaddingLeft(),getPaddingTop()) 在这里是(10,10)
//就可以拿到圆所在的矩形区域了
canvas.translate(getPaddingLeft(), getPaddingTop());
//拿到角度
float angle = mProgress * 1.0f / 100 * 360;
//矩形的绘制 RectF 因为已经平移了 所以 可以将(10,10)当做新位置的初始坐标0,0,宽度,高度
//参数1:所在的矩形区域(规定圆的范围) 参数2:开始的位置 参数3:结束的位置 参数4:是否画扇形 参数5:画笔
canvas.drawArc(mProgressCicleRectf,
0, angle, false, mPaint);
canvas.restore();
/**
* 绘制中间文本 进度值
*/
text = mProgress + "%";
//让文本水平居中
mPaint.setTextAlign(Paint.Align.CENTER);
mPaint.setTextSize(mTextSize);
int y = getHeight() / 2;
mPaint.getTextBounds(text, 0, text.length(), bound);
textHeight = bound.height();
//最主要是y的位置 是baseline(基线) 看图 已经在onSizeChanged中进行计算 避免在onDraw方法在new 对象
//y的位置要加上文本的一半
canvas.drawText(text, 0, text.length(), getWidth() / 2, y + textHeight / 2
, mPaint);
//特别说明:当字体是中文时 y的高度需要设置为 上移descent的1/2
//y + textHeight / 2 - mPaint.descent()/2;
}
/**
* 对外提供设置progress
* 属性动画调用
*
* @param progress
*/
public void setProgress(int progress) {
mProgress = progress;
//重绘视图
invalidate();
}
public int getProgress() {
return mProgress;
}
/**
* 初始化画笔
*/
private void initPaint() {
//1.定义画笔
mPaint = new Paint();
mPaint.setColor(mColor);
//设置抗锯齿
mPaint.setAntiAlias(true);
}
private static final String INSTANCE = "instance";
private static final String KEY_PROGRESS = "key_progress";
/**
* 状态保存
*
* @return
*/
@Nullable
@Override
protected Parcelable onSaveInstanceState() {
Bundle bundle = new Bundle();
//控件的字符串 之前定义的
bundle.putInt(KEY_PROGRESS, mProgress);
//存父控件的状态
bundle.putParcelable(INSTANCE, super.onSaveInstanceState());
return bundle;
}
/**
* 状态恢复
*
* @param state
*/
@Override
protected void onRestoreInstanceState(Parcelable state) {
if (state instanceof Bundle) {
Bundle b = (Bundle) state;
//恢复父类的状态
Parcelable parcelable = b.getParcelable(INSTANCE);
super.onRestoreInstanceState(parcelable);
//恢复子类的状态
mProgress = b.getInt(KEY_PROGRESS);
return;
}
super.onRestoreInstanceState(state);
}
}
MainActivity.java
设置了属性动画,自动
public class MainActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
final View view = findViewById(R.id.progressbar);
view.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
//设置属性动画
//对象属性自动赋值 get set 方法 自动赋值开始0 结束100
ObjectAnimator.ofInt(view, "progress", 0, 100).setDuration(3000).start();
}
});
}
}
完成啦