自定义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}