Android 自定义View:实现一个 FM 刻度尺

4,455 阅读4分钟

效果图

前言

最近在做收音机项目需要绘制一个 FM 刻度尺,刚开始考虑了一下现有的开源库,后来发现都不太满足 UI 小哥哥的要求,于是决定自己画一个吧。实现的 Demo 效果如上所示。主要包含大中小三种长度的刻度线,部分刻度整数值和一根指示器。这样就完美实现了一个 FM 刻度尺。下面大致介绍一下具体的做法。只想看代码的同学可以直奔 Github 地址

开始绘制

我是通过继承 View 重写相关类来实现自定义 View的。最重要的就是实现三个相关方法:

  • onMeasure():作用就是测量View需要多大的空间
  • onDraw():绘制各种形状
  • onTouchEvent():触摸事件的处理

重写onMeasure()

重写 onMeasure(),并调用父类 onMeasure()时:

  • RulerView 的 layout_width 以及 layout_height 属性值 match_parent 或者 wrap_content 显示大小由其父容器控件决定。
  • RulerView 设置为固定的值,就显示为该设定的值。
@Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        setMeasuredDimension(setMeasureWidth(widthMeasureSpec), setMeasureHeight(heightMeasureSpec));
    }

    private int setMeasureHeight(int spec) {
        int mode = MeasureSpec.getMode(spec);
        int size = MeasureSpec.getSize(spec);
        int result = Integer.MAX_VALUE;
        switch (mode) {
            case MeasureSpec.AT_MOST:
                size = Math.min(result, size);
                break;
            case MeasureSpec.EXACTLY:
                break;
            default:
                size = result;
                break;
        }
        return size;
    }

    private int setMeasureWidth(int spec) {
        int mode = MeasureSpec.getMode(spec);
        int size = MeasureSpec.getSize(spec);
        int result = Integer.MAX_VALUE;
        switch (mode) {
            case MeasureSpec.AT_MOST:
                size = Math.min(result, size);
                break;
            case MeasureSpec.EXACTLY:
                break;
            default:
                size = result;
                break;
        }
        return size;
    }

说明

MeasureSpec.getSize()会解析 MeasureSpec 值得到父容器 width 或者 height。

MeasureSpec.getMode()会得到三个int类型的值分别为:MeasureSpec.EXACTLY MeasureSpec.AT_MOST,MeasureSpec.UNSPECIFIED。

  • MeasureSpec.UNSPECIFIED 未指定,所以可以设置任意大小。
  • MeasureSpec.AT_MOST:RulerView 可以为任意大小,但是有一个上限。
  • MeasureSpec.EXACTLY:父容器为MeasureExampleView决定了一个大小,MeasureExampleView大小只能在这个父容器限制的范围之内。

重写 onDraw()

首先我们需要初始化画笔

private Paint mLinePaint;//刻度线画笔
private Paint mTextPaint;//指示数字画笔
private Paint mRulerPaint;//指示线画笔

private void init() {
        mLinePaint = new Paint();
        mLinePaint.setColor(getResources().getColor(R.color.grey));
        //抗锯齿
        mLinePaint.setAntiAlias(true);
        mLinePaint.setStyle(Paint.Style.STROKE);
        mLinePaint.setStrokeWidth(1);

        mTextPaint = new Paint();
        mTextPaint.setColor(getResources().getColor(R.color.grey));
        mTextPaint.setAntiAlias(true);
        mTextPaint.setStyle(Paint.Style.FILL);
        mTextPaint.setStrokeWidth(2);
        mTextPaint.setTextSize(24);

        mRulerPaint = new Paint();
        mRulerPaint.setAntiAlias(true);
        mRulerPaint.setStyle(Paint.Style.FILL_AND_STROKE);
        mRulerPaint.setColor(getResources().getColor(R.color.ruler_line));
        mRulerPaint.setStrokeWidth(3);
    }

