自定义View从实现到原理(七)- 类似迅雷实现水波纹波浪加载效果

自定义View从实现到原理(七)

已经到这一步了啊,这一篇写完基本上自定义View就不会写了,以后有可能的话,也许会写一下自定义ViewGroup或者是自定义View的仿真书籍翻页效果,不过那也是以后的事情了,今天就来实现以下水波纹加载效果,先看一下效果图:
在这里插入图片描述
类似这种的效果,其实也就是一个自定义的View,接下来我们来一步步实现一下:

定义属性

首先还是一样,根据效果图,先定义这个View的属性,这个效果我觉得需要圆形的背景颜色,圆形的半径,显示的进度,显示文字的大小,显示文字的颜色,定义属性的代码:

<?xml version="1.0" encoding="utf-8"?>
<resources>

    <declare-styleable name="WaterView">
    
        <!-- 背景颜色,圆的半径,进度,进度字号,进度颜色 -->
        <attr name="backgroundColor" format="color" />
        <attr name="radius" format="dimension" />
        <attr name="text" format="string" />
        <attr name="textSize" format="dimension" />
        <attr name="textColor" format="color" />

    </declare-styleable>

</resources>

按照正常步骤,下一步在自定义的View中的构造函数获取属性:

 	private int backgroundColor, textColor;
    private float radius, textSize;
    private String text;
    private Paint backgroundPaint, textPaint;

    public WaterView(Context context) {
     
        this(context, null);
    }

    public WaterView(Context context, @Nullable AttributeSet attrs) {
     
        super(context, attrs);
        TypedArray waterTypeArray = context.obtainStyledAttributes(attrs, R.styleable.WaterView);
        backgroundColor = waterTypeArray.getColor(R.styleable.WaterView_backgroundColor, 
                Color.BLACK);
        textColor = waterTypeArray.getColor(R.styleable.WaterView_textColor, Color.WHITE);
        radius = waterTypeArray.getDimension(R.styleable.WaterView_radius, 260f);
        textSize = waterTypeArray.getDimension(R.styleable.WaterView_textSize, 24f);
        text = waterTypeArray.getString(R.styleable.WaterView_text);
        //记得回收
        waterTypeArray.recycle();
    }

绘制进度文字

首先我们要初始化画笔,在自定义View中我们通过画笔可以绘制我们想要的所有效果:

/**
     * 初始化画笔
     */
    private void initPaint() {
     
        //初始化背景画笔
        backgroundPaint = new Paint();
        backgroundPaint.setColor(backgroundColor);
        //抗锯齿
        backgroundPaint.setAntiAlias(true);

        //初始化显示文字画笔
        textPaint = new Paint();
        textPaint.setTextSize(textSize);
        textPaint.setColor(textColor);
        textPaint.setAntiAlias(true);
        //字体为粗体
        textPaint.setFakeBoldText(true);
    }

定义完画笔之后,我们首先绘制出底层的圆形,有圆形才能在圆形中心绘制文字:

	canvas.drawCircle(getWidth()/2, getHeight()/2, radius, backgroundPaint);

别忘了在构造函数中调用initPaint()方法,要不然会有空指针异常的错误抛出的。

写到这里我们想看一下能不能画出圆形,所以在xml中简单引用一下:

<?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=".Activity.WaterActivity">

    <com.example.day1.View.WaterView
        android:layout_width="260dp"
        android:layout_height="260dp"
        android:layout_centerInParent="true"
        />
    
</RelativeLayout>

效果图如下:
自定义View从实现到原理(七)- 类似迅雷实现水波纹波浪加载效果_第1张图片
可真是朴实无华的一个圆形,既然能画出来,那我们之前的工作就没错,在中心显示一下文字:

canvas.drawText(text, getWidth() / 2, getHeight() / 2, textPaint);

