×
广告

自定义View之FormView

96
Routee
2018.07.23 19:15* 字数 962

本Demo主要目的为学习及研究自定义View,通过实现一个图表的数据展示功能,熟悉和了解View的绘制过程

先看一下产品需求

产品需求
产品需求
  • X轴和Y轴坐标分别表示时间及对应的数值
  • Y轴坐标依数据显示5-10行,Y轴辅助线显示3-5条
  • X轴依时间文字的长短进行展示,要求X轴坐标值不重合
  • 各坐标点用直线相连,且连线与X轴区域添加渐变色
  • 添加touch时间,当触摸至坐标点时显示文本框,显示说明文本,绘制坐标点圆圈及X、Y轴辅助线

功能分析

为完成产品的需求,我们需要解决如下的6个问题:
1.首先,我们需要计算出绘图区域及坐标轴文字显示区域;
2.绘制坐标轴文字;
3.绘制平行于X轴的辅助线;
4.计算各坐标点位置;
5.连接各坐标点并绘制渐变区域;
6.捕捉touch事件并添加回调;
7.依据回调设置提示框内容并绘制提示框。

代码实现

因为要展示数据,所以需要自定义View暴露对外的设置数据的接口,同时数据需要如下三个属性:颜色(绘制连接线时连接线的颜色)、Y轴坐标(选用string类型,因为横坐标可能是周一、二……)、X轴坐标值(这里选用double类型)。因此,在自定义View中可以使用内部类Units来作为坐标点,同时用Map<Color,Units>来保存需要展示的数据。

//坐标点位置
public static class Units {
    public double y;
    String x;

    public Units(double y, String x) {
        this.x = x;
        this.y = y;
    }
}
//对外暴露的接口,用以设置数据
public void resetData(Map<Integer, List<Units>> map) {
    this.mDatas.clear();
    Iterator<Integer> it = map.keySet().iterator();
    while (it.hasNext()) {
        Integer color = it.next();
        mDatas.put(color, map.get(color));
    }
    invalidate();
}

我们知道,因为是自定义View,所以我们需要添加一些atrrs属性,便于对View进行一些设置;
本demo中添加的一些属性如下

属性名 类型 说明
min_size integer view最小尺寸
base_stroke_width integer 基础线条宽度
base_stroke_color color 基础线条颜色
base_text_size integer 坐标文字大小
help_text_size integer 弹出提示框文字大小
help_text_margin integer 弹出提示框Margin
text_margin_y integer Y方向文字与表格间距
point_size integer 触摸时显示坐标点的大小
point_touch_size integer 触摸范围
text_margin_x integer X方向文字与表格间距
zero_start boolean Y轴是否从零开始
help_text_bg_res reference 触摸响应说明背景
shader boolean 是否添加X坐标与连线间的渐变

有了如上属性,我们在自定义初始化的初始化这些属性,同时初始化Paint

private void init(AttributeSet attrs) {
    TypedArray a = getContext().obtainStyledAttributes(attrs, R.styleable.RouteeFormView);
    mMinSize = a.getInteger(R.styleable.RouteeFormView_min_size, 0);
    mBaseColor = a.getColor(R.styleable.RouteeFormView_base_stroke_color, Color.parseColor("#d0d0d0"));
    mBaseStrokeWidth = a.getInteger(R.styleable.RouteeFormView_base_stroke_width, 1);
    mBaseTextSize = a.getInteger(R.styleable.RouteeFormView_base_text_size, 12);
    mHelpTextSize = a.getInteger(R.styleable.RouteeFormView_help_text_size, 14);
    mHelpTextMargin = a.getInteger(R.styleable.RouteeFormView_help_text_margin, 8);
    mTextMarginX = DisplayUtils.dp2px(getContext(), a.getInteger(R.styleable.RouteeFormView_text_margin_x, 4));
    mTextMarginY = DisplayUtils.dp2px(getContext(), a.getInteger(R.styleable.RouteeFormView_text_margin_y, 4));
    mHelpTextBgResId = a.getResourceId(R.styleable.RouteeFormView_help_text_bg_res, R.drawable.bg_routee_form_view_help_text);
    mNeedDrawShader = a.getBoolean(R.styleable.RouteeFormView_shader, false);
    mPointWidth = DisplayUtils.dp2px(getContext(), a.getInteger(R.styleable.RouteeFormView_point_size, 2));
    mPointTouchWith = DisplayUtils.dp2px(getContext(), a.getInteger(R.styleable.RouteeFormView_point_touch_size, 10));
    isStartZero = a.getBoolean(R.styleable.RouteeFormView_zero_start, false);
    a.recycle();
    mPaint = new Paint();
    mPaint.setAntiAlias(true);
}

