Android开发实战——自定义view之文字绘制+居中+仿歌词动画

首先新建文件MyTextView,继承AppCompatTextView,并重写onDraw方法:

public class MyTextView extends AppCompatTextView {
    /**
     * 需要绘制的文字
     */
    private String mText;
    /**
     * 文本的颜色
     */
    private int mTextColor;
    /**
     * 文本的大小
     */
    private int mTextSize;

    private Paint mPaint;

    public MyTextView(Context context) {
        super(context);
    }

    public MyTextView(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    public MyTextView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
    }
}

 

先处理自定义的属性,使我们在布局时可以随意更改该view的文字内容、颜色、大小

在res/values/下创建一个名为attrs.xml的文件,然后定义如下属性: 
format的意思是该属性的取值是什么类型(支持的类型有string,color,demension,integer,enum,reference,float,boolean,fraction,flag)

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <declare-styleable name="MyTextView">
        <attr name="mText" format="string" />
        <attr name="mTextColor" format="color" />
        <attr name="mTextSize" format="dimension" />
    </declare-styleable>
</resources>

 

在布局文件中引入我们的命名空间xmlns:lfm="http://schemas.android.com/apk/res-auto"

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:lfm="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <com.lfm.view.MyTextView
        android:id="@+id/my_tv"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        lfm:mText="金大人的梦"
        lfm:mTextColor="#000000"
        lfm:mTextSize="30sp" />

</LinearLayout>

在构造方法中获取自定义属性的值:

public MyTextView(Context context, AttributeSet attrs) {
        super(context, attrs);
        //获取自定义属性的值
        TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.MyTextView);
        mText = a.getString(R.styleable.MyTextView_mText);
        mTextColor = a.getColor(R.styleable.MyTextView_mTextColor, Color.BLACK);
        mTextSize = (int) a.getDimension(R.styleable.MyTextView_mTextSize, 100);
        a.recycle();  //回收
    }

 

接下来处理onDraw方法里面的内容即可

自定义view,画笔画布不可少,

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        // 绘制文字
        mPaint = new Paint();
        mPaint.setTextSize(mTextSize);
     canvas.drawText(mText,0,0,mPaint);
}

看下效果:

与想象的似乎不太一样,文字的X坐标和Y坐标并不是从屏幕的0、0开始的,我们看一下源码drawText方法:

/**
     * Draw the text, with origin at (x,y), using the specified paint. The origin is interpreted
     * based on the Align setting in the paint.
     *
     * @param text The text to be drawn
     * @param x The x-coordinate of the origin of the text being drawn
     * @param y The y-coordinate of the baseline of the text being drawn
     * @param paint The paint used for the text (e.g. color, size, style)
     */
    public void drawText(@NonNull String text, float x, float y, @NonNull Paint paint) {
        super.drawText(text, x, y, paint);
    }

从源码的注释发现,y坐标是根据一个叫baseline的值来开始绘制的,那么baseline是什么东西呢:

以上图来理解,baseline就是红线,指的是文字的基准线,就好像我们小学时候的标准拼音四格线一样:

因此,当y设置为0的时候,实际上就是基准线为0,那么此时我们在屏幕上就只能看到基准线以下的区域,所以才会出现运行效果图的那种情况,所以当我们设置baseline=100时看看效果:

@Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        // 绘制文字
        mPaint = new Paint();
        mPaint.setTextSize(mTextSize);
        mPaint.setColor(mTextColor);
        float baseline = 100;
        canvas.drawText(mText, 0, baseline, mPaint);
    }

那么此时文字就可以显示出来了。

 

以上是最最基本的自定义textview,如何将文字横竖都居中呢?