开始绘制:

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        canvas.save();
        //绘制刻度线
        for (int i = min; i <= max; i++) {
            if (i % 10 == 0) {
                canvas.drawLine(20, 0, 20, 140, mLinePaint);

                String text = i / 10 + "";
                Rect rect = new Rect();
                float txtWidth = mTextPaint.measureText(text);
                mTextPaint.getTextBounds(text, 0, text.length(), rect);
                if (i / 10 % 2 == 1 && i / 10 != 107) {
                    canvas.drawText(text, 20 - txtWidth / 2, 72 + rect.height() + 74, mTextPaint);
                }
                if (i / 10 == 108) {
                    canvas.drawText(text, 20 - txtWidth / 2, 72 + rect.height() + 74, mTextPaint);
                }
            } else if (i % 5 == 0) {
                canvas.drawLine(20, 30, 20, 110, mLinePaint);
            } else {
                canvas.drawLine(20, 54, 20, 86, mLinePaint);
            }
            canvas.translate((float) 8, 0);
        }
        canvas.restore();

        //绘制指示线
        canvas.drawLine(position, 0, position, 140, mRulerPaint);
        mTextPaint.setTextSize(24);
    }

上面的代码分别画出了三种长度不同的刻度线、刻度数字和指示器的线。就这样我们完成了刻度尺的绘制。但是只有一个刻度尺是不够的,我们还需要重写 onTouchEvent 对点击和滑动事件做出响应。如果我们需要在滑动时获得刻度尺对应的数值还需要定义相应对监听接口。

重写 onTouchEvent()

@Override
    public boolean onTouchEvent(MotionEvent event) {
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                break;
            case MotionEvent.ACTION_MOVE:
                float x = event.getX();
                if (x < MIN_POSITION) {
                    setPosition(MIN_POSITION);
                } else if (x > MAX_POSITION) {
                    setPosition(MAX_POSITION);
                } else {
                    setPosition((int) x);
                }
                //移动指示条
                if (mMove != null) {
                    mMove.onMove(Double.parseDouble(String.format("%.1f", getFmChannel())));
                }
                Log.d("TAG", "position:" + position);
                Log.d("TAG", "channel:" + getFmChannel());
            case MotionEvent.ACTION_CANCEL:
                //只停在0.1(刻度线上)的位置
                setFmChanel(Double.parseDouble(String.format("%.1f", getFmChannel())));
                Log.d("停下来后", "channel:" + Double.parseDouble(String.format("%.1f", getFmChannel())));
                break;
            default:
        }
        return true;
    }
    
    public void setPosition(int i) {
        position = i;
        invalidate();
    }

    public void setFmChanel(double fmChanel) {
        int temp = (int) ((fmChanel - 87) * 80) + 20;
        setPosition(temp);
    }

    public double getFmChannel() {
        return ((position - 20.0) / 80.0 + 87.0);
    }

这样我们对刻度尺就是一个可以滑动指示器的刻度尺了。我在 ViewPager 中使用这个刻度尺的过程中遇到了一个问题:无法顺利滑动刻度尺了。这是因为和父控件滑动事件冲突,只需要重写 dispatchTouchEvent 方法就可以解决,代码如下:

   @Override
    public boolean dispatchTouchEvent(MotionEvent ev) {
        //解决刻度尺和viewPager的滑动冲突
        //当滑动刻度尺时,告知父控件不要拦截事件,交给子view处理
        getParent().requestDisallowInterceptTouchEvent(true);
        return super.dispatchTouchEvent(ev);
    }

如果我们需要实时监听刻度尺滑动时的值就需要设置相应监听接口。代码如下:

  /**
     * 定义监听接口
     */
    public interface OnMoveActionListener {
        void onMove(double x);
    }

    /**
     * 为每个接口设置监听器
     */
    public void setOnMoveActionListener(OnMoveActionListener move) {
        mMove = move;
    }

这样就实现了一个可以滑动指示器、实时监听刻度表数值、跳转至特定数值的刻度尺。

总结

整个刻度尺的实现主要包括刻度线相关元素绘制和滑动事件处理。刻度线绘制看起来麻烦,实际只要理清思路,将对应位置的对应长度的线画出来即可。此次提到的刻度尺可扩展性较差,需要的同学可以在次基础上重新修改使用。

Github:github.com/gs666/Ruler… 欢迎大家提issue 和 star~

掘金主页:juejin.cn/user/284079… 欢迎关注~