已经到这一步了啊,这一篇写完基本上自定义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>
效果图如下:
可真是朴实无华的一个圆形,既然能画出来,那我们之前的工作就没错,在中心显示一下文字:
canvas.drawText(text, getWidth() / 2, getHeight() / 2, textPaint);
我第一次是这么写的,感觉很正常,在Width的中间与Height的中间绘制出显示的文字,结果运行出来出了问题:
震惊,什么鬼?我仔细看了一下这个文字的位置,发现它的左下角,应该就是我们设置的中心位置,就离谱,遇到了问题就要解决,我就去搜了一下自定义View怎么将文字显示在中心,这里参考了这篇文章:
Android自定义View之文字居中
首先在initPaint()方法中设置居中,不过注意这只能做到水平居中:
textPaint.setTextAlign(Paint.Align.CENTER);
这一行代码就可以了,那么接下来我们处理竖直方向居中,按理来说上面这一行代码就足以解决了,但是为什么会有一点偏上呢,这就是因为在文字显示的时候,有一个基线,这个基线正是在我们设置的竖直居中位置,我们的文字在基线上部,因此就会比中心位置提高一点,对于这个问题有两种解决方法:
先来看一下这个方法的使用:
getTextBounds(String text, int start, int end, Rect bounds)
text是要显示的文本,start以及end是文本的开始显示位置与结束位置,bounds是存储文字显示位置的对象,最后的结果会写进bounds中。我们使用就是通过这个方法获得文字的边框bounds,由于基线在文字的下方,因此我们想要竖直居中的话就得向下平移文字高度的一半,就是 (bounds.top+bound.bottom)/2 这个值,但是要注意这个是一个负数,代表的是文字的高度一半的位置而不是真正意义的高度一半,getTextBounds()方法会有一个自己的坐标系:
可以看得出来在这个坐标系下,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);
和之前的方法类似,我们看一下这个图,有了之前的经验,我们可以看出需要的两个数据为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.当绘制内容固定的时候,我们用哪个都可以,第一种看起来更加直观。
通常我们都会在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后再次重启,同时进度加一,如果已经完成则回收这个线程,效果图如下:
文字效果已经实现,那么接下来才是主要的部分,实现水波纹波浪效果:
简单来说,水波纹波浪效果,就是二阶贝塞尔曲线的一个应用,来看一下二阶贝塞尔曲线效果:
就是这样,我们这个效果可以看成是曲线在固定范围内的不断变换,我们这里就简要了解一下二阶贝塞尔曲线的应用就好了,如果需要深入在后面我再研究研究。
在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的坐标系,经过我的反复研究,我们绘制的画布的坐标系原点是在左上角的位置,也就是这里:
原谅我画的图太辣鸡,但是想来应该都会懂得,红点的位置就是原点位置,x轴向右为正,y轴向下为正,在上面那串移动坐标系的代码中,我们可以看到横坐标没有移动,纵坐标移动 height / 4 ,y轴向下为正,所以整体绘制的View就会向下移动 height / 4 距离,呈现的效果就是这样:
好,我们回归第三行代码,我们定义了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不用多说,是直径,至于这个三十,我们贴一张图:
这部分借鉴了这篇文章:
android 自定义view-水波纹进度球
我们选择30为一个周期,这部分我们可以自定,也可以是60,这样计算出count这个值,作为计算的结果,在下一步我们计算控制点的y坐标,我们一开始拟定是15,根据当前进度,进度越大,波动越小;
9.根据8中计算的次数进行循环,根据8中计算的控制点y坐标进行波动,我们回头看一下刚说过的贝塞尔曲线的rQuadTo()方法,里面的参数都是相对起点的值,这里的起点,其实是我们刚说的path的终点,有点绕,自己理解一下;
10.在最后部份,我们使用path.close()方法,将path的起点和终点相连,构成闭合区域,最后绘制,
效果图如下:
当然我这里更改了一下颜色,这样看着更加明显一点。
最后贴一个整体的自定义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);
}
}
}
}
就这样,撤退。