然后,我们需要重写我们的onMeasure方法,计算自定义View的大小

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    super.onMeasure(widthMeasureSpec, heightMeasureSpec);
    int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
    int widthSpecSize = MeasureSpec.getSize(widthMeasureSpec);
    int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec);
    int heightSpecSize = MeasureSpec.getSize(heightMeasureSpec);
    if (widthSpecMode == AT_MOST && heightSpecMode == AT_MOST) {
        setMeasuredDimension(mMinSize, mMinSize);
    } else if (widthMeasureSpec == AT_MOST) {
        setMeasuredDimension(mMinSize, heightSpecSize);
    } else if (heightMeasureSpec == AT_MOST) {
        setMeasuredDimension(widthSpecSize, mMinSize);
    }
}

在计算出View的尺寸后,我们需要开始完成自定View最重要的一步绘制,也就是重写onDraw(Canvas canvas)方法,依据需求分析,我们需要进行一些列的计算再去按如下顺序去绘制View的不同部分:

流程图
流程图
@Override
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    //当设置的数据为空时不绘制任何UI
    if (mDatas == null || mDatas.size() == 0) {
        return;
    }
    calc();
    drawText(canvas);
    drawLines(canvas);
    drawData(canvas);
    drawHelpLine(canvas);
    drawHelpText(canvas);
}

在上述的onDraw(Canvas canvas)我们发现,在drawText(cavas)绘制坐标轴文字之前我们执行了calc()方法,该方法其实就是我们之前需求分析时提到的需要计算的一些东西。我们再来看看calc()都计算了哪些:

private void calc() {
    calcMaxYValue();    //计算Y轴最大值
    calcMinYValue();    //计算Y轴最小值
    calcYSpacing();     //计算Y轴间隔大小
    calcYTextList();    //计算Y轴的文字内容列表
    calcTextSize();     //计算文字占用尺寸
    calcFormSize();     //计算表格区域尺寸
    calcXTextList();    //计算X轴的文字内容列表
    calcBaseLines();    //计算平行于X轴的辅助线
    calcData();         //计算数据对应的位置
}

接下来就是计算这些数据的具体实现,代码的实现有很多种,以下的方法只是其中一种实现方式而已。主要还是calc的逻辑