1、我们先把中心坐标给画出来,便于我们处理是否真的是居中了。

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        // 绘制文字
        mPaint = new Paint();
        mPaint.setTextSize(mTextSize);
        mPaint.setColor(mTextColor);
        float baseline = 100;
        canvas.drawText(mText, 0, baseline, mPaint);

        drawCenterLineX(canvas);
        drawCenterLineY(canvas);

    }

    private void drawCenterLineX(final Canvas canvas){
        Paint paint = new Paint();
        paint.setStyle(Paint.Style.FILL);//实线
        paint.setColor(Color.RED);// 颜色
        paint.setStrokeWidth(3);// 线的宽度
        canvas.drawLine(getWidth()/2,0,getWidth()/2,getHeight(),paint);
    }

    private void drawCenterLineY(final Canvas canvas){
        Paint paint = new Paint();
        paint.setStyle(Paint.Style.FILL);
        paint.setColor(Color.BLUE);
        paint.setStrokeWidth(3);
        canvas.drawLine(0,getHeight()/2,getWidth(),getHeight()/2,paint);
    }

2、横向居中,有两种方法

(1)在绘制文字之前也就是drawText之前添加如下代码

mPaint.setTextAlign(Paint.Align.CENTER);
canvas.drawText(mText, getWidth()/2, baseline, mPaint);

 

(2)当setTextAlign不设置为CENTER时,默认则是为LEFT,效果如图所示

//mPaint.setTextAlign(Paint.Align.CENTER);
canvas.drawText(mText, getWidth()/2, baseline, mPaint);

那么此时想要居中的话,应该在X轴向左偏移文字宽度的一半即可,效果图+代码如下

//mPaint.setTextAlign(Paint.Align.CENTER);
float width = mPaint.measureText(mText);//获取文字宽度
canvas.drawText(mText, (getWidth()-width)/2, baseline, mPaint);

 

3、纵向居中

纵向坐标的起始位置取决于baseline,先把baseline设置为屏幕高度的一半看看效果,为了有更明显的感觉,我把文字大小进行了调整,变得更大了

float baseline = getHeight()/2;
canvas.drawText(mText, (getWidth()-width)/2, baseline, mPaint);

发现文字在纵向并没有居中,与前面的分析一样,也就是如果我们将baseline设置为控件高度的一半,那么文字的绘制是以该线为基准线,那么想要将文字纵向居中要如何处理呢?paint有一个属性

Paint.FontMetrics fontMetrics = paint.getFontMetrics();

FontMetrics有几个属性,对应的就是下图所标明的位置

//        public float ascent;//基准线距离上边界的距离,文字通常在这个范围内(由于各个国家地区文字不同,可能会有超出这个范围的,例如藏文,但是最高不会超出top),ascent为负数

//        public float bottom; //基准线距离下边界的最高距离,文字最低不能超出bottom的范围

//        public float descent;//基准线距离下边界的距离,文字通常在这个范围内(由于各个国家地区文字不同,可能会有超出这个范围的,例如藏文,但是最高不会超出bottom)

//        public float leading;//看成行距即可

//        public float top; //基准线距离上边界的最高距离,文字最高不能超出top的范围,top为负数

注:Android中,X或Y偏移时,往左和上是减,往右和下是加,而top和ascent的值为负数

那么实际上想要文字垂直方向居中,那么基于上图再结合我们的运行效果图,我们可以得出以下结论:

此时图中红线也就是基准线,也是屏幕的中心线,如果我们想让文字垂直居中,在中心线不变的情况下,那么文字需要再往下移动一段距离,也就是基准线(baseline)需要往下移动才能使文字居中

结合图中字母Jj,可以想象成,文字下移一段距离之后,红线才会处于文字的中间,那么这个文字下移的距离就是baseline下移的距离,那么我们可以得出结论

首先红线的位置在我们代码里面是 getHeight()/2的位置

文字先向下走ascent的距离,再向上走(descent+ascent)/2的距离,就居中了。

因为ascent为负数,所以要取绝对值,所以向下走是 getHeight()/2-ascent,再向上走getHeight()/2-ascent-(descent-ascent)/2

float baseline = getHeight() / 2 - fontMetrics.ascent - (fontMetrics.descent - fontMetrics.ascent) / 2;

一步一步简化公式:

float baseline = getHeight() / 2 - fontMetrics.ascent - fontMetrics.descent/2 + fontMetrics.ascent/ 2;
float baseline = getHeight() / 2 - fontMetrics.descent/2 - fontMetrics.ascent + fontMetrics.ascent/ 2;
float baseline = getHeight() / 2 - fontMetrics.descent/2 - fontMetrics.ascent/2;
float baseline = getHeight() / 2 - (fontMetrics.descent + fontMetrics.ascent)/2;

