本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();
}
}
}
最后,展示一下实际效果图