自定义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)的使用。
1// 绘制
2@Override
3protected void onDraw(Canvas canvas) {
4 // 绘制圆
5 canvas.drawCircle(getWidth()/2, getHeight()/2, getHeight()/2 - paint.getStrokeWidth()/2, paint);
6
7 // 重新设置画笔宽度
8 paint.setStrokeWidth(2);
9
10 // 绘制直线
11 canvas.drawLine(0, getWidth()/2, getWidth(), getHeight()/2, paint);
12 canvas.drawLine(getWidth()/2, 0, getWidth()/2, getHeight(), paint);
13
14 // 绘制文本
15 paint.setTextSize(72);
16 paint.setStyle(Paint.Style.FILL);
17 paint.setStrokeWidth(0);
18
19 String stringTest = "Tim"
20 canvas.drawText(stringTest, 0, stringTest.length(), 0, getHeight(), paint);
21}
invalidate()方法用于刷新View,本质是调用View的onDraw()重新绘制。在主线程之外,用postInvalidate()。requestLayout()方法和invalidate()方法恰恰相反,requestLayout()方法只调用measure()和layout()过程,不会调用draw(),不会重新绘制任何视图包括该调用者本身。
canvas.save()与canvas.restore()用于保存/恢复画布的状态,比如我现在为了绘图方便需要将画图旋转90°,但是我又希望只是在这一段绘制过程中才需要旋转画布,于是需要将之前正常的画布存储起来,然后旋转画布90°,用完了再恢复为正常状态:
1canvas.save(); //这时候保存的是画布没旋转之前的状态
2
3canvas.rotate(90, px / 2, py / 2); // 画布开始旋转
4
5canvas.drawLine(......); // 收到旋转操作的影响
6canvas.drawLine(......); // 收到旋转操作的影响
7
8canvas.restore(); // 还原正常状态
对于translate()方法与rotate()方法,可以将它们理解为是画布平移,画布翻转,但是把它理解为坐标系的平移与翻转则会更加形象。前面的文章中说了,默认绘图坐标零点位于屏幕左上角,那么在调用translate(x, y)方法之后,则将原点(0,0)移动到了(x, y)。之后的所有绘图操作都将以(x, y)为原点执行。同理,rotate()方法也是一样,它将坐标系旋转了一定的角度。大家也许会想,这样的操作有什么用呢?的确,没有这两个方法,同样可以绘图,只要算好坐标,没有什么画不出来的。所以说,这些方法是Android用来帮助我们简化绘图而创建的。
状态的恢复与存储
很多时候我们为获得在视图中自由绘制的能力,需要创建一个继承于View类的定制类,然后重写onTouchEvent方法处理触摸事件,重写onDraw绘制自定义视觉效果,但这里可能会被一个问题困扰,那就是设备旋转导致数据丢失的问题,好在View类为我们提供了onSaveInstanceState和onRestoreInstanceState两个方法,虽然这两个方法和Activity两个方法很相似,但是千万别认为是一样的,因为他们的使用方法完全不同。 看看下面这个示例吧:
1<?xml version="1.0" encoding="utf-8"?>
2<RelativeLayout
3 xmlns:android="http://schemas.android.com/apk/res/android"
4 xmlns:tim="http://schemas.android.com/apk/res-auto"
5 xmlns:tools="http://schemas.android.com/tools"
6 android:layout_width="match_parent"
7 android:layout_height="match_parent"
8 tools:context=".MainActivity">
9
10 <!-- 必须加上Id,方便系统重建 -->
11 <cn.tim.custom_view.TestView
12 android:id="@+id/tv"
13 android:layout_centerInParent="true"
14 tim:test_bool="true"
15 tim:test_dimension="100dp"
16 tim:test_enum="top"
17 tim:test_integer="10086"
18 android:layout_width="100dp"
19 android:layout_height="100dp"/>
20
21</RelativeLayout>
对于自己的自定义View要想保存状态必须加上ID android:id="@+id/xxx"
,这样系统才能正确找到需要保存状态的View。
1// 为了演示重建后的数据恢复
2@Override
3public boolean onTouchEvent(MotionEvent event) {
4 // 点击此View的时候更新要显示的文字
5 stringTest = "Jone";
6 invalidate();
7 return true;
8}
9
10// UI重建后数据的保存
11private static final String INSTANCE = "instance";
12private static final String KEY_TEXT = "key_text";
13@Nullable
14@Override
15protected Parcelable onSaveInstanceState() {
16 Bundle bundle = new Bundle();
17 bundle.putString(KEY_TEXT, stringTest);
18 bundle.putParcelable(INSTANCE, super.onSaveInstanceState());
19 return bundle;
20}
21
22@Override
23protected void onRestoreInstanceState(Parcelable state) {
24 if(state instanceof Bundle){
25 Bundle bundle = (Bundle) state;
26 Parcelable parcelable = bundle.getParcelable(INSTANCE);
27 super.onRestoreInstanceState(parcelable);
28 stringTest = bundle.getString(KEY_TEXT);
29 return;
30 }
31 super.onRestoreInstanceState(state);
32}
自定义一个圆形进度条
在attrs.xml声明自定义View需要的属性:
1<?xml version="1.0" encoding="utf-8"?>
2<resources>
3 <declare-styleable name="RoundProgressBar">
4 <attr name="color" format="color"/>
5 <attr name="line_width" format="dimension"/>
6 <attr name="radius" format="dimension"/>
7 <attr name="android:progress" />
8 <attr name="android:textSize" />
9 </declare-styleable>
10</resources>
编写自定义View类:RoundProgressBar.java
1package cn.tim.custom_view;
2
3import android.content.Context;
4import android.content.res.TypedArray;
5import android.graphics.Canvas;
6import android.graphics.Paint;
7import android.graphics.Rect;
8import android.graphics.RectF;
9import android.os.Bundle;
10import android.os.Parcelable;
11import android.util.AttributeSet;
12import android.util.TypedValue;
13import android.view.View;
14
15
16public class RoundProgressBar extends View {
17
18 private int mRadius;
19 private int mColor;
20 private int mLineWidth;
21 private int mTextSize;
22 private int mProgress;
23
24 private Paint mPaint;
25
26 public RoundProgressBar(Context context, AttributeSet attrs) {
27 super(context, attrs);
28
29 TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.RoundProgressBar);
30
31 mRadius = (int) ta.getDimension(R.styleable.RoundProgressBar_radius, dp2px(30));
32 mColor = ta.getColor(R.styleable.RoundProgressBar_color, 0xffff0000);
33 mLineWidth = (int) ta.getDimension(R.styleable.RoundProgressBar_line_width, dp2px(3));
34 mTextSize = (int) ta.getDimension(R.styleable.RoundProgressBar_android_textSize, dp2px(36));
35 mProgress = ta.getInt(R.styleable.RoundProgressBar_android_progress, 30);
36
37 ta.recycle();
38
39 initPaint();
40 }
41
42 private float dp2px(int dpVal) {
43 return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dpVal, getResources().getDisplayMetrics());
44 }
45
46 private void initPaint() {
47 mPaint = new Paint();
48 mPaint.setAntiAlias(true);
49 mPaint.setColor(mColor);
50 }
51
52 public void setProgress(int progress) {
53 mProgress = progress;
54 invalidate();
55 }
56
57 public int getProgress() {
58 return mProgress;
59 }
60
61 @Override
62 protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
63 int widthMode = MeasureSpec.getMode(widthMeasureSpec);
64 int widthSize = MeasureSpec.getSize(widthMeasureSpec);
65
66 int width = 0;
67 if (widthMode == MeasureSpec.EXACTLY) {
68 width = widthSize;
69 } else {
70 int needWidth = measureWidth() + getPaddingLeft() + getPaddingRight();
71 if (widthMode == MeasureSpec.AT_MOST) {
72 width = Math.min(needWidth, widthSize);
73 } else {
74 width = needWidth;
75 }
76 }
77
78 int heightMode = MeasureSpec.getMode(heightMeasureSpec);
79 int heightSize = MeasureSpec.getSize(heightMeasureSpec);
80 int height = 0;
81
82 if (heightMode == MeasureSpec.EXACTLY) {
83 height = heightSize;
84 }else {
85 int needHeight = measureHeight() + getPaddingTop() + getPaddingBottom();
86 if (heightMode == MeasureSpec.AT_MOST) {
87 height = Math.min(needHeight, heightSize);
88 } else { //MeasureSpec.UNSPECIFIED
89 height = needHeight;
90 }
91 }
92 setMeasuredDimension(width, height);
93 }
94
95 private int measureHeight()
96 {
97 return mRadius * 2;
98 }
99
100 private int measureWidth()
101 {
102 return mRadius * 2;
103 }
104
105
106 @Override
107 protected void onDraw(Canvas canvas) {
108 mPaint.setStyle(Paint.Style.STROKE);
109 mPaint.setStrokeWidth(mLineWidth * 1.0f / 4);
110
111 int width = getWidth();
112 int height = getHeight();
113
114 mPaint.setStrokeWidth(mLineWidth);
115 canvas.save();
116 canvas.translate(getPaddingLeft(), getPaddingTop());
117 float angle = mProgress * 1.0f / 100 * 360;
118 canvas.drawArc(new RectF(0, 0, width - getPaddingLeft() * 2, height - getPaddingLeft() * 2), 0, angle, false, mPaint);
119 canvas.restore();
120
121 String text = mProgress + "%";
122 mPaint.setStrokeWidth(0);
123 mPaint.setTextAlign(Paint.Align.CENTER);
124 mPaint.setTextSize(mTextSize);
125 int y = getHeight() / 2;
126 Rect bound = new Rect();
127 mPaint.getTextBounds(text, 0, text.length(), bound);
128 int textHeight = bound.height();
129 mPaint.setStyle(Paint.Style.FILL);
130 canvas.drawText(text, 0, text.length(), getWidth() / 2, y + textHeight / 2, mPaint);
131
132 mPaint.setStrokeWidth(0);
133 }
134
135
136 private static final String INSTANCE = "instance";
137 private static final String KEY_PROGRESS = "key_progress";
138
139 @Override
140 protected Parcelable onSaveInstanceState() {
141 Bundle bundle = new Bundle();
142 bundle.putInt(KEY_PROGRESS, mProgress);
143 bundle.putParcelable(INSTANCE, super.onSaveInstanceState());
144 return bundle;
145 }
146
147 @Override
148 protected void onRestoreInstanceState(Parcelable state) {
149 if (state instanceof Bundle) {
150 Bundle bundle = (Bundle) state;
151 Parcelable parcelable = bundle.getParcelable(INSTANCE);
152 super.onRestoreInstanceState(parcelable);
153 mProgress = bundle.getInt(KEY_PROGRESS);
154 return;
155 }
156 super.onRestoreInstanceState(state);
157 }
158}
在activity_main.xml中使用:
1<?xml version="1.0" encoding="utf-8"?>
2<RelativeLayout
3 xmlns:android="http://schemas.android.com/apk/res/android"
4 xmlns:tim="http://schemas.android.com/apk/res-auto"
5 xmlns:tools="http://schemas.android.com/tools"
6 android:layout_width="match_parent"
7 android:layout_height="match_parent"
8 tools:context=".MainActivity">
9
10 <cn.tim.custom_view.RoundProgressBar
11 android:id="@+id/id_pb"
12 android:layout_width="wrap_content"
13 android:layout_height="wrap_content"
14 android:layout_centerInParent="true"
15 android:padding="10dp"
16 android:progress="0"
17 android:textSize="18sp"
18 tim:color="#ea22e4"
19 tim:radius="36dp"
20 />
21
22</RelativeLayout>
在MainActivity.java动态更新进度:
1package cn.tim.custom_view;
2
3import androidx.appcompat.app.AppCompatActivity;
4
5import android.animation.ObjectAnimator;
6import android.os.Bundle;
7import android.view.View;
8
9public class MainActivity extends AppCompatActivity {
10
11 @Override
12 protected void onCreate(Bundle savedInstanceState) {
13 super.onCreate(savedInstanceState);
14 setContentView(R.layout.activity_main);
15
16 final View view = findViewById(R.id.id_pb);
17 view.setOnClickListener((v)-> ObjectAnimator.ofInt(view, "progress", 0, 100).setDuration(3000).start());
18 }
19}