看到上面这段代码就非常的熟悉了,这个公式应该在很多博客里面都见到过,就是这样简算得到的,如果不进行这样的分析,估计很多人都无法理解这个公式到底是什么意思。看下效果图:

最终onDraw方法里面的代码:

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        // 绘制文字
        mPaint = new Paint();
        mPaint.setTextSize(mTextSize);
        mPaint.setColor(mTextColor);
        //mPaint.setTextAlign(Paint.Align.CENTER);
        float width = mPaint.measureText(mText);//获取文字宽度

        Paint.FontMetrics fontMetrics = mPaint.getFontMetrics();
        float baseline = getHeight() / 2 - (fontMetrics.descent + fontMetrics.ascent)/2;
        canvas.drawText(mText, (getWidth() - width) / 2, baseline, mPaint);

        drawCenterLineX(canvas);
        drawCenterLineY(canvas);

    }

如何仿歌词动画?

已知文字是没有只设置一部分颜色值这个方法的,听歌的时候,歌词是逐渐的变色,那么思路是如何呢?

首先要了解canvas(画布),canvas在绘制的时候是可以一层一层绘制的,以save方法开始、restore方法结束为一层。

canvas.save();
...
canvas.restore();
canvas.save();
...
canvas.restore();

得知这个之后,我们就有思路了,我们可以将文字分为两层,例如底层为黑色,上层为红色,红色逐渐显示出来,覆盖黑色的字,那么就能实现我们需要的效果了。

先绘制两层:

    private Paint mPaint = new Paint();
    
    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);

        //底层 黑色
        drawCenterText1(canvas);
        //上面一层 红色
        drawCenterText2(canvas);

        //中心线
        drawCenterLineX(canvas);
        drawCenterLineY(canvas);

    }

    private void drawCenterText1(Canvas canvas){
        // 绘制黑色文字
        canvas.save();
        mPaint.setTextSize(mTextSize);
        mPaint.setColor(mTextColor);
        mPaint.setStyle(Paint.Style.FILL);
        mPaint.setAntiAlias(true);//抗锯齿
        //mPaint.setTextAlign(Paint.Align.CENTER);
        float width = mPaint.measureText(mText);//获取文字宽度

        Paint.FontMetrics fontMetrics = mPaint.getFontMetrics();
        float baseline = getHeight() / 2 - (fontMetrics.descent + fontMetrics.ascent)/2;
        canvas.drawText(mText, (getWidth() - width) / 2, baseline, mPaint);
        canvas.restore();
    }

    private void drawCenterText2(Canvas canvas){
        // 绘制红色文字
        canvas.save();
        mPaint.setTextSize(mTextSize);
        mPaint.setColor(Color.RED);
        mPaint.setStyle(Paint.Style.FILL);
        mPaint.setAntiAlias(true);
        float width = mPaint.measureText(mText);//获取文字宽度
        Paint.FontMetrics fontMetrics = mPaint.getFontMetrics();
        float baseline = getHeight() / 2 - (fontMetrics.descent + fontMetrics.ascent)/2;
        canvas.drawText(mText, (getWidth() - width) / 2, baseline, mPaint);
        canvas.restore();
    }

在我们自定义view里面,除了Paint、canvas之外还有一个类Rect类,它是用来设置画布大小的,配合canvas.clipRect(rect);一起使用,我们可以把它看成裁剪。

该方法用于裁剪画布,也就是设置画布的显示区域
调用clipRect()方法后,只会显示被裁剪的区域,之外的区域将不会显示

