1、前言
本文讲解如何通过canvas快速实现柱状图表的绘制,先看下最终效果图:
网上开源的图表绘制框架还是很多的,功能也非常强大,比如hellocharts,MPAndroidCharts等。
但是作为一个有追求的程序员,过多地使用第三方的东西总是觉得缺少点安全感。主要原因是现成的轮子固然好,但是仅仅作为使用者的我们对其理解不深,导致不易于扩展修改。尤其是涉及自定义view这种效果显示的,保不准哪天产品和设计就把效果给改了,然后我们只能苦逼的研究源码,这样太被动了。而自己写的东西就不一样了,全在自己掌控之下,任你需求设计随便改,我自横刀对你笑。
2、分析
整个柱状图的绘制可以分为三个步骤:
- 坐标轴绘制
- 绘制标尺
- 刻度值绘制
- 柱状绘制
3、代码实现
3.1 坐标轴的绘制
坐标轴的绘制使用的是cavans的drawLine
方法:
drawLine(float startX, float startY, float stopX, float stopY,Paint paint)
其中传入的参数分别是起始坐标、终点坐标、画笔。俩点妥妥的确认一条直线,所以我们知道俩个绘制坐标点就行。
x轴和y轴都是从原点(坐标轴的原点,而得view的,请勿混淆了)开始画,为了确认这个点,我们全局定义了一个变量mPaddingLabLine
表示坐标轴到view边界(Y轴就是到左边,x就是相对于底部)的距离,这个距离我们是用来绘制坐标轴刻度以及标题。涉及到位置计算时,为了兼容性我们去除view的内边距。这个原点的x、y坐标计算方法代码如下:
float startX = getPaddingLeft() + mPaddingLabLine;
float startY = getHeight() - getPaddingBottom() - mPaddingLabLine;
x轴终点坐标很好理解,y方向不变与起始点相等,x方向宽度减去内边距即可:
float stopX = getWidth() - getPaddingRight();
float stopY = startY;
y轴终点坐标与x轴的类似,只是此时x点坐标没变,y点变成了内边距,起始点计算出来了后,线条的绘制就很简单了:
canvas.drawLine(startX, startY, stopX, stopY, mTabLinePaint); //x轴
canvas.drawLine(startX, startY, startX, getPaddingTop(), mTabLinePaint); //y轴
3.2 标尺绘制
标尺这部分也是绘制直线,在图表上绘制mLabelCount
条直线作为标尺线.
float height = startY;
float ceilHeight = (mHeight - mAxisTitleSize) / mLabelCount;
for (int i = 0; i < mLabelCount; i++)
{
canvas.save();
canvas.translate(0, -ceilHeight * i);
canvas.drawLine(startX, startY, stopX, stopY, mTabLinePaint);
canvas.restore();
}
我们计算根据标尺数目计算出每根标尺线之间的距离ceilHeight
,每次绘制标尺线时,只需要在Y方向平移画布就行。可以理解为绘制一与x轴重叠的直线,然后往上平移,这样逻辑上更简洁。
3.3 刻度值绘制
刻度值包括俩个,一个是坐标轴名称,比如Y轴的销售额,x轴的年份等,另一个是每一个刻度的具体值。
坐标轴名称绘制
x轴相对简单一点,直接调用cavans的drawText方法即可。这里主要分析下y轴的画法,因为y轴名称文字是竖着显示,所以绘画的时候需要把画布逆时针旋转90度,画布旋转注意中心点的位置为文字绘制的位置,但是需要稍微右移一点,防止文字左偏导致部分内容不可见:
canvas.save();
canvas.rotate(-90, getPaddingLeft(), mHeight / 3 - textHeight);
canvas.drawText(axisTitle, getPaddingLeft(), mHeight / 3, mTabLinePaint);
canvas.restore();
坐标轴刻度值绘制
绘制刻度值的关键代码:
canvas.drawText(axisName[i], pointFs[i].x, pointFs[i].y, mTabLinePaint);
其中axisName
表示需要绘制的坐标刻度值,pointFs
表示每个刻度值文字绘制时的坐标点数组,俩个坐标轴的计算方法不同,先看x轴:
int textHeight = getTextHeight();
int cellWidth = (mWidth - mPaddingLabLine - mColumnMarginRight) / this.mColumnName.length;
final int y = mHeight - mPaddingLabLine + textHeight;
for (int i = 0; i < pointFs.length; i++)
{
float textWidth = mTabLinePaint.measureText(axisName[i]);
PointF pointF = new PointF();
pointF.x = mPaddingLabLine + mColumnMarginRight + i * cellWidth + textWidth / 2;
pointF.y = y;
pointFs[i] = pointF;
}
根据x轴刻度值数目把x轴平分为宽度为cellWidth
的若干等分,mColumnMarginRight
的含义为,第一根柱子从原点向像右偏移的距离,防止第一根柱子紧挨Y轴。为了保证每个刻度值都在柱子的正中间,每次计算该刻度的x位置时,加上该刻度值宽度的一半。所有X轴刻度值得y坐标都是一样的,x轴加上文字高度即可textHeight
。
接下来是Y轴刻度值绘制:
float height = getHeight() - getPaddingBottom() - mPaddingLabLine;
float ceilHeight = (mHeight - mAxisTitleSize) / mLabelCount;
for (int i = 0; i < pointFs.length; i++)
{
PointF pointF = new PointF();
float textWidth = mTabLinePaint.measureText(axisName[i]);
pointF.x = mPaddingLabLine - textWidth - mAxisTextDistance / 3;
pointF.y = height - i * ceilHeight;
pointFs[i] = pointF;
}
}
Y轴刻度值得计算与X轴类似,有个小细节注意一下就好了,可能刻度值文字个数不一样,按drawText方法又是从左开始画的(刻度值左对齐),这样的显示效果非常丑,要让刻度值右对齐的话就必须测量文字的宽度,然后Y轴减去这个宽度就可以,这样就紧靠Y轴,所以再减去mAxisTextDistance / 3
就可以实现Y轴刻度值右对齐并且左偏移Y轴一定距离。
getTextHeight()
测量字体的绘制高度:
//获取字符串的高度
private int getTextHeight()
{
Paint.FontMetricsInt fontMetrics = mTabLinePaint.getFontMetricsInt();
return fontMetrics.bottom - fontMetrics.top;
}
3.4 绘制柱型
先上代码
//每一条数据占有的宽度
int cellWidth = (int) (mWidth - mPaddingLabLine - mColumnMarginRight) / mColumnData.length;
cellWidth = cellWidth > mMaxSubcolumnWidth ? mMaxSubcolumnWidth : cellWidth; //限制柱状图的最大宽度,防止列数过少时柱形图过宽
//矩形区有效绘制高度
float height = getHeight() - getPaddingBottom() - mPaddingLabLine;
float proportion = height / (maxValue - minValue); //高度数值比例尺
mTabLinePaint.setStyle(Paint.Style.FILL);
for (int j = 0; j < mColumnData.length; j++)
{
int startPos = mPaddingLabLine + mColumnMarginRight + cellWidth * j;
float top =height - proportion * (mColumnData[j] - minValue);
RectF rectF = new RectF(startPos + cellWidth / 5, top, startPos + cellWidth * 4 / 5, height);
canvas.drawRect(rectF, mTabLinePaint);
}
4、完整代码
public class ColumnView extends View
{
private int mWidth;
private int mHeight;
private final static int X_LAB = 0; //表示x轴绘制
private final static int Y_LAB = 1; //y轴的绘制
private Paint mTabLinePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
private int color_label_line = Color.parseColor("#DDDDDD"); //线条颜色
private int mLineStroke = 1;//线条宽度
private int mLabelCount = 10; //标尺线条数目
/**
* 图标所能显示的数值范围,
* 最大值比能显示的极值大20%,防止如果出现较大数值时,矩形过高而过于贴近顶部
*/
private float maxValue = 100 * 1.2f;
private float minValue = 20;
private float[] mColumnData; //模拟数据
private String mAxisXTitle = "销售项"; //x轴含义
private String mAxisYTitle = "销售额"; //y轴含义
private String[] mColumnName; //每个矩形数据项名称,即x轴刻度值
private String[] mAxisYName; //y轴刻度值
private int mAxisTitleSize = DensityUtils.dp2px(getContext(), 12); //坐标轴标题大小
private int mAxisNameTextSize = DensityUtils.dp2px(getContext(), 10); //坐标轴刻度值文字大小
private int mAxisTextDistance = DensityUtils.dp2px(getContext(), 6); // 坐标轴标题文字与刻度值之间的距离
private int mColumnMarginRight = DensityUtils.dp2px(getContext(), 3); // 矩形离Y轴的距离
private int mMaxSubcolumnWidth = DensityUtils.dp2px(getContext(), 40);
//坐标轴到view边界的留出的空间,用于绘制文字
private int mPaddingLabLine = mAxisTitleSize + mAxisNameTextSize + 2 * mAxisTextDistance;
public ColumnView(Context context)
{
this(context, null);
}
public ColumnView(Context context, @Nullable AttributeSet attrs)
{
this(context, attrs, 0);
}
public ColumnView(Context context, @Nullable AttributeSet attrs, int defStyleAttr)
{
super(context, attrs, defStyleAttr);
init();
}
private void init()
{
mTabLinePaint.setColor(color_label_line);
mTabLinePaint.setStrokeWidth(mLineStroke);
initData();
}
private void initData()
{
//生成数据
mColumnData = new float[8];
for (int i = 0; i < mColumnData.length; i++)
{
mColumnData[i] = new Random().nextInt(80) + 20;
}
//Y轴刻度
mColumnName = new String[8];
for (int i = 0; i < mColumnData.length; i++)
{
mColumnName[i] = "数据" + String.valueOf(i + 1);
}
//y轴刻度
mAxisYName = new String[mLabelCount];
int v = (int) ((maxValue - minValue) / mLabelCount * 100);
float value = v / 100f; //转成精确小数点后俩位的小数
for (int i = 0; i < mAxisYName.length; i++)
{
String axisName = value * i + minValue + "";
mAxisYName[i] = axisName;
}
}
@Override
public void layout(int l, int t, int r, int b)
{
super.layout(l, t, r, b);
int width = getWidth();
int height = getHeight();
//兼容性处理,获取view的可绘制区域的宽高
mWidth = width - getPaddingLeft() - getPaddingRight();
mHeight = height - getPaddingBottom() - getPaddingTop();
}
@Override
protected void onDraw(Canvas canvas)
{
super.onDraw(canvas);
canvas.drawColor(Color.WHITE);
drawLabLine(canvas);
drawAxis(canvas);
drawSubcolumn(canvas);
}
//画柱状矩形
private void drawSubcolumn(Canvas canvas)
{
mTabLinePaint.setColor(Color.parseColor("#4476AB"));
//每一条数据占有的宽度
int cellWidth = (int) (mWidth - mPaddingLabLine - mColumnMarginRight) / mColumnData.length;
cellWidth = cellWidth > mMaxSubcolumnWidth ? mMaxSubcolumnWidth : cellWidth; //限制柱状图的最大宽度
//矩形区有效绘制高度
float height = getHeight() - getPaddingBottom() - mPaddingLabLine;
float proportion = height / (maxValue - minValue); //高度数值比例尺
mTabLinePaint.setStyle(Paint.Style.FILL);
for (int j = 0; j < mColumnData.length; j++)
{
int startPos = mPaddingLabLine + mColumnMarginRight + cellWidth * j;
float top =height - proportion * (mColumnData[j] - minValue);
RectF rectF = new RectF(startPos + cellWidth / 5, top, startPos + cellWidth * 4 / 5, height);
canvas.drawRect(rectF, mTabLinePaint);
}
}
//画刻度值
private void drawAxis(Canvas canvas)
{
drawAxisTitle(canvas, mAxisYTitle, Y_LAB);
drawAxisTitle(canvas, mAxisXTitle, X_LAB);
drawAxisName(canvas, Y_LAB, mAxisYName);
drawAxisName(canvas, X_LAB, mColumnName);
}
// 画刻度值
private void drawAxisName(Canvas canvas, int labelType, String[] axisName)
{
mTabLinePaint.setTextSize(mAxisNameTextSize);
//计算绘制坐标
PointF[] pointFs = calculateAxisNamePosition(labelType, axisName);
for (int i = 0; i < axisName.length; i++)
{
canvas.drawText(axisName[i], pointFs[i].x, pointFs[i].y, mTabLinePaint);
}
}
@NonNull
private PointF[] calculateAxisNamePosition(int labelType, String[] axisName)
{
PointF[] pointFs = new PointF[axisName.length];
if (labelType == X_LAB) //x轴
{
int textHeight = getTextHeight();
int cellWidth = (mWidth - mPaddingLabLine - mColumnMarginRight) / this.mColumnName.length;
final int y = mHeight - mPaddingLabLine + textHeight;
for (int i = 0; i < pointFs.length; i++)
{
float textWidth = mTabLinePaint.measureText(axisName[i]);
PointF pointF = new PointF();
pointF.x = mPaddingLabLine + mColumnMarginRight + i * cellWidth + textWidth / 2;
pointF.y = y;
pointFs[i] = pointF;
}
} else
{
float height = getHeight() - getPaddingBottom() - mPaddingLabLine;
float ceilHeight = (mHeight - mAxisTitleSize) / mLabelCount;
for (int i = 0; i < pointFs.length; i++)
{
PointF pointF = new PointF();
float textWidth = mTabLinePaint.measureText(axisName[i]);
pointF.x = mPaddingLabLine - textWidth - mAxisTextDistance / 3;
pointF.y = height - i * ceilHeight;
pointFs[i] = pointF;
}
}
return pointFs;
}
//绘制坐标轴名称
private void drawAxisTitle(Canvas canvas, String axisTitle, int labelType)
{
mTabLinePaint.setTextSize(mAxisTitleSize);
int textHeight = getTextHeight();
if (labelType == X_LAB)
{
//x轴
canvas.drawText(mAxisXTitle, mWidth / 2, mHeight - textHeight + mAxisTextDistance, mTabLinePaint);
} else
{
//y轴
canvas.save();
//注意中心点的位置,需要稍微右移一点,防止文字太过于左偏
canvas.rotate(-90, getPaddingLeft(), mHeight / 3 - textHeight);
canvas.drawText(axisTitle, getPaddingLeft(), mHeight / 3, mTabLinePaint);
canvas.restore();
}
}
//获取字符串的高度
private int getTextHeight()
{
Paint.FontMetricsInt fontMetrics = mTabLinePaint.getFontMetricsInt();
return fontMetrics.bottom - fontMetrics.top;
}
//画坐标轴
private void drawLabLine(Canvas canvas)
{
mTabLinePaint.setColor(color_label_line);
//x轴起始点(也是Y轴的起点),终点
float startX = getPaddingLeft() + mPaddingLabLine;
float startY = getHeight() - getPaddingBottom() - mPaddingLabLine;
float stopX = getWidth() - getPaddingRight();
float stopY = startY;
canvas.drawLine(startX, startY, stopX, stopY, mTabLinePaint); //x轴
canvas.drawLine(startX, startY, startX, getPaddingTop(), mTabLinePaint); //y轴
//画标尺
float height = startY;
float ceilHeight = (mHeight - mAxisTitleSize) / mLabelCount;
for (int i = 0; i < mLabelCount; i++)
{
canvas.save();
canvas.translate(0, -ceilHeight * i);
canvas.drawLine(startX, startY, stopX, stopY, mTabLinePaint);
canvas.restore();
}
}
}
5、总结
在xml文件中的使用与普通view用法相同:
<widget.qike.com.columnview.widget.ColumnView
android:id="@+id/cv_test"
android:layout_width="match_parent"
android:layout_height="match_parent"/>