自定义View(二)
前面的文章介绍了自定义View的需要准备的基础知识,自定义View的属性获取、测量(Measure)过程,Measure过程是为了计算View的大小。本篇文章主要记载一下自定义View过程中的Layout过程,Layout过程用于计算View的位置。另外还有更关键的一步,那就是绘制(Draw)过程,一切都准备好了就需要开始绘制出我们的自定义View,后面会有一个绘制例子,在该示例中绘制了一个圆形且带有进度文字说明的进度条,通过这个示例切实感受一下自定义View的魅力。最后还有一个重点就是自定义View状态的存储与恢复。
Layout过程
前面说了在自定义View的时候,我们需要测量出View的宽和高,在某些情况下,需要多次测量才能确定View最终的宽/高;该情况下,Measure过程后得到的宽和高可能不准确,因此最好是在Layout过程中通过onLayout()方法去获取最终的宽和高。Layout过程用于计算View的位置,即计算View的四个顶点位置:Left、Top、Right、Bottom,还记得View的位置定义吗?
Layout过程也可以根据View的类型分为2种情况:对于单一View,仅仅需要计算本身View的位置即可。对于ViewGroup,除了计算自身View的位置外,还需要确定子View在父容器中的位置,即遍历调用所有子元素的measure()并且各子元素再递归去执行该流程。View树的位置是由包含的每个子视图的位置所决定的,所以如果想要计算整个View树的位置,则需递归计算每个子视图的位置,这与measure过程非常相似。
getWidth()、getHeight()与 getMeasuredWidth()、getMeasuredHeight()获取的宽高有什么区别呢?其实getWidth()、getHeight()是获得View最终的宽和高,而getMeasuredWidth()、getMeasuredHeight()是获得View测量的宽和高。一般情况下二者获取的宽和高是相等的,但是通过重写View的layout()方法强行设置后就会导致二者得到的结果不一致,所以在非人为设置的情况下,View的最终宽高getWidth()、getHeight()与View的测量宽高getMeasuredWidth()、getMeasuredHeight()永远是相等的。
Draw过程
Draw过程就是为了绘制出View视图,借助Paint、Canvas类,可以很轻松的实现绘制很多种图形,同时Draw也分为绘制单个View还是绘制ViewGroup,为了避免不必要的复杂度,示例中都是绘制单个View,所以Draw过程就是学习画笔(Paint)与画布(Canvas)的使用。
// 绘制
@Override
protected void onDraw(Canvas canvas) {
// 绘制圆
canvas.drawCircle(getWidth()/2, getHeight()/2, getHeight()/2 - paint.getStrokeWidth()/2, paint);
// 重新设置画笔宽度
paint.setStrokeWidth(2);
// 绘制直线
canvas.drawLine(0, getWidth()/2, getWidth(), getHeight()/2, paint);
canvas.drawLine(getWidth()/2, 0, getWidth()/2, getHeight(), paint);
// 绘制文本
paint.setTextSize(72);
paint.setStyle(Paint.Style.FILL);
paint.setStrokeWidth(0);
String stringTest = "Tim"
canvas.drawText(stringTest, 0, stringTest.length(), 0, getHeight(), paint);
}
invalidate()方法用于刷新View,本质是调用View的onDraw()重新绘制。在主线程之外,用postInvalidate()。requestLayout()方法和invalidate()方法恰恰相反,requestLayout()方法只调用measure()和layout()过程,不会调用draw(),不会重新绘制任何视图包括该调用者本身。
canvas.save()与canvas.restore()用于保存/恢复画布的状态,比如我现在为了绘图方便需要将画图旋转90°,但是我又希望只是在这一段绘制过程中才需要旋转画布,于是需要将之前正常的画布存储起来,然后旋转画布90°,用完了再恢复为正常状态:
canvas.save(); //这时候保存的是画布没旋转之前的状态
canvas.rotate(90, px / 2, py / 2); // 画布开始旋转
canvas.drawLine(......); // 收到旋转操作的影响
canvas.drawLine(......); // 收到旋转操作的影响
canvas.restore(); // 还原正常状态
对于translate()方法与rotate()方法,可以将它们理解为是画布平移,画布翻转,但是把它理解为坐标系的平移与翻转则会更加形象。前面的文章中说了,默认绘图坐标零点位于屏幕左上角,那么在调用translate(x, y)方法之后,则将原点(0,0)移动到了(x, y)。之后的所有绘图操作都将以(x, y)为原点执行。同理,rotate()方法也是一样,它将坐标系旋转了一定的角度。大家也许会想,这样的操作有什么用呢?的确,没有这两个方法,同样可以绘图,只要算好坐标,没有什么画不出来的。所以说,这些方法是Android用来帮助我们简化绘图而创建的。
状态的恢复与存储
很多时候我们为获得在视图中自由绘制的能力,需要创建一个继承于View类的定制类,然后重写onTouchEvent方法处理触摸事件,重写onDraw绘制自定义视觉效果,但这里可能会被一个问题困扰,那就是设备旋转导致数据丢失的问题,好在View类为我们提供了onSaveInstanceState和onRestoreInstanceState两个方法,虽然这两个方法和Activity两个方法很相似,但是千万别认为是一样的,因为他们的使用方法完全不同。 看看下面这个示例吧:
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tim="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">
<!-- 必须加上Id,方便系统重建 -->
<cn.tim.custom_view.TestView
android:id="@+id/tv"
android:layout_centerInParent="true"
tim:test_bool="true"
tim:test_dimension="100dp"
tim:test_enum="top"
tim:test_integer="10086"
android:layout_width="100dp"
android:layout_height="100dp"/>
</RelativeLayout>
对于自己的自定义View要想保存状态必须加上ID android:id="@+id/xxx"
,这样系统才能正确找到需要保存状态的View。
// 为了演示重建后的数据恢复
@Override
public boolean onTouchEvent(MotionEvent event) {
// 点击此View的时候更新要显示的文字
stringTest = "Jone";
invalidate();
return true;
}
// UI重建后数据的保存
private static final String INSTANCE = "instance";
private static final String KEY_TEXT = "key_text";
@Nullable
@Override
protected Parcelable onSaveInstanceState() {
Bundle bundle = new Bundle();
bundle.putString(KEY_TEXT, stringTest);
bundle.putParcelable(INSTANCE, super.onSaveInstanceState());
return bundle;
}
@Override
protected void onRestoreInstanceState(Parcelable state) {
if(state instanceof Bundle){
Bundle bundle = (Bundle) state;
Parcelable parcelable = bundle.getParcelable(INSTANCE);
super.onRestoreInstanceState(parcelable);
stringTest = bundle.getString(KEY_TEXT);
return;
}
super.onRestoreInstanceState(state);
}
自定义一个圆形进度条
在attrs.xml声明自定义View需要的属性:
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="RoundProgressBar">
<attr name="color" format="color"/>
<attr name="line_width" format="dimension"/>
<attr name="radius" format="dimension"/>
<attr name="android:progress" />
<attr name="android:textSize" />
</declare-styleable>
</resources>
编写自定义View类:RoundProgressBar.java
package cn.tim.custom_view;
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.util.AttributeSet;
import android.util.TypedValue;
import android.view.View;
public class RoundProgressBar extends View {
private int mRadius;
private int mColor;
private int mLineWidth;
private int mTextSize;
private int mProgress;
private Paint mPaint;
public RoundProgressBar(Context context, AttributeSet attrs) {
super(context, attrs);
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(36));
mProgress = ta.getInt(R.styleable.RoundProgressBar_android_progress, 30);
ta.recycle();
initPaint();
}
private float dp2px(int dpVal) {
return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dpVal, getResources().getDisplayMetrics());
}
private void initPaint() {
mPaint = new Paint();
mPaint.setAntiAlias(true);
mPaint.setColor(mColor);
}
public void setProgress(int progress) {
mProgress = progress;
invalidate();
}
public int getProgress() {
return mProgress;
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);
int width = 0;
if (widthMode == MeasureSpec.EXACTLY) {
width = widthSize;
} else {
int needWidth = measureWidth() + getPaddingLeft() + getPaddingRight();
if (widthMode == MeasureSpec.AT_MOST) {
width = Math.min(needWidth, widthSize);
} else {
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() + getPaddingTop() + getPaddingBottom();
if (heightMode == MeasureSpec.AT_MOST) {
height = Math.min(needHeight, heightSize);
} else { //MeasureSpec.UNSPECIFIED
height = needHeight;
}
}
setMeasuredDimension(width, height);
}
private int measureHeight()
{
return mRadius * 2;
}
private int measureWidth()
{
return mRadius * 2;
}
@Override
protected void onDraw(Canvas canvas) {
mPaint.setStyle(Paint.Style.STROKE);
mPaint.setStrokeWidth(mLineWidth * 1.0f / 4);
int width = getWidth();
int height = getHeight();
mPaint.setStrokeWidth(mLineWidth);
canvas.save();
canvas.translate(getPaddingLeft(), getPaddingTop());
float angle = mProgress * 1.0f / 100 * 360;
canvas.drawArc(new RectF(0, 0, width - getPaddingLeft() * 2, height - getPaddingLeft() * 2), 0, angle, false, mPaint);
canvas.restore();
String text = mProgress + "%";
mPaint.setStrokeWidth(0);
mPaint.setTextAlign(Paint.Align.CENTER);
mPaint.setTextSize(mTextSize);
int y = getHeight() / 2;
Rect bound = new Rect();
mPaint.getTextBounds(text, 0, text.length(), bound);
int textHeight = bound.height();
mPaint.setStyle(Paint.Style.FILL);
canvas.drawText(text, 0, text.length(), getWidth() / 2, y + textHeight / 2, mPaint);
mPaint.setStrokeWidth(0);
}
private static final String INSTANCE = "instance";
private static final String KEY_PROGRESS = "key_progress";
@Override
protected Parcelable onSaveInstanceState() {
Bundle bundle = new Bundle();
bundle.putInt(KEY_PROGRESS, mProgress);
bundle.putParcelable(INSTANCE, super.onSaveInstanceState());
return bundle;
}
@Override
protected void onRestoreInstanceState(Parcelable state) {
if (state instanceof Bundle) {
Bundle bundle = (Bundle) state;
Parcelable parcelable = bundle.getParcelable(INSTANCE);
super.onRestoreInstanceState(parcelable);
mProgress = bundle.getInt(KEY_PROGRESS);
return;
}
super.onRestoreInstanceState(state);
}
}
在activity_main.xml中使用:
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tim="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">
<cn.tim.custom_view.RoundProgressBar
android:id="@+id/id_pb"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true"
android:padding="10dp"
android:progress="0"
android:textSize="18sp"
tim:color="#ea22e4"
tim:radius="36dp"
/>
</RelativeLayout>
在MainActivity.java动态更新进度:
package cn.tim.custom_view;
import androidx.appcompat.app.AppCompatActivity;
import android.animation.ObjectAnimator;
import android.os.Bundle;
import android.view.View;
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.id_pb);
view.setOnClickListener((v)-> ObjectAnimator.ofInt(view, "progress", 0, 100).setDuration(3000).start());
}
}