Android自定义View(10)- 写一个雷达扫描界面
概述
蛮久没写关于自定义View的东西了,现在来一个。写一个类似雷达扫面界面的View,可用于蓝牙设备搜索界面的显示。还是先看图:
Screenrecorder-2021-08-04-18-15-57-5262021842252183.gif
我们按照上面的效果,拆解分步实现:
- 从里到外画6个圆
- 实现中间扫描的动态效果
- 将外部添加进来的设备,以小圆的形式显示
- 完善对外接口,可添加和删除界面的设备、停止扫描、开始扫描等。
1、从里到外画6个圆
第一部分先从里到外画6个圆,且相邻圆的半径差相等。这个简单,不用多解释:
// spaceBetweenCircle = (int) ((dipToPx(size / 2) - 10) / 6);
private void drawCircle(Canvas canvas) {
for (int i = 0; i <= 6; i++) {
canvas.drawCircle(getWidth() >> 1, getHeight() >> 1, spaceBetweenCircle * i, circlePaint);
}
}
上面for循环画出了6个圆,圆心取宽高的一半处,也就是View的中心位置。上面的 spaceBetweenCircle是两圆之间的半径差,是用屏幕宽度计算出来的,乘以 i表示从里到外半径递增。
2、实现中间扫面的动态效果
要实现图中的扫描效果要借助两个类,Shader和Matrix。Shader就是阴影,图中颜色渐变的效果就是给画圆的笔设置阴影来实现的。Shader有几个子类:BitmapShader、SweepGradient、LinearGradient 、RadialGradient。这几个大概分别代表底部图层Bitmap渲染、梯度渲染、线性渲染、环形渲染。而我们这次要用到的渲染方式就是梯度渲染,使用到子类SweepGradient实现颜色渐变。
我们用到SweepGradient也只是实现颜色渐变,而还不能实现动态的扫面效果。所以我们还要让它转起来,这就用到了矩阵Matrix。下面看代码:
private void initShader() {
// 注释 1,创建阴影 SweepGradient
mShader = new SweepGradient(getWidth() >> 1, getHeight() >> 1,
new int[]{Color.TRANSPARENT, Color.parseColor("#FF60A8A1")}, null);
// 注释 2,给画笔设置阴影
scanPaint.setShader(mShader);
}
private void drawScan(Canvas canvas) {
// 注释 3,绘制扫描区域的圆
canvas.drawCircle(getWidth() >> 1, getHeight() >> 1,
spaceBetweenCircle * 5, scanPaint);
}
private void postScan() {
// 注释 4,每次绘制完之后改变角度,循环绘制
if (rotationInt >= 360) {
rotationInt = 0;
}
rotationInt += 2;
// 注释 5,设置新的矩阵角度
matrix.setRotate(rotationInt, getWidth() >> 1, getHeight() >> 1);// 会先清除之前的状态
// matrix.postRotate(2f, getWidth() >> 1, getHeight() >> 1); // 状态累加
给阴影设置矩阵
mShader.setLocalMatrix(matrix);
if (!stopScan) invalidate();
}
上面注释1、注释 2处分别创建了一个扫描风格的颜色阴影SweepGradient及给画扫描区域的画笔设置阴影。上面的SweepGradient设置了两种颜色值,其中一种是透明色Color.TRANSPARENT,这样过度就实现了渐变。
上面注释 4、注释 5的地方开始处理矩阵角度。这个 View每次调用绘制完之后,我们就改变矩阵角度,然后给渐变阴影mShader设置矩阵,再重新绘制。如此循环就可以实现扫描的效果。Matrix还可以实现平移、缩放等效果,这里不多解释。
3、将扫描结果显示在扫描区域
这里的“扫描结果”当然不是这个View扫描出来的,而是外界扫描到设备之后传进来的。那么这一步我们将图中的实现小圆画在扫描界面上。
我们看效果可以发现,新显示的设备是跟着扫面线走的。也就是新画的实心小圆要在扫面线刚扫过的地方出现。而且最新的点先显示大的半径,然后再变小。并且颜色随机。还有一个要实现的点就是,实心小圆和圆心的距离代表当前扫描到的设备信号强弱,或者说代表设备距离远近。
下面我们写一个类来封装扫描到的设备信息,这些信息包括信号等级、设备该现实的位置坐标及设备名称等。
/**
* 设备信息
*
* EthanLee
*/
public class PointPosition {
// 信号等级
private int rank = 0;
// 所能显示的区域半径,这里是默认值
private int radio = 60;
// 扫描区域中心
private PointF centerPoint = new PointF(0f, 0f);
// 实心小圆圆心
private PointF mPoint;
private Random random = new Random();
// 设备名称
private String userName = "";
private int[] colors = {Color.parseColor("#FFE10505"),
Color.parseColor("#FFFF9800"),
Color.parseColor("#FF9C27B0"),
Color.parseColor("#FF02188F"),
Color.parseColor("#FF0431D8")};
public int pointColor;
public int getRank() {
return rank;
}
public int getRadio() {
return radio;
}
public PointF getCenterPoint() {
return centerPoint;
}
public PointPosition setRank(int mRank) {
if (mRank > 70) {
mRank = 70;
}
if (mRank < 20) {
mRank = 20;
}
this.rank = mRank + 20;
return this;
}
public PointPosition setRadio(int radio) {
if (radio < 0) return this;
this.radio = radio;
return this;
}
public PointPosition setCenterPoint(PointF centerPoint) {
if (centerPoint == null) return this;
this.centerPoint = centerPoint;
return this;
}
// 注释 6 ,currentDegree是当前扫描线所处的角度
public PointPosition setPoint(int currentDegree) {
if (mPoint == null) mPoint = new PointF(0f, 0f);
// rank是信号等级,这里设置的范围是 20 - 90
// radio 是可现实的区域半径,也就是扫面区域大圆半径
// distance 是根据等级 rank和 区域半径算出来的实心小圆到大圆中心处的距离
float distance = radio * rank / 100;
// 三角函数分别算出View中心点距离目标点的横、纵坐标距离
float xDistance = (float) (distance * Math.cos(currentDegree * 2 * Math.PI / 360));
float yDistance = (float) (distance * Math.sin(currentDegree * 2 * Math.PI / 360));
// 算出点的横纵坐标
mPoint.x = centerPoint.x + xDistance;
mPoint.y = centerPoint.y + yDistance;
// 算一个随机颜色
pointColor = colors[random.nextInt(4)];
return this;
}
public PointF getPoint() {
return this.mPoint;
}
}
上面注释 6处的方法会获得扫描线当前所处的角度,还会获得当前设备的信号等级以及扫描区域大半径等信息,然后就可以根据这些信息,在上面方法里通过三角函数求得新加入的设备应该显示的坐标点,然后绘制出来。关于使用三角函数求坐标这次就不画图分析了,可以参考我之前的文章:画一个加载控件
上面确定好设备显示的坐标点之后,就可以添加进来绘制了:
// 注释 7 往列表里添加设备
public void addPoint(PointPosition point) {
if (stopScan) return;
if (this.pointList.contains(point)) return;
point.setRadio(spaceBetweenCircle * 6)
.setCenterPoint(new PointF(getWidth() >> 1, getHeight() >> 1))
.setPoint(rotationInt);
this.pointList.add(point);
}
// 注释 8 绘制所有设备点
private void drawPoint(Canvas canvas) {
for (PointPosition pointPosition : pointList) {
pointPaint.setColor(pointPosition.pointColor);
if (pointList.indexOf(pointPosition) == pointList.size() - 1) {
canvas.drawCircle(pointPosition.getPoint().x, pointPosition.getPoint().y, spaceBetweenCircle >> 1, pointPaint);
}
canvas.drawCircle(pointPosition.getPoint().x, pointPosition.getPoint().y, spaceBetweenCircle >> 2, pointPaint);
}
}
4、完善对外接口
下面最后一步我们来完善一下对外接口。文章开头说了,外界可以添加设备、清除设备,控制开始扫描、停止扫描等。这个也简单:
添加设备在上面注释 8处,这里保存设备的列表是一个 CopyOnWriteArrayList,(写时复制)。
清除设备:
// 清除特定设备
public void removePoint(PointPosition point) {
if (this.pointList.contains(point)) {
pointList.remove(point);
}
invalidate();
}
// 清除所有设备
public void clearPoint() {
if (pointList.size() == 0) return;
pointList.clear();
invalidate();
}
开始扫描:
public void setStartScan(){
if (!this.stopScan) return;
this.stopScan = false;
invalidate();
}
停止扫描:
public void setScanStop(){
if (this.stopScan) return;
this.stopScan = true;
}
最后,ScanView的代码:
/**
* 蓝牙扫描
*
* EthanLee
*/
public class ScanView extends View {
private Paint circlePaint;
// 两圆间的半径差
private int spaceBetweenCircle;
private Paint scanPaint;
private Shader mShader;
private Matrix matrix;
// 实心小圆
private Paint pointPaint;
// 扫描到的设备
private CopyOnWriteArrayList<PointPosition> pointList = new CopyOnWriteArrayList();
// 阴影旋转角度
private int rotationInt = 0;
// 停止扫描
private boolean stopScan = false;
public ScanView(Context context) {
this(context, null);
}
public ScanView(Context context, @Nullable AttributeSet attrs) {
this(context, attrs, 0);
}
public ScanView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
initRes(context, attrs, 0);
}
private void initRes(Context context, AttributeSet attrs, int defStyleAttr) {
circlePaint = new Paint();
circlePaint.setStyle(Paint.Style.STROKE);
circlePaint.setAntiAlias(true);
circlePaint.setDither(true);
circlePaint.setStrokeWidth(2);
circlePaint.setColor(Color.parseColor("#FF605F5F"));
scanPaint = new Paint();
scanPaint.setAntiAlias(true);
scanPaint.setDither(true);
pointPaint = new Paint();
pointPaint.setStyle(Paint.Style.FILL);
pointPaint.setAntiAlias(true);
pointPaint.setDither(true);
pointPaint.setColor(Color.parseColor("#FF3700B3"));
matrix = new Matrix();
}
private void initShader() {
mShader = new SweepGradient(getWidth() >> 1, getHeight() >> 1,
new int[]{Color.TRANSPARENT, Color.parseColor("#FF60A8A1")}, null);
scanPaint.setShader(mShader);
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int size = Math.min(getMeasureSize(widthMeasureSpec), getMeasureSize(heightMeasureSpec));
setMeasuredDimension(size, size);
spaceBetweenCircle = (int) ((dipToPx(size / 2) - 10) / 6);
}
private int getMeasureSize(int measureSpec) {
int measureMode = MeasureSpec.getMode(measureSpec);
int measureSize = MeasureSpec.getSize(measureSpec);
if (measureMode == MeasureSpec.EXACTLY) return measureSize;
if (measureMode == MeasureSpec.AT_MOST) return Math.min(600, measureSize);
return 600;
}
@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
super.onLayout(changed, left, top, right, bottom);
Log.d("tag", "getMeasuredWidth = " + getMeasuredWidth());
Log.d("tag", "getMeasuredHeight = " + getMeasuredHeight());
Log.d("tag", "getWidth = " + getWidth());
Log.d("tag", "getHeight = " + getHeight());
initShader();
// setBackground(getResources().getDrawable(R.mipmap.start));
}
@Override
protected void onDraw(Canvas canvas) {
drawCircle(canvas);
}
private void drawCircle(Canvas canvas) {
for (int i = 0; i <= 6; i++) {
canvas.drawCircle(getWidth() >> 1, getHeight() >> 1, spaceBetweenCircle * i, circlePaint);
}
drawScan(canvas);
}
private void drawScan(Canvas canvas) {
canvas.drawCircle(getWidth() >> 1, getHeight() >> 1, spaceBetweenCircle * 5, scanPaint);
drawPoint(canvas);
postScan();
}
private void drawPoint(Canvas canvas) {
for (PointPosition pointPosition : pointList) {
pointPaint.setColor(pointPosition.pointColor);
if (pointList.indexOf(pointPosition) == pointList.size() - 1) {
canvas.drawCircle(pointPosition.getPoint().x, pointPosition.getPoint().y, spaceBetweenCircle >> 1, pointPaint);
}
canvas.drawCircle(pointPosition.getPoint().x, pointPosition.getPoint().y, spaceBetweenCircle >> 2, pointPaint);
}
}
private void postScan() {
if (rotationInt >= 360) {
rotationInt = 0;
}
rotationInt += 2;
matrix.setRotate(rotationInt, getWidth() >> 1, getHeight() >> 1);// 会先清除之前的状态
// matrix.postRotate(2f, getWidth() >> 1, getHeight() >> 1); // 状态累加
mShader.setLocalMatrix(matrix);
if (!stopScan) invalidate();
}
public void setScanStop(){
if (this.stopScan) return;
this.stopScan = true;
}
public void setStartScan(){
if (!this.stopScan) return;
this.stopScan = false;
invalidate();
}
public void addPoint(PointPosition point) {
if (stopScan) return;
if (this.pointList.contains(point)) return;
point.setRadio(spaceBetweenCircle * 6)
.setCenterPoint(new PointF(getWidth() >> 1, getHeight() >> 1))
.setPoint(rotationInt);
this.pointList.add(point);
}
public void removePoint(PointPosition point) {
if (this.pointList.contains(point)) {
pointList.remove(point);
}
invalidate();
}
public void clearPoint() {
if (pointList.size() == 0) return;
pointList.clear();
invalidate();
}
private float dipToPx(int dip) {
return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_PX, dip, getResources().getDisplayMetrics());
}
}