自定义View之FormView

本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();
            }
        }
    }

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

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

推荐阅读更多精彩内容