折線圖在很多App中都有用到,GitHub上面有一些功能全面的折線圖框架,比如hellecharts-android,achartengine。但是很多時(shí)候設(shè)計(jì)師給定的樣式,通過這些框架不一定能完全達(dá)到效果。所以只有琢磨著通過自定義View來自己繪制一個(gè)折線圖:

根據(jù)展示效果,坐標(biāo)軸、刻度、刻度值、數(shù)據(jù)點(diǎn)線、標(biāo)題全部都通過自繪來實(shí)現(xiàn)。
初始化繪制相關(guān)參數(shù)
在構(gòu)造函數(shù)中初始化畫筆及數(shù)據(jù)刻度值等參數(shù):
private void init() {
this.setBackgroundColor(Color.WHITE);
// x軸刻度值
if (xLabel == null) {
xLabel = new String[]{"12-11", "12-12", "12-13", "12-14", "12-15", "12-16", "12-17"};
}
// 數(shù)據(jù)點(diǎn)
if (data == null) {
data = new String[]{"2.98", "2.99", "2.99", "2.98", "2.92", "2.94", "2.95"};
}
// 標(biāo)題
if (title == null) {
title = "七日年化收益率(%)";
}
// 根據(jù)設(shè)置的數(shù)據(jù)值生成Y坐標(biāo)刻度值
yLabel = createYLabel();
// 數(shù)據(jù)點(diǎn)及其連線的顏色集合
mDataLineColors = new String[]{"#fbbc14", "#fbaa0c", "#fbaa0c", "#fb8505", "#ff6b02", "#ff5400", "#ff5400"};
// 新建畫筆
mDataLinePaint = new Paint(); // 數(shù)據(jù)(點(diǎn)和連線)畫筆
mScaleLinePaint = new Paint(); // 坐標(biāo)(刻度線條)值畫筆
mScaleValuePaint = new Paint(); // 圖表(刻度值)畫筆
mBackColorPaint = new Paint(); // 背景(色塊)畫筆
// 畫筆抗鋸齒
mDataLinePaint.setAntiAlias(true);
mScaleLinePaint.setAntiAlias(true);
mScaleValuePaint.setAntiAlias(true);
mBackColorPaint.setAntiAlias(true);
}
x軸的刻度值、數(shù)據(jù)點(diǎn)、y中刻度值設(shè)置初始值根據(jù)初始值先進(jìn)行繪制。后續(xù)重新設(shè)置數(shù)據(jù)后再重繪。
在根據(jù)給定數(shù)據(jù)點(diǎn)生成y的坐標(biāo)刻度值時(shí),需要考慮兩點(diǎn):
1.數(shù)據(jù)點(diǎn)及其連線需要繪制在坐標(biāo)區(qū)域的中間位置,并且數(shù)據(jù)點(diǎn)的臨界值(最大或最小值)不能超過y坐標(biāo)刻度的臨界值;
2.y刻度值必須均分,并且根據(jù)不同數(shù)據(jù)值展示合適的y刻度值。
所以createYLabel()方法實(shí)現(xiàn)了根據(jù)給定的數(shù)據(jù)點(diǎn)的值來計(jì)算出對應(yīng)y刻度值的算法。
/**
* 根據(jù)數(shù)據(jù)值data生成合適的Y坐標(biāo)刻度值
*
* @return y軸坐標(biāo)刻度值數(shù)組
*/
private String[] createYLabel() {
float[] dataFloats = new float[7];
for (int i = 0; i < data.length; i++) {
dataFloats[i] = Float.parseFloat(data[i]);
}
// 將數(shù)據(jù)值從小到大排序
Arrays.sort(dataFloats);
// 中間值
float middle = format3Bit((dataFloats[0] + dataFloats[6]) / 2f);
// y軸刻度,+0.01f為了避免所有數(shù)據(jù)點(diǎn)都相等時(shí)計(jì)算出的y刻度為0.
float scale = format3Bit((dataFloats[6] - dataFloats[0]) / 4 + 0.01f);
String[] yText = new String[5];
yText[0] = (middle - 2 * scale) + "";
yText[1] = (middle - scale) + "";
yText[2] = middle + "";
yText[3] = (middle + scale) + "";
yText[4] = (middle + 2 * scale) + "";
for (int i = 0; i < yText.length; i++) {
yText[i] = format3Bit(yText[i]);
}
return yText;
}
將數(shù)據(jù)值排序后計(jì)算出中間值middle、y軸刻度值scale。format3Bit(float number)將計(jì)算結(jié)果進(jìn)行格式化,保證刻度值的小數(shù)位數(shù)一致。
/**
* 格式化數(shù)字 ###.000
*
* @return ###.000
*/
private float format3Bit(float number) {
DecimalFormat decimalFormat = new DecimalFormat("###.000");
String target = decimalFormat.format(number);
if (target.startsWith(".")) {
target = "0" + target;
}
return Float.parseFloat(target);
}
/**
* 格式化數(shù)據(jù) ###.000
*
* @param numberStr 格式化后的字符串形式
* @return ###.000
*/
private String format3Bit(String numberStr) {
if (TextUtils.isEmpty(numberStr)) {
return "0.000";
}
float numberFloat = Float.parseFloat(numberStr);
DecimalFormat decimalFormat = new DecimalFormat("###.000");
String target = decimalFormat.format(numberFloat);
if (target.startsWith(".")) {
target = "0" + target;
}
return target;
}
在onMeasure中初始化繪制尺寸及畫筆屬性等參數(shù):
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
initParams();
}
private void initParams() {
int width = getMeasuredWidth() - getPaddingLeft() - getPaddingRight();
int height = getMeasuredHeight() - getPaddingTop() - getPaddingBottom();
yScale = height / 7.5f; // y軸刻度
xScale = width / 7.5f; // x軸刻度
startPointX = xScale / 2; // 開始繪圖的x坐標(biāo)
startPointY = yScale / 2; // 開始UI圖的y坐標(biāo)
xLength = 6.5f * xScale; // x軸長度
yLength = 5.5f * yScale; // y軸長度
xTextPlaceHeight = yScale / 2; // x軸刻度文字的占位高度
yTextPlaceWidth = xScale / 2; // y軸刻度文字的占位寬度
titleHeight = yScale;
chartLineStrokeWidth = xScale / 50; // 圖表線條的線寬
coordTextSize = xScale / 5; // 坐標(biāo)刻度文字的大小
dataLineStrodeWidth = xScale / 15; // 數(shù)據(jù)線條的線寬
// 設(shè)置畫筆相關(guān)屬性
mBackColorPaint.setColor(0x11DEDE68);
mScaleLinePaint.setStrokeWidth(chartLineStrokeWidth);
mScaleLinePaint.setColor(0xFFDEDCD8);
mScaleValuePaint.setColor(0xFF999999);
mScaleValuePaint.setTextSize(coordTextSize);
mDataLinePaint.setStrokeWidth(dataLineStrodeWidth);
mDataLinePaint.setStrokeCap(Paint.Cap.ROUND);
mDataLinePaint.setTextSize(1.5f * coordTextSize);
}
在onMeasure中就不去判斷測量模式了,布局中直接使用match_parent或具體的dp值。
繪制
在onDraw方法中進(jìn)行繪制:
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
drawBackColor(canvas); // 繪制背景色塊
drawYAxisAndXScaleValue(canvas); // 繪制y軸和x刻度值
drawXAxisAndYScaleValue(canvas); // 繪制x軸和y刻度值
drawDataLines(canvas); // 繪制數(shù)據(jù)連線
drawDataPoints(canvas); // 繪制數(shù)據(jù)點(diǎn)
drawTitle(canvas); // 繪制標(biāo)題
}
繪制方法的具體實(shí)現(xiàn):
/**
* 繪制背景色塊
* @param canvas
*/
private void drawBackColor(Canvas canvas) {
for (int i = 0; i < 7; i++) {
if (i == 2 || i == 4 || i == 6) {
canvas.drawRect(startPointX + (i - 1) * xScale,
startPointY,
startPointX + i * xScale,
yLength + startPointY,
mBackColorPaint);
}
}
}
private void drawYAxisAndXScaleValue(Canvas canvas) {
for (int i = 0; i < 7; i++) {
canvas.drawLine(startPointX + i * xScale,
startPointY,
startPointX + i * xScale,
startPointY + yLength,
mScaleLinePaint);
mScaleValuePaint.getTextBounds(xLabel[i], 0, xLabel[i].length(), bounds);
if (i == 0) {
canvas.drawText(xLabel[i],
startPointX,
startPointY + yLength + bounds.height() + yScale / 15,
mScaleValuePaint);
} else {
canvas.drawText(xLabel[i],
startPointX + i * xScale - bounds.width() / 2,
startPointY + yLength + bounds.height() + yScale / 15,
mScaleValuePaint);
}
}
}
/**
* 繪制x軸和y刻度值
* @param canvas
*/
private void drawXAxisAndYScaleValue(Canvas canvas) {
for (int i = 0; i < 6; i++) {
if (i < 5) {
mScaleValuePaint.getTextBounds(yLabel[4 - i], 0, yLabel[4 - i].length(), bounds);
canvas.drawText(yLabel[4 - i],
startPointX + xScale / 15,
startPointY + yScale * (i + 0.5f) + bounds.height() / 2,
mScaleValuePaint);
canvas.drawLine(startPointX + bounds.width() + 2 * xScale / 15,
startPointY + (i + 0.5f) * yScale,
startPointX + xLength,
startPointY + (i + 0.5f) * yScale,
mScaleLinePaint);
} else {
canvas.drawLine(startPointX,
startPointY + (i + 0.5f) * yScale,
startPointX + xLength,
startPointY + (i + 0.5f) * yScale,
mScaleLinePaint);
}
}
}
/**
* 繪制數(shù)據(jù)線條
* @param canvas
*/
private void drawDataLines(Canvas canvas) {
getDataRoords();
for (int i = 0; i < 6; i++) {
mDataLinePaint.setColor(Color.parseColor(mDataLineColors[i]));
canvas.drawLine(mDataCoords[i][0], mDataCoords[i][1], mDataCoords[i + 1][0], mDataCoords[i + 1][1], mDataLinePaint);
}
}
/**
* 繪制數(shù)據(jù)點(diǎn)
* @param canvas
*/
private void drawDataPoints(Canvas canvas) {
// 點(diǎn)擊后,繪制數(shù)據(jù)點(diǎn)
if (isClick && clickIndex > -1) {
mDataLinePaint.setColor(Color.parseColor(mDataLineColors[clickIndex]));
canvas.drawCircle(mDataCoords[clickIndex][0], mDataCoords[clickIndex][1], xScale / 10, mDataLinePaint);
mDataLinePaint.setColor(Color.WHITE);
canvas.drawCircle(mDataCoords[clickIndex][0], mDataCoords[clickIndex][1], xScale / 20, mDataLinePaint);
mDataLinePaint.setColor(Color.parseColor(mDataLineColors[clickIndex]));
}
}
/**
* 繪制標(biāo)題
* @param canvas
*/
private void drawTitle(Canvas canvas) {
// 繪制標(biāo)題文本和線條
mDataLinePaint.getTextBounds(title, 0, title.length(), bounds);
canvas.drawText(title, (getWidth() - bounds.width()) / 2, startPointY + yLength + yScale, mDataLinePaint);
canvas.drawLine((getWidth() - bounds.width()) / 2 - xScale / 15,
startPointY + yLength + yScale - bounds.height() / 2 + coordTextSize / 4,
(getWidth() - bounds.width()) / 2 - xScale / 2,
startPointY + yLength + yScale - bounds.height() / 2 + coordTextSize / 4,
mDataLinePaint);
}
/**
* 獲取數(shù)據(jù)值的坐標(biāo)點(diǎn)
*
* @return 數(shù)據(jù)點(diǎn)的坐標(biāo)
*/
private void getDataRoords() {
float originalPointX = startPointX;
float originalPointY = startPointY + yLength - yScale;
for (int i = 0; i < data.length; i++) {
mDataCoords[i][0] = originalPointX + i * xScale;
float dataY = Float.parseFloat(data[i]);
float oriY = Float.parseFloat(yLabel[0]);
mDataCoords[i][1] = originalPointY - (yScale * (dataY - oriY) / (Float.parseFloat(yLabel[1]) - Float.parseFloat(yLabel[0])));
}
}
getDataRoords()是為了根據(jù)數(shù)據(jù)點(diǎn)的值及坐標(biāo)刻度的的比例關(guān)系計(jì)算出數(shù)據(jù)點(diǎn)的坐標(biāo),數(shù)據(jù)點(diǎn)(小圓圈)是點(diǎn)擊后重繪才顯示。
點(diǎn)擊數(shù)據(jù)點(diǎn)后,詳細(xì)的數(shù)據(jù)信息采用PopupWindow來展示。點(diǎn)擊事件就直接在onTouchEnvent中來處理:
@Override
public boolean onTouchEvent(MotionEvent event) {
float touchX = event.getX();
float touchY = event.getY();
for (int i = 0; i < 7; i++) {
float dataX = mDataCoords[i][0];
float dataY = mDataCoords[i][1];
// 控制觸摸/點(diǎn)擊的范圍,在有效范圍內(nèi)才觸發(fā)
if (Math.abs(touchX - dataX) < xScale / 2 && Math.abs(touchY - dataY) < yScale / 2) {
isClick = true;
clickIndex = i;
invalidate(); // 重繪展示數(shù)據(jù)點(diǎn)小圓圈
showDetails(i); // 通過PopupWindow展示詳細(xì)數(shù)據(jù)信息
return true;
} else {
hideDetails();
}
clickIndex = -1;
invalidate();
}
return super.onTouchEvent(event);
}```
遍歷數(shù)據(jù)點(diǎn),根據(jù)觸摸的位置判斷是否在數(shù)據(jù)點(diǎn)的有效范圍內(nèi),在有效范圍內(nèi)則通過`showDetails(i)`彈出彈窗,展示詳細(xì)的百分比信息。
private void showDetails(int index) {
if (mPopWin != null) mPopWin.dismiss();
TextView tv = new TextView(getContext());
tv.setTextColor(Color.WHITE);
tv.setBackgroundResource(R.drawable.shape_pop_bg);
GradientDrawable myGrad = (GradientDrawable) tv.getBackground();
myGrad.setColor(Color.parseColor(mDataLineColors[index]));
tv.setPadding(20, 0, 20, 0);
tv.setGravity(Gravity.CENTER);
tv.setText(data[index] + "%");
mPopWin = new PopupWindow(tv, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
mPopWin.setBackgroundDrawable(new ColorDrawable(0));
mPopWin.setFocusable(false);
// 根據(jù)坐標(biāo)點(diǎn)的位置計(jì)算彈窗的展示位置
int xoff = (int) (mDataCoords[index][0] - 0.5f * xScale);
int yoff = -(int) (getHeight() - mDataCoords[index][1] + 0.75f * yScale);
mPopWin.showAsDropDown(this, xoff, yoff);
mPopWin.update();
}
private void hideDetails() {
if (mPopWin != null) mPopWin.dismiss();
}
需要注意的是:根據(jù)需要展示的數(shù)據(jù)點(diǎn)的`index`以及數(shù)據(jù)點(diǎn)的坐標(biāo),計(jì)算出展示彈窗的位置`xoff `和`xoff `,彈窗的展示位置是從控件的最左下角為原點(diǎn)算的偏移量。
#####配置添加數(shù)據(jù)的方法,供外部調(diào)用
/**
- 設(shè)置x軸刻度值
- @param xLabel x刻度值
*/
public void setxLabel(String[] xLabel) {
this.xLabel = xLabel;
}
/**
- 設(shè)置數(shù)據(jù)
- @param data 數(shù)據(jù)值
*/
public void setData(String[] data) {
this.data = data;
}
/**
- 設(shè)置標(biāo)題
- @param title 標(biāo)題
*/
public void setTitle(String title) {
this.title = title;
}
/**
- 重新設(shè)置x軸刻度、數(shù)據(jù)、標(biāo)題后必須刷新重繪
*/
public void fresh() {
init();
postInvalidate();
}
當(dāng)然,也可以添加自定義屬性,將數(shù)據(jù)、坐標(biāo)刻度值在布局文件中來配置,這里就不添加了。
#####使用
private void setData() {
String title = "7日年化收益率(%)";
String[] xLabel1 = {"12-11", "12-12", "12-13", "12-14", "12-15", "12-16", "12-17"};
String[] xLabel2 = {"2-13", "2-14", "2-15", "2-16", "2-17", "2-18", "2-19"};
String[] data1 = {"2.92", "2.99", "3.20", "2.98", "2.92", "2.94", "2.90"};
String[] data2 = {"2.50", "2.50", "2.50", "2.50", "2.50", "2.50", "2.50"};
mChartView1.setTitle(title);
mChartView1.setxLabel(xLabel1);
mChartView1.setData(data1);
mChartView1.fresh();
mChartView2.setTitle(title);
mChartView2.setxLabel(xLabel2);
mChartView2.setData(data2);
mChartView2.fresh();
}


***
源碼:https://github.com/xiaoyanger0825/ChartView