我第一次是这么写的,感觉很正常,在Width的中间与Height的中间绘制出显示的文字,结果运行出来出了问题:
自定义View从实现到原理(七)- 类似迅雷实现水波纹波浪加载效果_第2张图片
震惊,什么鬼?我仔细看了一下这个文字的位置,发现它的左下角,应该就是我们设置的中心位置,就离谱,遇到了问题就要解决,我就去搜了一下自定义View怎么将文字显示在中心,这里参考了这篇文章:

Android自定义View之文字居中

首先在initPaint()方法中设置居中,不过注意这只能做到水平居中:

textPaint.setTextAlign(Paint.Align.CENTER);

这一行代码就可以了,那么接下来我们处理竖直方向居中,按理来说上面这一行代码就足以解决了,但是为什么会有一点偏上呢,这就是因为在文字显示的时候,有一个基线,这个基线正是在我们设置的竖直居中位置,我们的文字在基线上部,因此就会比中心位置提高一点,对于这个问题有两种解决方法:

getTextBounds()方法

先来看一下这个方法的使用:

getTextBounds(String text, int start, int end, Rect bounds)

text是要显示的文本,start以及end是文本的开始显示位置与结束位置,bounds是存储文字显示位置的对象,最后的结果会写进bounds中。我们使用就是通过这个方法获得文字的边框bounds,由于基线在文字的下方,因此我们想要竖直居中的话就得向下平移文字高度的一半,就是 (bounds.top+bound.bottom)/2 这个值,但是要注意这个是一个负数,代表的是文字的高度一半的位置而不是真正意义的高度一半,getTextBounds()方法会有一个自己的坐标系:
自定义View从实现到原理(七)- 类似迅雷实现水波纹波浪加载效果_第3张图片
可以看得出来在这个坐标系下,top以及bottom都是负值,因此我们向下平移需要减去之前算出的文字高度中心位置,那么具体的用法就是下面的代码:

		Rect bounds = new Rect();
        textPaint.getTextBounds(text, 0, text.length(), bounds);
        float offset = (bounds.bottom + bounds.top) / 2;

        canvas.drawText(text, getWidth() / 2, (getHeight() / 2) - offset, textPaint);

自定义View从实现到原理(七)- 类似迅雷实现水波纹波浪加载效果_第4张图片
可行,我们接下来看一下另外一种方法;

FontMetircs 方法

自定义View从实现到原理(七)- 类似迅雷实现水波纹波浪加载效果_第5张图片
和之前的方法类似,我们看一下这个图,有了之前的经验,我们可以看出需要的两个数据为ascent以及descent这两个,不过要注意的是,这几个值,是固定不会变的,也就是无论你绘制的内容怎么改变,这几个值都不会变,类比上面的代码,我们来看一下这个:

		Paint.FontMetrics fontMetrics= new Paint.FontMetrics();
        textPaint.getFontMetrics(fontMetrics);
        float offset = (fontMetrics.descent+fontMetrics.ascent)/2;
        canvas.drawText(text, getWidth() / 2, (getHeight() / 2) - offset , textPaint);

效果的话和上面是一样的,那么这两种方法,我们应该如何区别使用呢:

对于第一种,getTextBounds()方法,我们是获取到了文字的中间高度,那么随着内容的改变,我们的中间位置可能也会发生改变;

第二种,FontMetircs方法,这是固定的文字测量工具,不管内容如何改变,他的中间位置不会发生改变。

这样我们就可以总结出:

1.当我们绘制的内容会有动态改变操作时,使用FontMetircs()方法;

2.当绘制内容固定的时候,我们用哪个都可以,第一种看起来更加直观。

设置onMeasure

通常我们都会在xml布局中将控件设为wrapContent类型,按照我们之前写过的博客,我们也应该要重写onMeasure函数,这部分就不多说了,之前也介绍过:

@Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
     
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
        int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec);
        int widthSpecSize = MeasureSpec.getSize(widthMeasureSpec);
        int heightSpecSize = MeasureSpec.getSize(heightMeasureSpec);

        int width, height;

        if (widthSpecMode == MeasureSpec.AT_MOST && heightSpecMode == MeasureSpec.AT_MOST) {
     
            height = width = (int) radius * 2;
            setMeasuredDimension(width, height);
        } else {
     
            setMeasuredDimension(widthSpecSize, heightSpecSize);
        }
    }

简简单单的一串代码,朴实无华,效果也没什么变化,就这样,下一步。

显示文字模拟下载

下载自然是从0%到100%,我们开启线程进行模拟,在规定时间内跑完,并动态更新文字内容的显示:

	private SingleTapThread singleTapThread;
    private GestureDetector detector;

    private int currentProgress = 0;
    private int maxProgress = 100;
	
	text = currentProgress + "%";

做好准备活动,接下来会开启线程模拟下载:

	@SuppressLint("ClickableViewAccessibility")
    @Override
    public boolean onTouchEvent(MotionEvent event) {
     

        if (event.getAction() == MotionEvent.ACTION_UP) {
     
            startProgressAnimation();
        }
        return super.onTouchEvent(event);
    }


    private void startProgressAnimation() {
     
        if (singleTapThread == null) {
     
            singleTapThread = new SingleTapThread();
            getHandler().postDelayed(singleTapThread, 100);
        }
    }


    private class SingleTapThread implements Runnable {
     

        @Override
        public void run() {
     
            int maxProgress = 100;
            if (currentProgress < maxProgress) {
     
                invalidate();
                getHandler().postDelayed(singleTapThread, 100);
                currentProgress++;
            } else {
     
                getHandler().removeCallbacks(singleTapThread);
            }
        }
    }

我们从上到下看,首先设置了点击事件,只要有触碰抬起的事件发生,那么就启动startProgressAnimation()方法,在这个方法中我们首先检测了是否有线程在运行,如果没有的话开启线程,延迟100ms后启动SingleTapThread()线程,在这个线程中定义了最大值为100,如果当前值小于100就会刷新View并且100ms后再次重启,同时进度加一,如果已经完成则回收这个线程,效果图如下:
自定义View从实现到原理(七)- 类似迅雷实现水波纹波浪加载效果_第6张图片
文字效果已经实现,那么接下来才是主要的部分,实现水波纹波浪效果:

实现水波纹波浪效果

简单来说,水波纹波浪效果,就是二阶贝塞尔曲线的一个应用,来看一下二阶贝塞尔曲线效果:

就是这样,我们这个效果可以看成是曲线在固定范围内的不断变换,我们这里就简要了解一下二阶贝塞尔曲线的应用就好了,如果需要深入在后面我再研究研究。

在Android SDK中提供了关于绘制贝塞尔曲线的方法:

public void rQuadTo(float dx1, float dy1, float dx2, float dy2) 

这四个参数都是相对值,我们来看一下上面的哪个动图,曲线起点是P0,终点P2,控制点P1,而相对的我们这四个参数的值,为:

1.dx1:控制点X坐标,表示相对上一个终点X坐标的位移坐标,可为负值,正值表示相加,负值表示相减;
2.dy1:控制点Y坐标,相对上一个终点Y坐标的位移坐标。同样可为负值,正值表示相加,负值表示相减;
3.dx2:终点X坐标,同样是一个相对坐标,相对上一个终点X坐标的位移值,可为负值,正值表示相加,负值表示相减;
4.dy2:终点Y坐标,同样是一个相对,相对上一个终点Y坐标的位移值。可为负值,正值表示相加,负值表示相减;

这四个参数都是传递的都是相对值,相对上一个终点的位移值。

接下来正式绘制波浪特效:

首先初始化波浪的画笔:

		//初始化波浪画笔
        progressPaint = new Paint();
        progressPaint.setAntiAlias(true);
        progressPaint.setColor(waterColor);
        //两层在一起我在上面
        progressPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_IN));

最后这行设置如果两层绘制在了同一个界面,这个绘制的会在上部显示,并且只显示交集,这是绘制圆形波浪的关键;