private void calcMaxYValue() {
    double max = 0;
    for (Integer color : mDatas.keySet()) {
        for (Units units : mDatas.get(color)) {
            max = Math.max(max, units.y);
        }
    }
    mMaxUsefulY = max;
}
private void calcMinYValue() {
    double min = 0;
    for (Integer color : mDatas.keySet()) {
        List<Units> list = mDatas.get(color);
        for (int i = 0; i < list.size(); i++) {
            if (i == 0) {
                min = list.get(i).y;
            }
            min = Math.min(min, list.get(i).y);
        }
    }
    mMinUsefulY = min;
}
private void calcYSpacing() {
    mUsefulY = mMaxUsefulY - mMinUsefulY;
    if (mUsefulY == 0) {
        mMaxUsefulY = mMinUsefulY + 80;
        mUsefulY = 80.0;
    }
    int minSpacing = (int) (mUsefulY / 6);
    if (minSpacing == 0) {
        int w = (mMaxUsefulY + "").length();
        int spacing = w / 10;
        if (spacing != 0) {
            mYDataSpacing = spacing;
        } else if (mMaxUsefulY == 0) {
            mYDataSpacing = 20;
        } else if (mMaxUsefulY <= 1) {
            mYDataSpacing = 1;
        } else {
            mYDataSpacing = 2;
        }
        return;
    }
    String s = minSpacing + "";
    int length = s.length() - 1 > 0 ? s.length() - 1 : 0;
    int unit = (int) (1 * Math.pow(10, length));
    for (int i = 1; i <= 10; i += 1) {
        if (mUsefulY / (i * unit) < 6) {
            mYDataSpacing = i * unit;
            return;
        }
    }
}
private void calcYTextList() {
    mYTexts = new ArrayList<>();
    if (mYDataSpacing == 1) {
        mMaxUsefulY = 1.0;
    }
    double remainder = mMaxUsefulY % mYDataSpacing;
    for (double i = mMaxUsefulY - remainder + mYDataSpacing; i >= mMinUsefulY - mYDataSpacing && i >= 0; i -= mYDataSpacing) {
        mYTexts.add((int) i + "");
    }
    String maxY = mYTexts.get(0);
    mMaxYValue = Double.parseDouble(maxY);
    String minY = mYTexts.get(mYTexts.size() - 1);
    mMinYValue = Double.parseDouble(minY);
}
private void calcTextSize() {
    String xMax = "";
    for (Integer integer : mDatas.keySet()) {
        List<Units> units = mDatas.get(integer);
        for (Units unit : units) {
            xMax = unit.x.length() > xMax.length() ? unit.x : xMax;
        }
    }
    mPaint.setTextSize(DisplayUtils.dp2px(getContext(), mBaseTextSize));
    Rect bounds = new Rect();
    mPaint.getTextBounds(xMax, 0, xMax.length(), bounds);
    mMaxXTextHeight = bounds.height();
    mMaxXTextWidth = bounds.width();
    mPaint.getTextBounds(mYTexts.get(0), 0, mYTexts.get(0).length(), bounds);
    mMaxYTextHeight = bounds.height();
    mMaxYTextWidth = bounds.width();
    mMaxXTextHeight = Math.max(mMaxXTextHeight, mMaxYTextHeight);
    mMaxYTextHeight = Math.max(mMaxXTextHeight, mMaxYTextHeight);
    mMaxYTextWidth = Math.max(mMaxYTextWidth, mMaxXTextWidth / 2 - mTextMarginX);
}
private void calcFormSize() {
    mFormWidth = getWidth() - mTextMarginX - mMaxYTextWidth - mMaxXTextWidth / 2 - 1;
    mFormHeight = getHeight() - mTextMarginY - mMaxXTextHeight - mMaxYTextHeight;
}
private void calcXTextList() {
    mXTexts.clear();
    mXSpacingCount = 1;
    Iterator<Integer> it = mDatas.keySet().iterator();
    if (it.hasNext()) {
        Integer next = it.next();
        List<Units> units = mDatas.get(next);
        while ((units.size() / mXSpacingCount + 1) * mMaxXTextWidth > mFormWidth * 2 / 3) {
            mXSpacingCount++;
        }
        for (int i = 0; i < units.size(); i++) {
            mXTexts.add(units.get(i).x + "");
        }
        return;
    }
}
private void calcBaseLines() {
    mLineSpacingCount = (mYTexts.size() - 1) / 2;
    if (mLineSpacingCount == 0) {
        mLineSpacingCount = 1;
    }
    mLineSpacingCountRemainer = (mYTexts.size() - 1) % mLineSpacingCount;
}
private void calcData() {
    Iterator<Integer> it = mDatas.keySet().iterator();
    int size = mXTexts.size();
    while (it.hasNext()) {
        List<Point> listPoint = new ArrayList<>();
        List<Rect> listRect = new ArrayList<>();
        Integer color = it.next();
        List<Units> units = mDatas.get(color);
        for (int i = 0; i < units.size(); i++) {
            float x = i * mFormWidth / (size - 1) + mMaxYTextWidth + mTextMarginY;
            float y = (float) ((mMaxYValue - units.get(i).y) * mFormHeight / (mMaxYValue - mMinYValue) + mMaxYTextHeight);
            listPoint.add(new Point((int) x, (int) y));
            listRect.add(new Rect((int) (x - mPointTouchWith), (int) (y - mPointTouchWith), (int) (x + mPointTouchWith), (int) (y + mPointTouchWith)));
        }
        mDataPoints.put(color, listPoint);
        mDataRects.put(color, listRect);
    }
}

以上,该计算的都计算了,onDraw方法此时已经可以将我们的数据绘制出来了,但是产品的需求是在我们点击touch的时候还需要绘制辅助线并显示辅助文本,因此,我们还需要去重写onTouchEvent方法:

