读前思考:
为什么要自定义View?
答:当Android SDK中提供的系统UI控件无法满足业务需求时,这时候就需要我们使用自定义 View 来进行绘制了。
如何实现自定义View?
答:两种方式。其一为继承系统提供的成熟控件(比如LinearLayout,RelativeLayout,ImageView等)。其二为直接继承自系统View或者ViewGroup,并自绘现实内容。
1.了解自定义View的方法
主要重写两个方法:
(1) onMeasure():用于测量,你的控件占多大的地方由这个方法指定;
(2) onDarw():用于绘制,你的控件呈现给用户长什么样子由这个方法决定;
1.1 onMeasure( )
onMeasure( )方法中有两个参数,widthMeasureSpec 和 heightMeasureSpec,可以通过如下代码获取模式和大小
//获取高度模式
int height_mode = MeasureSpec.getMode(heightMeasureSpec);
//获取宽度模式
int with_mode = MeasureSpec.getMode(widthMeasureSpec);
//获取高度尺寸
int height_size = MeasureSpec.getSize(heightMeasureSpec);
//获取宽度尺寸
int width_size = MeasureSpec.getSize(widthMeasureSpec);
复制代码测量模式的话,有下面三种:
UNSPECIFIED:任意大小,想要多大就多大,尽可能大,一般我们不会遇到,如 ListView,RecyclerView,ScrollView 测量子 View 的时候给的就是 UNSPECIFIED ,一般开发中不需要关注它;
EXACTLY:一个确定的值,比如在布局中你是这样写的 layout_width="100dp","match_parent","fill_parent";
AT_MOST:包裹内容,比如在布局中你是这样写的 layout_width="wrap_content"。
1.2 onDraw( )
onDarw( ) 方法中有个参数 Canvas,Canvas 就是我们要在上面绘制的画布,我们可以使用我们的画笔在上面进行绘制,最后呈现给用户。
1.3 坐标系
在Android坐标系中,以屏幕左上角作为原点,这个原点向右是X轴的正轴,向下是Y轴正轴。
除了Android坐标系,还存在View坐标系,View坐标系内部关系如图所示。
View获取自身高度
由上图可算出View的高度:
- width = getRight() - getLeft();
- height = getBottom() - getTop();
View的源码当中提供了getWidth()和getHeight()方法用来获取View的宽度和高度,其内部方法和上文所示是相同的,我们可以直接调用来获取View得宽高。
View自身的坐标
通过如下方法可以获取View到其父控件的距离。
- getTop();获取View到其父布局顶边的距离。
- getLeft();获取View到其父布局左边的距离。
- getBottom();获取View到其父布局底边的距离。
- getRight();获取View到其父布局右边的距离。
2.自定义View流程
2.1 创建类并继承View
创建一个类,并继承View,本示例创建一个名为CustomView的类,需要实现其构造方法,为了在XML布局中使用自定义View的属性,至少需要提供一个参数包含Context和AttributeSet的构造方法,如下所示:
public class CustomView extends View {
public CustomView(Context context) {
this(context,null);
}
public CustomView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
init(context, attrs);
}
}
2.2 提供自定义属性
为了像系统提供的组件那样,可以在XML布局中设置视图组件的属性,需要提供自定义View的属性设置,在res/values路径下新建一个attrs.xml文件,并在其中编辑属性名和格式,常用的格式有string:字符串,boolean:布尔值,color:颜色值, dimension:尺寸值,enum:枚举值,flags:位,float:浮点值,fraction:百分数,integer整数值,reference:引用资源ID。示例如下:
<resources>
<declare-styleable name="CustomView">
<attr name="textContent" format="string|reference" />
<attr name="textSize" format="dimension|reference" />
<attr name="textColor" format="color|reference" />
<attr name="circleColor" format="color|reference" />
</declare-styleable>
</resources>
在XML布局中使用自定义属性,需要提供命名空间,命名空间的格式如:xmlns:[别名]="[schemas.android.com/apk/res/pa… name],还有一种常用的命名空间:xmlns:app="schemas.android.com/apk/res-aut…
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout 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"
tools:context=".MainActivity">
<com.android.viewdemo.CustomView
android:id="@+id/cv_view"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true"
app:textContent="Android"
app:textSize="50sp"
app:textColor="@color/teal_200"
app:circleColor="@color/purple_500"/>
</RelativeLayout>
在XML布局中设置属性值后,接着便是在自定义的View中获取这些属性值,调用context.obtainStyledAttributes()返回TypedArray数组,TypedArray调用相应的方法获取属性值,如调用typedArray.getString(R.styleable.CustomView_textContent)获得字符串,TypedArray对象在调用之后要调用typedArray.recycle()回收资源,示例如下:
TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.CustomView, 0, 0);
try {
textContent = typedArray.getString(R.styleable.CustomView_textContent);
textSize = typedArray.getDimensionPixelSize(R.styleable.CustomView_textSize, 50);
textColor = typedArray.getColor(R.styleable.CustomView_textColor, 0);
circleColor = typedArray.getColor(R.styleable.CustomView_circleColor, 0);
} finally {
typedArray.recycle();
}
2.3 提供属性的getter和setter方法
自定义View的属性不仅可以在XML布局中设置,还应提供getter和setter方法,以便在代码中更改属性,在调用setter方法更改属性时,View的外观发生变化时需要调用invalidate()方法使当前的视图失效,进而触发onDraw()方法重绘视图,如果View的大小和形状发生了变化,则需要调用requestLayout()请求重新布局,需要注意的是invalidate()方法要在UI线程中调用,在非UI线程中调用postInvalidate(),示例如下:
public void setTextContent(String textContent) {
this.textContent = textContent;
//外观发生变化时,在UI线程中调用
invalidate();
//大小和形状发生了变化调用,非必要不调用,以提高性能
requestLayout();
}
2.4 重写onMeasure()方法
此方法主要是用来控制View的大小,让父视图知道View希望的大小,在方法内计算得出希望View显示的大小后,调用setMeasuredDimension()方法将计算出的宽高传入,示例如下;
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int width = 0;
int height = 0;
int sizeWidth = MeasureSpec.getSize(widthMeasureSpec);
int modeWidth = MeasureSpec.getMode(widthMeasureSpec);
if (modeWidth == MeasureSpec.EXACTLY) {
width = sizeWidth;
} else if (modeWidth == MeasureSpec.AT_MOST) {
width = Math.min(defaultWidth, sizeWidth);
} else {
width = defaultWidth;
}
int sizeHeight = MeasureSpec.getSize(heightMeasureSpec);
int modeHeight = MeasureSpec.getMode(heightMeasureSpec);
if (modeHeight == MeasureSpec.EXACTLY) {
height = sizeHeight;
} else if (modeHeight == MeasureSpec.AT_MOST) {
height = Math.min(defaultHeight, sizeHeight);
} else {
height = defaultHeight;
}
setMeasuredDimension(width, height);
}
2.5 重写onSizeChanged()方法
当视图的大小发生变化时,onSizeChanged()方法会被调用,onSizeChanged()方法会携带4个参数,分别是新的宽度、新的高度、旧的宽度、旧的高度,这对正确地绘制View至关重要,绘制需要的位置和尺寸等参数需要在此方法内进行计算,示例如下:
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
textY = (float) h / 2;
centerX = (float) w / 2;
centerY = (float) h / 2;
maxCircleRadius = (float) (w - 20) / 2;
}
2.6 初始化画笔Paint
绘制View需要用到画布Canvas和画笔Paint,Canvas负责处理绘制什么,如点、线、圆、矩形等,Paint负责处理如何绘制,如绘制的颜色、是否填充、透明度等,画布Canvas可以在重写onDraw()方法后获取,而画笔Paint则需要在初始化阶段新建一个或多个Paint对象,示例如下:
paintText = new Paint();
paintText.setAntiAlias(true);
paintText.setTextSize(textSize);
paintText.setColor(textColor);
paintText.setStyle(Paint.Style.FILL);
paintCircle = new Paint();
paintCircle.setAntiAlias(true);
paintCircle.setColor(circleColor);
paintCircle.setStyle(Paint.Style.STROKE);
paintCircle.setStrokeWidth(10);
复制代码
2.7 重写onDraw()方法绘制View
绘制View是重要的一环,它将可见的界面呈现给使用者,重写onDraw()方法后,它将提供一个画布Canvas,它将和画笔Paint一起执行绘制,Canvas提供了丰富的绘制方法,如drawLine()绘制线段、drawText()绘制文本、drawPoint()绘制点、drawRect()绘制矩形等,传入计算好的参数和画笔,便可绘制出相应的图形,示例如下:
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
canvas.drawText(textContent, 30, textY + 30, paintText);
canvas.drawCircle(centerX, centerY, circleRadius, paintCircle);
}
2.8 响应用户手势操作
View还会经常与使用者进行交互,因此还需要响应和处理用户的手势操作,一般来说,需要重写onTouchEvent(MotionEvent event),在此方法内处理手势操作,常见的手势操作有按下、滑动、抬起等,在此方法内加上业务逻辑,示例如下:
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()){
case MotionEvent.ACTION_DOWN:
break;
case MotionEvent.ACTION_MOVE:
break;
case MotionEvent.ACTION_UP:
break;
}
return true;
}
此外,还可以借助GestureDetector类实现更多的手势检测,如双击、长按、滚动等。
2.9 添加动画效果
为了让自定义View更有吸引力和自然,还需要添加一些动画效果,这时候使用属性动画修改View的属性,可以产生动画效果,示例如下:
ObjectAnimator textAlpha = ObjectAnimator.ofInt(this, "textAlpha", 255, 50);
textAlpha.setDuration(2000);
textAlpha.setRepeatCount(ValueAnimator.INFINITE);
textAlpha.setRepeatMode(ValueAnimator.RESTART);
textAlpha.start();
textAlpha.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
int animatedValue = (int) animation.getAnimatedValue();
setTextAlpha(animatedValue);
}
});
ObjectAnimator circle = ObjectAnimator.ofFloat(this, "circleRadius", 0.0f, maxCircleRadius);
circle.setDuration(2000);
circle.setRepeatCount(ValueAnimator.INFINITE);
circle.setRepeatMode(ValueAnimator.RESTART);
circle.start();
circle.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
float animatedValue = (float) animation.getAnimatedValue();
setCircleRadius(animatedValue);
}
});
复制代码
2.10 对外提供回调接口
自定义View还应对外提供回调接口,以传递一些事件和数据,方便调用方处理相应的逻辑,常见的操作是在View内定义一些接口,在接口内部定义一些事件,并对外提供回调接口的方法,示例如下:
public interface OnCircleAnimationStartListener {
void onCircleAnimationStart();
}
public void setOnCircleAnimationStartListener(OnCircleAnimationStartListener onCircleAnimationStartListener) {
this.onCircleAnimationStartListener = onCircleAnimationStartListener;
}
cv_view.setOnCircleAnimationStartListener(new CustomView.OnCircleAnimationStartListener() {
@Override
public void onCircleAnimationStart() {
}
});
3.示例圆环——直接继承自View或者ViewGroup
这种自定义View实现麻烦一些,但是更加灵活,也能实现更加复杂的UI界面,实现过程中需要解决以下几个问题
如何根据相应的属性将UI元素绘制到界面
自定义控件的大小,也就是宽和高分别设置多少
如果是ViewGroup,如何合理安排其内部子View的排放位置
以上3个问题可以在如下3个方法中得到解决:
onDraw()
onMeasure()
onLayout()
所以自定义View的重点工作其实就是复写并合理实现这3个方法。
3.1.onDraw
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
}
onDraw方法接收一个Canvas类型的参数。Canvas可以理解为一个画布,在这块画布上可以绘制各种类型的UI
系统提供了一系列Canvas操作方法如下:
Canvas
public class Canvas extends BaseCanvas {
void drawArc(RectF oval, startAngle, float sweepAngle, useCenter,Paint paint) 绘制弧形
void drawBitmap(@NonNull Bitmap bitmap, float left, float top, @Nullable Paint paint) 绘制图片
void drawCircle(float cx, float cy, float radius, @NonNull Paint paint) 绘制圆形
void drawLine(float startX, float startY, float stopX, float stopY,Paint paint) 绘制直线
void drawOval(@NonNull RectF oval, @NonNull Paint paint) 绘制椭圆
void drawPath(@NonNull Path path, @NonNull Paint paint) 绘制path路径
void drawPoint(float x, float y, @NonNull Paint paint) 绘制点
void drawRect(Rect r, @NonNull Paint paint) 绘制矩形区域
void drawRoundRect(RectF rect, float rx, float ry, Paint paint) 绘制圆角矩形
void drawText(String text, float x, float y, @NonNull Paint paint) 绘制文本
}
调用Canvas类的draw方法最终会调用BaseCanvas中的native方法
Paint
在Canvas的各种draw方法中,都需要传入一个Paint对象。Paint相当于一个画笔,通过设置画笔的各种属性,来实现不同绘制效果:
public class Paint {
void setStyle(Style style) 设置绘制模式
void setColor(@ColorInt int color) 设置画笔颜色
void setAlpha(int a) 设置画笔透明度
void setStrokeWidth(float width) 设置线条宽度
void setStrokeCap(Cap cap) 设置画笔绘制两端时的样式
void setStrokeJoin(Join join) 设置画笔绘制时,折线的样式
Shader setShader(Shader shader) 设置Paint的填充效果
ColorFilter setColorFilter(ColorFilter filter) 设置画笔线的样式
public Xfermode setXfermode(Xfermode xfermode) 设置画笔的层叠效果
public Typeface setTypeface(Typeface typeface) 设置字体样式
void setTextSize(float textSize) 设置文本字体大小
void setAntiAlias(boolean aa) 设置抗锯齿开关
void setDither(boolean dither) 设置防抖动开关
}
实现圆环进行条控件
自定义控件
/**
* 绘制扇形进度控件:绘制一个圆,和其中代表进度的扇形
* 1。接收自定义属性- 原的颜色,扇形的颜色等
* 2。初始化Paint(2)
* 3。onDraw方法中绘制圆形和扇形
* 在onSizeChange方法中获取到绘制的区域
*/
public class PieImageView extends View {
private Paint arcPaint;
private Paint circlePaint;
private RectF mBound;
private int radius;
public PieImageView(Context context) {
this(context, null);
}
public PieImageView(Context context, @Nullable AttributeSet attrs) {
this(context, attrs, 0);
}
public PieImageView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
initPaint(context);
}
private void initPaint(Context context) {
arcPaint = new Paint();
arcPaint.setAntiAlias(true);
arcPaint.setStyle(Paint.Style.FILL_AND_STROKE);
arcPaint.setStrokeWidth(dpTopx(0.1f, context));
arcPaint.setColor(Color.BLUE);
circlePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
circlePaint.setStyle(Paint.Style.STROKE);
circlePaint.setStrokeWidth(dpTopx(2, context));
circlePaint.setColor(Color.RED);
mBound = new RectF();
}
/**
* 拿到控件的宽高
* 设置圆形绘制的半径
* 设置圆形绘制的区域 mBound
*/
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
Log.e("Tim", "onSizeChanged w:" + w + " ,h:" + h);
int min = Math.min(w, h);
radius = min / 3;
mBound.set(min / 2 - radius, min / 2 - radius, min / 2 + radius, min / 2 + radius);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
Log.e("Tim", "onDraw");
canvas.drawCircle(mBound.centerX(), mBound.centerY(), radius, circlePaint);
canvas.drawArc(mBound, 0, 125, true, arcPaint);
}
float density = 0;
/**
* dp转px
*/
private int dpTopx(float dp, Context context) {
if (density == 0) {//密度
density = context.getResources().getDisplayMetrics().density;
}
Log.e("Tim", "density:" + density);
return (int) (dp * density);
}
}
在xml中使用
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout 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:orientation="vertical">
<com.timmy.demopractice.view.PieImageView
android:layout_width="300dp"
android:layout_height="300dp"
android:background="@color/colorAccent" />
</LinearLayout>
参考文章:
https://www.jianshu.com/p/70ea446d2b99
https://juejin.cn/post/6844903607855218702