既然这个画布是有遮盖效果的,那么就应该设置在同一个bitmap上,我们定义一个bitmap,为了在这个bitmap上进行绘制,我们还要定义一个bitmapCanvas,来载入bitmap:

	if (bitmap == null) {
     
            bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
            bitmapCanvas = new Canvas(bitmap);
        }

这是初始化bitmap以及bitmapCanvas部分,定义部分自己在开始写一下吧,既然已经有了bitmapCanvas,我们就来把之前的画圆以及绘制文字都载入一下:

		super.onDraw(canvas);

        width = getWidth();
        height = getHeight();

        if (bitmap == null) {
     
            bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
            bitmapCanvas = new Canvas(bitmap);
        }
        bitmapCanvas.save();
        //移动坐标系
        bitmapCanvas.translate(0, height / 4);
        //绘制圆
        bitmapCanvas.drawCircle(radius, radius, radius, backgroundPaint);

        //绘制PATH
        //重置绘制路线
        path.reset();
        float percent = currentProgress * 1.0f / maxProgress;
        float y = (1 - percent) * radius * 2;
        //起点移动到右下
        path.moveTo(width, y);
        //移动到右下方
        path.lineTo(width, height);
        //移动到最左下边
        path.lineTo(0, height);
        //移动到左上边
        // path.lineTo(0, y);
        //实现左右波动,根据progress来平移
        path.lineTo(-(1 - percent) * radius * 2, y);
        if (currentProgress != 0.0f) {
     
            //根据直径计算绘制贝赛尔曲线的次数
            float count = radius * 2 / 30;
            //控制-控制点y的坐标
            float point = (1 - percent) * 15;
            for (int i = 0; i < count; i++) {
     
                path.rQuadTo(15, -point, 30, 0);
                path.rQuadTo(15, point, 30, 0);
            }
        }
        //闭合
        path.close();
        bitmapCanvas.drawPath(path, progressPaint);

        //绘制文字
        String text = currentProgress + "%";
        Paint.FontMetrics fontMetrics = textPaint.getFontMetrics();
        float offset = (fontMetrics.ascent + fontMetrics.descent) / 2;
        bitmapCanvas.drawText(text, width / 2, radius - offset, textPaint);

        bitmapCanvas.restore();

        canvas.drawBitmap(bitmap, 0, 0, null);

这里我们将onDraw()的代码都写了上去,我们就着重来看一下绘制贝塞尔曲线这一部分,逐行分析:

1.首先我们reset了path,path是安卓中用来绘制图形,路径,曲线等多种图案的工具,有必要的时候我会写一篇博客学习一下;

2.定义了float类型变量,这个的值就是当前进度百分比,用 currentProgress * 1.0f / maxProgress 计算得到,这样计算可以保留精度;

3.在之前我们有一行代码需要关注一下:

bitmapCanvas.translate(0, height / 4);

这一行代码,我们移动了bitmapCanvas的坐标系,经过我的反复研究,我们绘制的画布的坐标系原点是在左上角的位置,也就是这里:
自定义View从实现到原理(七)- 类似迅雷实现水波纹波浪加载效果_第7张图片
原谅我画的图太辣鸡,但是想来应该都会懂得,红点的位置就是原点位置,x轴向右为正,y轴向下为正,在上面那串移动坐标系的代码中,我们可以看到横坐标没有移动,纵坐标移动 height / 4 ,y轴向下为正,所以整体绘制的View就会向下移动 height / 4 距离,呈现的效果就是这样:
自定义View从实现到原理(七)- 类似迅雷实现水波纹波浪加载效果_第8张图片
好,我们回归第三行代码,我们定义了float类型的y,也就是纵坐标,表达式为 (1 - percent) * radius * 2;,percent是当前的进度,那么y的值就是从radius*2一直变到0,由于y轴向下为正,那么也就是从View的最下端一直到最上部;

4.在这里我们开始对path进行操作,moveto的作用是设置起点,从代码可以看出,起点在(width,y)这个位置,根据上面说的,那也就是右下角位置;

