引言
在无线电监测方面,需要对信号进行展示,其中一项数据就是设备返回的电平数据,需要对其实时展示,一图胜千言,最好且最直观的方式就是图表展示,这样对其信号强弱的变化,就可以一目了然。
本文主要讲安卓版的电平流图形控件的实现,首先效果图如下:
实现了Y轴的拖动、放大、缩小,以及自动调整比例显示。
实现
布局
自定义控件,首先是要设计布局,要绘制哪些元素,元素的位置,弄清楚之后,就可以开始动手画了,我们最终的电平图如下:
那么我们先设计布局,如下如所示:
[站外图片上传中...(image-d0505-1566435331895)]
首先我们在左边要绘制单位,然后是刻度区,然后在是电平折线区。另外考虑到留边,所以在上下左右都加入了边距,便于动态调整。
编码
图纸(布局)确定之后,那么接下来就是搬砖(编码)了,首先新建一个类LevelStreamView继承View,然后实现必要的构造函数,如下:
public LevelStreamView(Context context, AttributeSet attrs, int defStypeAttr) {
super(context, attrs, defStypeAttr);
initView(context, attrs);
}
public LevelStreamView(Context context, AttributeSet attrs) {
super(context, attrs);
initView(context, attrs);
}
public LevelStreamView(Context context) {
super(context);
initView();
}
然后在attrs.xml中自定义控件的一些属性,方便调用者动态更改某些属性,代码如下:
<declare-styleable name="LevelStreamView">
<!--上边距-->
<attr name="margin_top_level" format="integer" />
<!--下边距-->
<attr name="margin_bottom_level" format="integer" />
<!--左边距-->
<attr name="margin_left_level" format="integer" />
<!--右边距-->
<attr name="margin_right_level" format="integer" />
<!--刻度线长度-->
<attr name="_scale_line_length_level" format="integer" />
<!--单位-->
<attr name="unit_str_level" format="string" />
<!--单位字体大小-->
<attr name="unit_size_level" format="integer" />
<!--单位字体颜色-->
<attr name="unit_color_level" format="color" />
<!--网格数-->
<attr name="grid_count_level" format="integer" />
<!--网格颜色-->
<attr name="grid_color_level" format="color" />
<!--电平线颜色-->
<attr name="level_color_level" format="color" />
<!--显示的电平点数-->
<attr name="level_count_level" format="integer" />
<!--刻度显示的最大值-->
<attr name="max_value_level" format="integer" />
<!--刻度显示的最小值-->
<attr name="min_value_level" format="integer" />
<!--刻度字体大小-->
<attr name="scale_size_level" format="integer" />
</declare-styleable>
然后在构造函数中调用initView()初始化函数,读取刚才在attr.xml中设置的控件属性。
接下来就是具体的图形绘制了,在绘制之前,先重载onMeasure()和onDraw()方法,在onMeasure()方法中确定控件的宽和高:
_width = getMeasuredWidth();
_height = getMeasuredHeight();
然后在onDraw()里面就是具体的画图了,首先是画单位drawUnit(),代码如下:
private void drawUnit(Canvas canvas) {
if (_unitStr == null || _unitStr.length() == 0)
return;
canvas.rotate(-90);
canvas.translate(-_height, 0);
_paint.setColor(_unitColor);
_paint.setTextSize(_unitSize);
Rect unitRect = new Rect();
_paint.getTextBounds(_unitStr, 0, _unitStr.length(), unitRect);
canvas.drawText(_unitStr, _height / 2 - (unitRect.width() / 2), unitRect.height(), _paint);
canvas.save();
canvas.translate(_height, 0);
canvas.rotate(90);
}
画布本来的方向是右下的,先旋转-90°,变成右上的方向,然后再把原点往上移_height,接着就是画文本了,画完之后再把canvas改回原来的样子。
接下来就是画刻度和网格了,drawAxis(),先附上代码,然后再进行粗略的讲解。
private void drawAxis(Canvas canvas) {
_paint.setColor(_gridColor);
_paint.setTextSize(_scale_size);
int scaleHeight = _height - _marginTop - _marginBottom; // 绘制去总高度
int scaleWidth = _width - _marginLeft - _marginRight - _scaleLineLength; // 绘制去总宽度
float perScaleHeight = scaleHeight / (float) _gridCount; // 每一格的高度
float perScaleWidth = scaleWidth / (float) _gridCount; // 每一格的宽度
int maxValue = _maxValue + _offsetY - _zoomOffsetY;
int minValue = _minValue + _offsetY + _zoomOffsetY;
Rect scaleRect = new Rect();
_paint.getTextBounds(maxValue + "", 0, (maxValue + "").length(), scaleRect);
for (int i = 0; i <= _gridCount; i++) {
int height = (int) (i * perScaleHeight) + _marginTop;
int width = _marginLeft + _scaleLineLength + (int) (i * perScaleWidth);
int textHeight = 0;
int startWidth = _marginLeft;
if ((i + 1) % 2 != 0) {
if (i == 0) {
textHeight += scaleRect.height();
} else if (i == _gridCount) {
} else {
textHeight += scaleRect.height() / 2;
}
int scaleValue = maxValue - i * (maxValue - minValue) / _gridCount;
float scaleTextLen = _paint.measureText(scaleValue + "");
canvas.drawText(scaleValue + "", startWidth - scaleTextLen, height + textHeight, _paint);
} else {
startWidth += _scaleLineLength;
}
// 画横轴
canvas.drawLine(startWidth, height, _width - _marginRight, height, _paint);
// 画纵轴
canvas.drawLine(width, _marginTop, width, _height - _marginBottom, _paint);
}
}
首先设置画笔的颜色和字体大小,然后根据宽度、高度和上下左右边距,以及设置的网格数,计算出每一格的宽度和高度,因为这个参数在画坐标轴和网格时需要用到。
然后一个循环,对坐标轴,文本和网格线进行绘制。OK,基本的背景图形就有了,后面再把数据接入即可。
绘制电平数据,关键点在于根据电平值和Y坐标显示的值,计算出其应在的位置点的(x,y)。
private void drawLevel(Canvas canvas) {
if (_levels.size() == 0)
return;
_paint.setColor(_levelColor);
_paint.setStyle(Paint.Style.STROKE.STROKE);
Path path = new Path();
int maxValue = _maxValue + _offsetY - _zoomOffsetY;
int minValue = _minValue + _offsetY + _zoomOffsetY;
float perHeight = (_height - _marginTop - _marginBottom) / (float) Math.abs(maxValue - minValue);
float perWidth = (_width - _marginLeft - _scaleLineLength - _marginRight) / (float) _levelCount;
for (int i = 0; i < _levels.size(); i++) {
float level = _levels.get(i);
int x = _marginLeft + _scaleLineLength + (int) (i * perWidth);
int y = _marginTop + (int) ((maxValue - level) * perHeight);
if (i == 0) {
path.moveTo(x, y);
} else {
path.lineTo(x, y);
}
}
canvas.drawPath(path, _paint);
if (_levels.size() < _levelCount) {
// 如果电平点数还未满时,绘制当前电平电
int index = _levels.size() - 1;
float level = _levels.get(index);
int x = _marginLeft + _scaleLineLength + (int) (index * perWidth);
int y = _marginTop + (int) ((maxValue - level) * perHeight);
_paint.setStyle(Paint.Style.FILL_AND_STROKE);
canvas.drawCircle(x, y, 5, _paint);
}
// 覆盖上边和下边,使得绘制的图形看上去只在网格中绘制
_paint.setStyle(Paint.Style.FILL);
Drawable background = getBackground();
if (background instanceof ColorDrawable) {
ColorDrawable colorDrawable = (ColorDrawable) background;
int color = colorDrawable.getColor();
_paint.setColor(color);
canvas.drawRect(_marginLeft + _scaleLineLength, 0, _width - _marginRight, _marginTop, _paint);
canvas.drawRect(_marginLeft + _scaleLineLength, _height - _marginBottom + 1, _width - _marginRight, _height, _paint);
}
}
绘制完折线之后,再在上边和下边各绘制一个与背景色相同的矩形,这样看上去电平线就只在网格中绘制。
至此,基本的图形就已绘制完成。那么电平流式绘制是怎么实现的呢?很简单,设置最大显示的电平点数,如果电平点数超过了最大值,则移除最开始的那个点,然后在绘制当前电平点数,看起来就是流动的了。
/**
* 设置电平值
*
* @param level
*/
public void setLevel(float level) {
if (_levels.size() > _levelCount) {
_levels.remove(0); // 移除最前一个
}
_levels.add(level);
postInvalidate();
}
最后要实现的就是图形的一些手势操作,Y轴拖动,多点触控缩放等。首先要重载onTouch()方法:
@Override
public boolean onTouch(View view, MotionEvent motionEvent) {
switch (motionEvent.getAction() & MotionEvent.ACTION_MASK) {
case MotionEvent.ACTION_DOWN:
// 单点触控
actionDown(motionEvent);
break;
case MotionEvent.ACTION_POINTER_DOWN:
// & MotionEvent.ACTION_MASK 才能得到 ACTION_POINTER_DOWN
// 多点触控
actionPointerDown(motionEvent);
break;
case MotionEvent.ACTION_MOVE:
// 移动:缩放或拖动
actionMove(motionEvent);
break;
case MotionEvent.ACTION_POINTER_UP:
// 多点触控抬起
actionPointerUp(motionEvent);
break;
case MotionEvent.ACTION_CANCEL:
case MotionEvent.ACTION_UP:
// 取消或抬起
actionCancelUp(motionEvent);
break;
}
return true;
}
首先是单点触控,在ACTION_DOWN(即手指按下的事件)中,判断按下的点是否在Y轴刻度区。
// 以下为手势拖动和缩放实现
private void actionDown(MotionEvent motionEvent) {
// 判断点是否在纵轴刻度上
if (Utils.IsPointInRect(0, 0, _marginLeft + _scaleLineLength, _height, (int) motionEvent.getX(), (int) motionEvent.getY())) {
_handleType = HandleType.DRAG;
_firstY = motionEvent.getY();
} else {
_handleType = HandleType.NONE;
}
}
然后在ACTION_MOVE(即手指按下后并移动)中,判断是往上移动还是往下移动,并实时刷新Y轴的刻度值和电平流的位置。
private void actionMove(MotionEvent motionEvent) {
if (_handleType == HandleType.DRAG) {
float currentY = motionEvent.getY();
float spanY = currentY - _firstY;
// 实际在屏幕上滑动的距离,映射到坐标轴Y上的距离
int spanScale = (int) (spanY / ((_height - _marginTop - _marginBottom) / Math.abs(_maxValue - _minValue)));
if (spanScale != 0) {
_offsetY = spanScale;
postInvalidate();
}
}
。。。
}
此处的_offsetY是关键点,绘制刻度值和电平流就用到的它。
多点触摸缩放,在ACTION_POINTER_DOWN(即两个手指按下的事件)中,记录当前的位置的距离_oldDistanceY。
private void actionPointerDown(MotionEvent motionEvent) {
if (motionEvent.getPointerCount() == 2) {
_handleType = HandleType.ZOOM;
float y1 = motionEvent.getY(0);
float y2 = motionEvent.getY(1);
_oldDistanceY = Math.abs(y1 - y2);
}
}
然后在ACTION_MOVE中计算实时的距离currentDistanceY,并根据此计算出_zoomOffsetY,以此实时刷新Y轴的刻度值和电平流的图形。
private void actionMove(MotionEvent motionEvent) {
if (_handleType == HandleType.DRAG) {
float currentY = motionEvent.getY();
float spanY = currentY - _firstY;
// 实际在屏幕上滑动的距离,映射到坐标轴Y上的距离
int spanScale = (int) (spanY / ((_height - _marginTop - _marginBottom) / Math.abs(_maxValue - _minValue)));
if (spanScale != 0) {
_offsetY = spanScale;
postInvalidate();
}
} else if (_handleType == HandleType.ZOOM && motionEvent.getPointerCount() == 2) { // 需要加一个判断不然会报错
float y1 = motionEvent.getY(0);
float y2 = motionEvent.getY(1);
float currentDistanceY = Math.abs(y1 - y2);
float perScaleHeight = (_height - _marginTop - _marginBottom) / (float) Math.abs(_maxValue - _minValue);
int spanScale = (int) ((currentDistanceY - _oldDistanceY) / perScaleHeight);
if (spanScale != 0 && ((_maxValue - spanScale) - (_minValue + spanScale)) >= _gridCount) { // 防止交叉越界,并且在放大到 总刻度长为 _gridCount 时,不能缩小
_zoomOffsetY = spanScale;
postInvalidate();
}
}
}
最后在ACTION_POINTER_UP和ACTION_UP中固化状态。
private void actionPointerUp(MotionEvent motionEvent) {
if (_handleType == HandleType.ZOOM) {
_maxValue -= _zoomOffsetY;
_minValue += _zoomOffsetY;
_zoomOffsetY = 0;
}
_handleType = HandleType.NONE;
}
private void actionCancelUp(MotionEvent motionEvent) {
if (_handleType == HandleType.DRAG) {
_maxValue += _offsetY;
_minValue += _offsetY;
_offsetY = 0;
}
_handleType = HandleType.NONE;
}
还忘了介绍自动调整比列显示,即根据电平的最大值和最小值,计算出Y轴的刻度值,然后再更新电平流图形。
/**
* 自动调整
*/
public void autoView() {
if (_levels.size() == 0)
return;
// 自动调整视图,就是要找到合理的 _maxValue 和 _minValue
float sum = 0;
int num = 0;
for (int i = 0; i < _levels.size(); i++) {
sum += _levels.get(i);
num = i;
}
float average = sum / num;
float maxValue = Collections.max(_levels);
float minValue = Collections.min(_levels);
num = 0;
for (int i = 1; i < 1000; i++) {
float max = average + 5 * i;
float min = average - 5 * i;
if (max > maxValue && min < minValue) {
if (Math.abs((max - maxValue) / (float) Math.abs(max - min)) >= 0.25) {
num = i; // 最大值与顶点的距离 >= 1/4
break;
}
}
}
if (num != 0) {
_maxValue = (int) (average + 5 * num);
_minValue = (int) (average - 5 * num);
postInvalidate();
}
}
至此,自定义电平流图形控件的绘制基本完成。当然这仅仅是实现了基本的绘制和展示,在实际的系统中,可能还会添加其他元素,比如电平门限,以及对外提供的一些接口或事件,这就要根据实际的应用场景再进行添加和完善了。
好了,本文的介绍就到这里了,我是一个安卓新手,代码中可能有所欠缺或不妥之处,敬请斧正。另外此控件的实现暂未考虑性能方便的问题,不过正常使用也很流畅,后面我要更新的频谱瀑布图,就会加入性能方便的考虑。敬请关注,谢谢。
最后奉上此控件的源代码: https://github.com/AnuoF/android_customview
AnuoF
Chengdu
Aug 18,2019