美文网首页具体自定义控件
安卓自定义电平流控件

安卓自定义电平流控件

作者: AnuoF | 来源:发表于2019-08-22 08:57 被阅读0次

    引言

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

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

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

    相关文章

      网友评论

        本文标题:安卓自定义电平流控件

        本文链接:https://www.haomeiwen.com/subject/udjgsctx.html