public boolean onTouchEvent(MotionEvent event) {
    int pointerCount = event.getPointerCount();
    if (pointerCount > 1) {
        getParent().requestDisallowInterceptTouchEvent(false);
        return false;
    }
    mXPosition = event.getX();
    mYPosition = event.getY();
    switch (event.getAction()) {
        case MotionEvent.ACTION_DOWN:
            mDownEventMills = Calendar.getInstance().getTimeInMillis();
            break;
        case MotionEvent.ACTION_MOVE:
            break;
        case MotionEvent.ACTION_UP:
            mUpEventMills = Calendar.getInstance().getTimeInMillis();
            break;
        default:
            break;
    }
    if (mUpEventMills - mDownEventMills < 100 && mUpEventMills > mDownEventMills) {
        mPreRect = null;
    }
    getParent().requestDisallowInterceptTouchEvent(true);
    invalidate();
    return true;
}

注意:因为我们的formView要添加touch事件,所以当formView被用在可以滚动的ViewGroup中时,我们touch时可能会消耗掉touchEvent,如果单独处理将滑动事件透传至ViewGroup,可能并不是我们想要的效果。所以我们添加了event.getPointer判断,当多点触控时,我们不消耗滑动事件。这样就能平滑的操作formView了。

最后,就是我们的drawText(canvas);drawLines(canvas);drawData(canvas);drawHelpLine(canvas);drawHelpText(canvas);在这里,大家可以在gitHub中查看代码,我们只渐变效果及辅助文本是如何被绘制的。

private void drawData(Canvas canvas) {
    Iterator<Integer> it = mDataPoints.keySet().iterator();
    while (it.hasNext()) {
        Path path = new Path();
        Integer color = it.next();
        List list = mDataPoints.get(color);
        for (int i = 0; i < list.size(); i++) {
            Point o = (Point) list.get(i);
            if (i == 0) {
                path.moveTo(o.x, o.y);
            } else {
                path.lineTo(o.x, o.y);
            }
        }

        mPaint.setColor(color);
        mPaint.setStyle(Paint.Style.STROKE);
        canvas.drawPath(path, mPaint);
        //绘制渐变效果
        if (mNeedDrawShader) {
            path.lineTo(((Point) list.get(list.size() - 1)).x, mFormHeight + mMaxYTextHeight);
            path.lineTo(mMaxYTextWidth + mTextMarginX, mFormHeight + mMaxYTextHeight);
            path.lineTo(((Point) list.get(0)).x, ((Point) list.get(0)).y);

            mPaint.setStyle(Paint.Style.FILL);
            mPaint.setColor(Color.WHITE);
            canvas.drawPath(path, mPaint);

            Shader shder = new LinearGradient(getWidth() / 2, 0, getWidth() / 2, getHeight()
                    , color & Color.parseColor("#44ffffff")
                    , color & Color.parseColor("#11ffffff"), Shader.TileMode.CLAMP);
            mPaint.setShader(shder);
            mPaint.setStyle(Paint.Style.FILL);
            mPaint.setColor(color);
            canvas.drawPath(path, mPaint);
            mPaint.setShader(null);
        }
    }
}

因为辅助文本是自定义的,所以我们需要对外提供一个借口,用来设置辅助文本内容。文本的model包含两个属性int color;String text;

public static class TextUnit {
    int    color;
    String text;
    public TextUnit(int color, String text) {
        this.color = color;
        this.text = text;
    }
}

最终使用List<List<TextUnit>> mDataTexts来保存需要展示的文本。

private void drawHelpText(Canvas canvas) {
        if (!calcHelpTextSize()) {
            return;
        }
        Drawable drawable = ContextCompat.getDrawable(getContext(), mHelpTextBgResId);
        Rect rect = calcHelpRect();
        if (rect == null) {
            return;
        }
        drawable.setBounds(rect);
        drawable.draw(canvas);
        Rect bounds = new Rect();
        int margin = DisplayUtils.dp2px(getContext(), mHelpTextMargin);
        int height = (mMaxHelpTextHeight - margin * 2 - (mDataTexts.size() - 1) * DisplayUtils.dp2px(getContext(), 4)) / mDataTexts.size();
        mPaint.setTextSize(DisplayUtils.dp2px(getContext(), mHelpTextSize));
        for (int i = 0; i < mDataTexts.size(); i++) {
            int width = 0;
            for (TextUnit unit : mDataTexts.get(i)) {
                mPaint.setColor(unit.color);
                mPaint.getTextBounds(unit.text, 0, unit.text.length(), bounds);
                canvas.drawText(unit.text, rect.left + margin + width, rect.top + height + margin + i * (height + DisplayUtils.dp2px(getContext(), 4)), mPaint);
                width += bounds.width();
            }
        }
    }

最后,展示一下实际效果图

效果图
日记本
Web note ad 1