安卓自定义电平流控件

引言

在无线电监测方面,需要对信号进行展示,其中一项数据就是设备返回的电平数据,需要对其实时展示,一图胜千言,最好且最直观的方式就是图表展示,这样对其信号强弱的变化,就可以一目了然。

本文主要讲安卓版的电平流图形控件的实现,首先效果图如下:

实现了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

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,319评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,801评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,567评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,156评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,019评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,090评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,500评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,192评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,474评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,566评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,338评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,212评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,572评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,890评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,169评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,478评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,661评论 2 335

推荐阅读更多精彩内容