由于是canvas绘制了两层,那么如果将第二层进行逐渐的裁剪,让它由0%到100%逐渐的进行显示,就能达到预期的效果了。所以在第二层红色的字体上加上Rect来进行处理

    private void drawCenterText2(Canvas canvas) {
        // 绘制红色文字
        canvas.save();
        mPaint.setTextSize(mTextSize);
        mPaint.setColor(Color.RED);
        mPaint.setStyle(Paint.Style.FILL);
        mPaint.setAntiAlias(true);

        // 文字X轴起始位置
        float width = mPaint.measureText(mText);//获取文字宽度
        float X = (getWidth() - width) / 2;

        // 文字baseline位置
        Paint.FontMetrics fontMetrics = mPaint.getFontMetrics();
        float baseline = getHeight() / 2 - (fontMetrics.descent + fontMetrics.ascent) / 2;

        //裁剪的范围(矩形的四条边)
        int left = (int)X;
        int top = 0;
        int right = (int)(left + width);
        int bottom = getHeight();
        Rect rect = new Rect(left, top, right, bottom);
        canvas.clipRect(rect);//掏空
        canvas.drawText(mText, X, baseline, mPaint);
        canvas.restore();
    }

运行效果图(红色字体全部显示出来):

将代码中right的值改成

int right = (int)(left + width/2);

看下效果

此时,证明我们的思路是对的,那么我们只要处理好百分比逐渐增加,就能达到预期效果了。

自定义view的类中添加变量

    private float percent = 0.0f;

    public float getPercent() {
        return percent;
    }

    public void setPercent(float percent) {
        this.percent = percent;
        invalidate();//重绘
    }

每当percent的值改变,也就是每次调用setPercent方法之后,重绘一次。那么right的代码就要改成

int right = (int)(left + width*percent);

修改percent的值我使用的是,在activity里面调用属性动画用来测试

    my_tv = (MyTextView) findViewById(R.id.my_tv);
        //属性动画
        Handler handler = new Handler();
        handler.postDelayed(new Runnable() {
            @Override
            public void run() {
                onStartLeft(null);
            }
        }, 2000);
    public void onStartLeft(View view) {
        ObjectAnimator.ofFloat(my_tv, "percent", 0, 1).setDuration(5000).start();
    }

在onStartLeft方法里面有一个“percent”参数,这个实际上只要是在自定义view里面有一个这个参数的set方法,那么他就会通过放射去调用setPercent方法。

效果实现完毕。接下来进行查漏补缺。

1、通过我们的方式进行渐变,那么会重复的调用onDraw方法,因此Paint需要放在最外层new出来,否则会产生很多的GC动作。

2、绘制的层级越少越好,不要过度绘制

  • 真彩色:没有过度绘制

  • 蓝色:过度绘制 1 次
  • 绿色:过度绘制 2 次
  • 粉色:过度绘制 3 次
  • 红色:过度绘制 4 次或更多次

如果显示红色,那么布局就要进行修改了,而我们上面的代码结束时候,层级是2,显示的是绿色。那么是否有办法把他降低为一层呢?

思路:当我们第二层红色字体在绘制的时候,第一层的黑色字体就不绘制,即“一进一出”,这样在绘制的时候就始终都为一层了。

那么第二层红色字体是从左到右依次绘制(即左坐标不变,右坐标百分比增加),所以第一层黑色字体就是从左到右依次消失(左坐标依次增加,右坐标不变)

修改后drawCenterText1方法代码

    private void drawCenterText1(Canvas canvas) {
        // 绘制黑色文字
        canvas.save();
        mPaint.setTextSize(mTextSize);
        mPaint.setColor(mTextColor);
        mPaint.setStyle(Paint.Style.FILL);
        mPaint.setAntiAlias(true);//抗锯齿

        // 文字X轴起始位置
        float width = mPaint.measureText(mText);//获取文字宽度
        float X = (getWidth() - width) / 2;

        // 文字baseline位置
        Paint.FontMetrics fontMetrics = mPaint.getFontMetrics();
        float baseline = getHeight() / 2 - (fontMetrics.descent + fontMetrics.ascent) / 2;

        //裁剪的范围(矩形的四条边)
        int left = (int) (X + width * percent);
        int top = 0;
        int right = (int) (X + width);
        int bottom = getHeight();
        Rect rect = new Rect(left, top, right, bottom);
        canvas.clipRect(rect);//掏空

        canvas.drawText(mText, X, baseline, mPaint);
        canvas.restore();
    }

MyTextView整体代码:

public class MyTextView extends AppCompatTextView {
    private float percent = 0.0f;

    public float getPercent() {
        return percent;
    }

    public void setPercent(float percent) {
        this.percent = percent;
        invalidate();//重绘
    }

    /**
     * 需要绘制的文字
     */
    private String mText;
    /**
     * 文本的颜色
     */
    private int mTextColor;
    /**
     * 文本的大小
     */
    private int mTextSize;

    public MyTextView(Context context) {
        super(context);
    }

    public MyTextView(Context context, AttributeSet attrs) {
        super(context, attrs);
        //获取自定义属性的值
        TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.MyTextView);
        mText = a.getString(R.styleable.MyTextView_mText);
        mTextColor = a.getColor(R.styleable.MyTextView_mTextColor, Color.BLACK);
        mTextSize = (int) a.getDimension(R.styleable.MyTextView_mTextSize, 100);
        a.recycle();  //回收
    }

    public MyTextView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
    }

    private Paint mPaint = new Paint();

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);

        //底层 黑色
        drawCenterText1(canvas);
        //上面一层 红色
        drawCenterText2(canvas);

        //中心线
        drawCenterLineX(canvas);
        drawCenterLineY(canvas);

    }

    private void drawCenterText1(Canvas canvas) {
        // 绘制黑色文字
        canvas.save();
        mPaint.setTextSize(mTextSize);
        mPaint.setColor(mTextColor);
        mPaint.setStyle(Paint.Style.FILL);
        mPaint.setAntiAlias(true);//抗锯齿

        // 文字X轴起始位置
        float width = mPaint.measureText(mText);//获取文字宽度
        float X = (getWidth() - width) / 2;

        // 文字baseline位置
        Paint.FontMetrics fontMetrics = mPaint.getFontMetrics();
        float baseline = getHeight() / 2 - (fontMetrics.descent + fontMetrics.ascent) / 2;

        //裁剪的范围(矩形的四条边)
        int left = (int) (X + width * percent);
        int top = 0;
        int right = (int) (X + width);
        int bottom = getHeight();
        Rect rect = new Rect(left, top, right, bottom);
        canvas.clipRect(rect);//掏空

        canvas.drawText(mText, X, baseline, mPaint);
        canvas.restore();
    }

    private void drawCenterText2(Canvas canvas) {
        // 绘制红色文字
        canvas.save();
        mPaint.setTextSize(mTextSize);
        mPaint.setColor(Color.RED);
        mPaint.setStyle(Paint.Style.FILL);
        mPaint.setAntiAlias(true);

        // 文字X轴起始位置
        float width = mPaint.measureText(mText);//获取文字宽度
        float X = (getWidth() - width) / 2;

        // 文字baseline位置
        Paint.FontMetrics fontMetrics = mPaint.getFontMetrics();
        float baseline = getHeight() / 2 - (fontMetrics.descent + fontMetrics.ascent) / 2;

        //裁剪的范围(矩形的四条边)
        int left = (int) X;
        int top = 0;
        int right = (int) (left + width * percent);
        int bottom = getHeight();
        Rect rect = new Rect(left, top, right, bottom);
        canvas.clipRect(rect);//掏空
        canvas.drawText(mText, X, baseline, mPaint);
        canvas.restore();
    }

    private void drawCenterLineX(final Canvas canvas) {
        Paint paint = new Paint();
        paint.setStyle(Paint.Style.FILL);//实线
        paint.setColor(Color.RED);// 颜色
        paint.setStrokeWidth(3);// 线的宽度
        canvas.drawLine(getWidth() / 2, 0, getWidth() / 2, getHeight(), paint);
    }

    private void drawCenterLineY(final Canvas canvas) {
        Paint paint = new Paint();
        paint.setStyle(Paint.Style.FILL);
        paint.setColor(Color.BLUE);
        paint.setStrokeWidth(3);
        canvas.drawLine(0, getHeight() / 2, getWidth(), getHeight() / 2, paint);
    }
}

 

完。

posted @ 2020-12-30 12:51  金大人的梦  阅读(1148)  评论(0编辑  收藏  举报