5.lineto操作,代表移动连线,起点已经设置好,但是由于我们这个y是一直在变化的,因此为了保持绘制的完整,我们下一点还是要设置在右下角;

6.和5基本一样,第三个点绘制在左下角;

7.最后一个点,我们为了绘制出的水波浪效果是动态波动的,将最后一个点设置为根据当前进度进行调整,绘制出平滑的波浪我们的终点起点纵坐标要一致,这样才能在同一水平线,横坐标表达式:

-(1 - percent) * radius * 2

也很好理解,一开始位置是-2*radius,最后是0,这样保证了绘制的所有阶段,圆形区域内都是顺滑的,不会出现空白;

8.这个if我们整体说一下,如果当前不是刚开始的时候,就会进入if中,首先我们根据直径来判断要绘制多少次贝塞尔曲线,radius * 2 / 30 ,radius*2不用多说,是直径,至于这个三十,我们贴一张图:
自定义View从实现到原理(七)- 类似迅雷实现水波纹波浪加载效果_第9张图片
这部分借鉴了这篇文章:

android 自定义view-水波纹进度球

我们选择30为一个周期,这部分我们可以自定,也可以是60,这样计算出count这个值,作为计算的结果,在下一步我们计算控制点的y坐标,我们一开始拟定是15,根据当前进度,进度越大,波动越小;

9.根据8中计算的次数进行循环,根据8中计算的控制点y坐标进行波动,我们回头看一下刚说过的贝塞尔曲线的rQuadTo()方法,里面的参数都是相对起点的值,这里的起点,其实是我们刚说的path的终点,有点绕,自己理解一下;

10.在最后部份,我们使用path.close()方法,将path的起点和终点相连,构成闭合区域,最后绘制,

效果图如下:
自定义View从实现到原理(七)- 类似迅雷实现水波纹波浪加载效果_第10张图片
当然我这里更改了一下颜色,这样看着更加明显一点。

最后贴一个整体的自定义View代码,以供参考:

package com.example.day1.View;

import android.annotation.SuppressLint;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Path;
import android.graphics.PorterDuff;
import android.graphics.PorterDuffXfermode;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;

import androidx.annotation.Nullable;

import com.example.day1.R;

public class WaterView extends View {
     
    //定义背景颜色,字体颜色;
    private int backgroundColor, textColor, waterColor;
    private float radius, textSize;
    private String text;
    private Paint backgroundPaint, textPaint, progressPaint;

    private SingleTapThread singleTapThread;

    private int currentProgress = 0;
    private int maxProgress = 100;

    private Path path = new Path();
    private Bitmap bitmap;
    private Canvas bitmapCanvas;

    private int width, height;

    public WaterView(Context context) {
     
        this(context, null);
    }

    public WaterView(Context context, @Nullable AttributeSet attrs) {
     
        super(context, attrs);
        TypedArray waterTypeArray = context.obtainStyledAttributes(attrs, R.styleable.WaterView);
        backgroundColor = waterTypeArray.getColor(R.styleable.WaterView_backgroundColor, Color.WHITE);
        textColor = waterTypeArray.getColor(R.styleable.WaterView_textColor, Color.BLACK);
        radius = waterTypeArray.getDimension(R.styleable.WaterView_radius, 260f);
        textSize = waterTypeArray.getDimension(R.styleable.WaterView_textSize, 24f);
        text = waterTypeArray.getString(R.styleable.WaterView_text);
        waterColor = waterTypeArray.getColor(R.styleable.WaterView_waterColor, Color.GREEN);
        //记得回收
        waterTypeArray.recycle();
        initPaint();
    }

    /**
     * 初始化画笔
     */
    private void initPaint() {
     
        //初始化背景画笔
        backgroundPaint = new Paint();
        backgroundPaint.setColor(backgroundColor);
        //抗锯齿
        backgroundPaint.setAntiAlias(true);

        //初始化显示文字画笔
        textPaint = new Paint();
        textPaint.setTextSize(textSize);
        textPaint.setColor(textColor);
        textPaint.setAntiAlias(true);
        //字体为粗体
        textPaint.setFakeBoldText(true);
        textPaint.setTextAlign(Paint.Align.CENTER);

        //初始化波浪画笔
        progressPaint = new Paint();
        progressPaint.setAntiAlias(true);
        progressPaint.setColor(waterColor);
        //取两层绘制交集。显示上层
        progressPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_IN));

    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
     
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
        int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec);
        int widthSpecSize = MeasureSpec.getSize(widthMeasureSpec);
        int heightSpecSize = MeasureSpec.getSize(heightMeasureSpec);

        int width, height;

        if (widthSpecMode == MeasureSpec.AT_MOST && heightSpecMode == MeasureSpec.AT_MOST) {
     
            height = width = (int) radius * 2;
            setMeasuredDimension(width, height);
        } else {
     
            setMeasuredDimension(widthSpecSize, heightSpecSize);
        }
    }

    /**
     * 绘制部份
     *
     * @param canvas 画布
     */
    @SuppressLint("DrawAllocation")
    @Override
    protected void onDraw(Canvas canvas) {
     
        super.onDraw(canvas);

        width = getWidth();
        height = getHeight();

        if (bitmap == null) {
     
            bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
            bitmapCanvas = new Canvas(bitmap);
        }
        bitmapCanvas.save();
        //移动坐标系
        bitmapCanvas.translate(0, height / 4);
        //绘制圆
        bitmapCanvas.drawCircle(radius, radius, radius, backgroundPaint);

        //绘制PATH
        //重置绘制路线
        path.reset();
        float percent = currentProgress * 1.0f / maxProgress;
        float y = (1 - percent) * radius * 2;
        //起点移动到右下
        path.moveTo(width, y);
        //移动到右下方
        path.lineTo(width, height);
        //移动到最左下边
        path.lineTo(0, height);
        //移动到左上边
        // path.lineTo(0, y);
        //实现左右波动,根据progress来平移
        path.lineTo(-(1 - percent) * radius * 2, y);
        if (currentProgress != 0.0f) {
     
            //根据直径计算绘制贝赛尔曲线的次数
            float count = radius * 2 / 30;
            //控制-控制点y的坐标
            float point = (1 - percent) * 15;
            for (int i = 0; i < count; i++) {
     
                path.rQuadTo(15, -point, 30, 0);
                path.rQuadTo(15, point, 30, 0);
            }
        }
        //闭合
        path.close();
        bitmapCanvas.drawPath(path, progressPaint);

        //绘制文字
        String text = currentProgress + "%";
        Paint.FontMetrics fontMetrics = textPaint.getFontMetrics();
        float offset = (fontMetrics.ascent + fontMetrics.descent) / 2;
        bitmapCanvas.drawText(text, width / 2, radius - offset, textPaint);

        bitmapCanvas.restore();

        canvas.drawBitmap(bitmap, 0, 0, null);

    }

    @SuppressLint("ClickableViewAccessibility")
    @Override
    public boolean onTouchEvent(MotionEvent event) {
     

        if (event.getAction() == MotionEvent.ACTION_UP) {
     
            startProgressAnimation();
        }
        return super.onTouchEvent(event);
    }


    private void startProgressAnimation() {
     
        if (singleTapThread == null) {
     
            singleTapThread = new SingleTapThread();
            getHandler().postDelayed(singleTapThread, 100);
        }
    }


    private class SingleTapThread implements Runnable {
     

        @Override
        public void run() {
     
            if (currentProgress < maxProgress) {
     
                invalidate();
                getHandler().postDelayed(singleTapThread, 100);
                currentProgress++;
            } else {
     
                getHandler().removeCallbacks(singleTapThread);
            }
        }
    }
}


就这样,撤退。

你可能感兴趣的:(View,android)