寫在前面
由於項目需要,下圖的圖表控件,搜索了各種開源庫,沒有合適的,只能自定義了。這是我第一次做的自定義控件。寫的很渣,請多指教。
拆分
該控件可以拆分幾個部分進行繪製
1. 繪製5條水平分割線
2. 繪製底部橫座標
3. 繪製貝塞爾曲線
4. 繪製圓角矩形標註
5. 繪製垂直線和底部三角形
繪製5條水平分割線
/**
*畫5條分割線 */
private void drawHorizonLine(Canvas canvas){
mLineTextPaint.setColor(0x66cccccc);
mLineTextPaint.setTextSize(DensityUtil.dip2px(mContext,12f));
float intervalY = (getMeasuredHeight()-mMarginTopBottom*2)/4;//每條線段的間隔
for (int i=0; i<5; i++){
canvas.drawLine(
mMarginLeftRight,
getMeasuredHeight()-mMarginTopBottom-intervalY*i,
getMeasuredWidth()-mMarginLeftRight,
getMeasuredHeight()-mMarginTopBottom-intervalY*i,
mLineTextPaint);
}
}
繪製底部橫座標
/**
*畫橫座標 */
private void drawXLabel(Canvas canvas){
float xNameintervalX = (mWidth - 2f * mMarginLeftRight) / (mXNameListShow.size()-1);//橫座標的間隔
mLineTextPaint.setTextSize(DensityUtil.dip2px(mContext,12f));
for (int i=0; i<mXNameListShow.size(); i++){
canvas.drawText(mXNameListShow.get(i),
mMarginLeftRight + xNameintervalX*(i)-DensityUtil.dip2px(mContext,12f),
getMeasuredHeight() - DensityUtil.dip2px(mContext,10f),
mLineTextPaint);
canvas.save();
}
}
mXNameListShow是橫座標的集合,默認10個以下的數據,通過外部獲取
/**
* 設置底部時間數據*/
public void setxNameDataList(List<String> xNameDataList){
this.mXNameList = xNameDataList;
setxNameListShow();
postInvalidateDelayed(50);
}
因爲從外部傳來的數據有可能會很多記錄幾百幾千個,因此抽樣出其中10個以下的數據
/**
* 設置顯示的時間
* 只顯示10個時間點*/
private void setxNameListShow(){
int interval = mXNameList.size()/10+1;//只顯示10個橫座標,的間隔
for (int i=0; i<mXNameList.size(); i = i+interval){
mXNameListShow.add(mXNameList.get(i));
}
}
01:00, 02:00, 03:00, 04:00, 05:00, 06:00, 07:00, 08:00, 09:00, 10:00
繪製貝塞爾曲線
繪製曲線之前,需要對貝塞爾曲線有最基本的瞭解。我瞭解的比較淺顯,根據起點,終點和控制點就能繪製出一條(一段)曲線,然後將每一段拼接成一條長長的貝塞爾曲線,通常情況下,只需要3-5點的貝塞爾線拼接成的貝塞爾曲線就很好看了。由於座標數量很多,所以需要從中抽樣出幾個點,繪製幾段貝賽爾曲線拼接起來。
我認爲這難點在於求沒一段曲線的控制點。繪製二價貝塞爾曲線需要一個控制點,繪製三價曲線需要兩個控制點。
怎麼求控制點可以參考這裏
然後把上面公式轉換成代碼就成了這樣
這個方法用來獲取每一段貝塞爾曲線所需要的點數據,包括起點,終點,控制點1,控制點2
pointList爲安卓座標系中的點集合,原始數據是
(1,1)
(2,10)
(3,6)
(4,8)
.
.
.
.
/**獲取每一段曲線所需要的點集*/
private List<BezierLineData> getLineData(List<PointF> pointList){
float t = 0.5f;//比例
List<BezierLineData> lineDataList = new ArrayList<>();
PointF startP;
PointF endP;
PointF cp1;
PointF cp2;
BezierLineData lineData;
for (int i = 0; i<pointList.size() - 1;i ++){
startP = pointList.get(i);
endP = pointList.get(i+1);
cp1 = new PointF();
cp1.x = startP.x + (endP.x-startP.x) * t;
cp1.y = startP.y;
cp2 = new PointF();
cp2.x = startP.x + (endP.x-startP.x) * (1 - t);
cp2.y = endP.y;
lineData = new BezierLineData(startP,endP,cp1,cp2);
lineDataList.add(lineData);
}
return lineDataList;
}
BezierLineData定義如下
/**
* Created by allever on 17-9-28.
*
* 每一段曲線用到的數據點集
*/
public class BezierLineData {
private PointF startP;
private PointF endP;
private PointF cp1;
private PointF cp2;
public BezierLineData(PointF startP,PointF endP,PointF cp1,PointF cp2){
this.startP = startP;
this.endP = endP;
this.cp1 = cp1;
this.cp2 = cp2;
}
public PointF getStartP() {
return startP;
}
public void setStartP(PointF startP) {
this.startP = startP;
}
public PointF getEndP() {
return endP;
}
public void setEndP(PointF endP) {
this.endP = endP;
}
public PointF getCp1() {
return cp1;
}
public void setCp1(PointF cp1) {
this.cp1 = cp1;
}
public PointF getCp2() {
return cp2;
}
public void setCp2(PointF cp2) {
this.cp2 = cp2;
}
}
繪製貝塞爾曲線的方法
private void drawBezier2(Canvas canvas){
//繪製前先獲取,保存一些數據,原始點(y值),每條曲線每一段的數據點集,繪製標註時用到.
initData();
mBezierPaint.setStyle(Paint.Style.STROKE);
mBezierPaint.setStrokeWidth(DensityUtil.dip2px(mContext,3f));//設置線寬
mBezierPaint.setAntiAlias(true);//去除鋸齒
mBezierPaint.setStrokeJoin(Paint.Join.ROUND);
mBezierPaint.setStrokeCap(Paint.Cap.ROUND);
for (int i=0;i<mLineDataSetList.size();i++){
Path bezierPath = new Path();//曲線路徑
bezierPath.moveTo(mBezierLineDataList.get(i).get(0).getStartP().x,mBezierLineDataList.get(i).get(0).getStartP().y);
for (int j=0; j<mBezierLineDataList.get(i).size();j++){
bezierPath.cubicTo(
mBezierLineDataList.get(i).get(j).getCp1().x, mBezierLineDataList.get(i).get(j).getCp1().y,
mBezierLineDataList.get(i).get(j).getCp2().x, mBezierLineDataList.get(i).get(j).getCp2().y,
mBezierLineDataList.get(i).get(j).getEndP().x, mBezierLineDataList.get(i).get(j).getEndP().y);
}
//設置顏色和漸變
int lineColor = mLineDataSetList.get(i).getColor();
mBezierPaint.setColor(lineColor);
LinearGradient mLinearGradient;
int[] colorArr;
if (mLineDataSetList.get(i).getGradientColors() != null){
colorArr = mLineDataSetList.get(i).getGradientColors();
}else {
colorArr = new int[]{lineColor,lineColor,lineColor,lineColor,lineColor};
}
mLinearGradient = new LinearGradient(
0,
mMarginTopBottom,
0,
getMeasuredHeight(),
colorArr,
null,
Shader.TileMode.CLAMP
);
mBezierPaint.setShader(mLinearGradient);
canvas.drawPath(bezierPath,mBezierPaint);
canvas.save();
}
}
繪製前先獲取,保存一些數據,原始點(y值),每條曲線每一段的數據點集,繪製標註時用到.
initData();方法如下
private void initData(){
List<PointF> aOriginPointList;
//List<PointF> aAndroidPointList;
//List<BezierLineData> aLineDataList;
//List<PointF> aSelectedOriginPointList;
mBezierLineDataList.clear();
mOriginPointList.clear();
//mAndroidPointList.clear();
for (LineDataSet lineDataSet: mLineDataSetList){
//每一次遍歷就是一條曲線數據
aOriginPointList = lineDataSet.getOldPointFsList();
if (aOriginPointList.size() == 0) continue;
mOriginPointList.add(aOriginPointList);
//lineDataSet.getOldPointFsList()獲取原始座標
//getSelectedPoint();獲取篩選後的數據
//changePoint();將原始點轉化爲Android的座標點
//getLineData();獲取貝塞爾曲線的點集
mBezierLineDataList.add(
getLineData(
changePoint(
getSelectedPoint(
lineDataSet.getOldPointFsList()))));
}
}
getSelectedPoint(List pointFList);
/**
* 從全部數據中選中其中指定個數據*/
private List<PointF> getSelectedPoint(List<PointF> pointFList){
PointF pointF;
PointF selectedPoint;
float ySum = 0;
float averageY;
int interval = pointFList.size()/mBezierPointCount+1;
List<PointF> selectedPointList = new ArrayList<>();
if (pointFList.size()==0 ) return selectedPointList;
int j=0;
for (int i=0; i<pointFList.size();i++){
pointF = pointFList.get(i);
ySum += pointF.y;
if (i%interval==0){
j++;
averageY = ySum/interval;
//selectedPoint = new PointF(j, averageY);//求平均
selectedPoint = new PointF(j, pointF.y);//不求平均
selectedPointList.add(selectedPoint);
ySum = 0;
}
}
Log.d(TAG, "getSelectedPoint: selected count = " + selectedPointList.size());
//暫時辦法-解決不夠n個點
if (selectedPointList.size() < mBezierPointCount){
int curPosition;
for (curPosition= selectedPointList.size();curPosition<mBezierPointCount; curPosition++){
selectedPointList.add(new PointF(curPosition+1,selectedPointList.get(selectedPointList.size()-1).y));
}
}
Log.d(TAG, "getSelectedPoint: after selected count = " + selectedPointList.size());
return selectedPointList;
}
changePoint()
/**
* 把一般座標轉爲 Android中的視圖座標**/
private List<PointF> changePoint(List<PointF> oldPointFs){
List<PointF> pointFs = new ArrayList<>();
float maxValueY = 0;
float yValue;
for (int i = 0; i < oldPointFs.size(); i++){
yValue = oldPointFs.get(i).y;
if (maxValueY < yValue) maxValueY = yValue+ (yValue*0.1f);//
}
Log.d(TAG, "changePoint: maxValueY = " + maxValueY);
//間隔,減去某個值是爲了空出多餘空間,爲了畫線以外,還要寫座標軸的值,除以座標軸最大值
//相當於縮小圖像
int blockCount = oldPointFs.size() - 1;
float intervalX = (getMeasuredWidth() - mMarginLeftRight * 2f)/blockCount;
float intervalY = (getMeasuredHeight() - mMarginTopBottom * 2f)/maxValueY-0f;
int height = getMeasuredHeight();
PointF p;
float x;
float y;
for (int i = 0; i< oldPointFs.size(); i++){
PointF pointF = oldPointFs.get(i);
//最後的正負值是左移右移
x = (pointF.x-1) * intervalX + mMarginLeftRight;
y = height - mMarginTopBottom - intervalY*pointF.y - DensityUtil.dip2px(mContext,5f);
p = new PointF(x, y);
pointFs.add(p);
}
return pointFs;
}
繪製曲線的核心代碼
繪製曲線的方法是,初始化一個路徑對象,設置起點,繪製路徑
path.moveTo(startPx, startPy);
path.cubicTo(cp1x, cp1y, cp2x, cp2y, endPx, endPy);
for (int i=0;i<mLineDataSetList.size();i++){//繪製n條曲線
Path bezierPath = new Path();//曲線路徑
bezierPath.moveTo(mBezierLineDataList.get(i).get(0).getStartP().x,mBezierLineDataList.get(i).get(0).getStartP().y);//移動到起點
//循環繪製路徑
for (int j=0; j<mBezierLineDataList.get(i).size();j++){//
bezierPath.cubicTo(
mBezierLineDataList.get(i).get(j).getCp1().x, mBezierLineDataList.get(i).get(j).getCp1().y,
mBezierLineDataList.get(i).get(j).getCp2().x, mBezierLineDataList.get(i).get(j).getCp2().y,
mBezierLineDataList.get(i).get(j).getEndP().x, mBezierLineDataList.get(i).get(j).getEndP().y);
}
//設置顏色和漸變
int lineColor = mLineDataSetList.get(i).getColor();
mBezierPaint.setColor(lineColor);
LinearGradient mLinearGradient;
int[] colorArr;
if (mLineDataSetList.get(i).getGradientColors() != null){
colorArr = mLineDataSetList.get(i).getGradientColors();
}else {
colorArr = new int[]{lineColor,lineColor,lineColor,lineColor,lineColor};
}
mLinearGradient = new LinearGradient(
0,
mMarginTopBottom,
0,
getMeasuredHeight(),
colorArr,
null,
Shader.TileMode.CLAMP
);
mBezierPaint.setShader(mLinearGradient);
canvas.drawPath(bezierPath,mBezierPaint);
canvas.save();
}
曲線參數對象
/**
* Created by allever on 17-8-10.
* 每一條線對應一個對象
*/
public class LineDataSet {
private int color;//顏色,
private int[] gradientColors;//漸變色數組
private List<PointF> oldPointFsList;//原始點
private SportAnalysisType sportAnalysisType;//參數類型,項目中用到,實際上該字段無用
public int getColor() {
return color;
}
public void setColor(int color) {
this.color = color;
}
public int[] getGradientColors() {
return gradientColors;
}
public void setGradientColors(int[] gradientColors) {
this.gradientColors = gradientColors;
}
public List<PointF> getOldPointFsList() {
return oldPointFsList;
}
public void setOldPointFsList(List<PointF> oldPointFsList) {
this.oldPointFsList = oldPointFsList;
}
public SportAnalysisType getSportAnalysisType() {
return sportAnalysisType;
}
public void setSportAnalysisType(SportAnalysisType sportAnalysisType) {
this.sportAnalysisType = sportAnalysisType;
}
}
繪製圓角矩形標註
難點在於,求曲線上的y座標(Android座標系)
可以根據公式來求
/**
* B(t) = P0 * (1-t)^3 + 3 * P1 * t * (1-t)^2 + 3 * P2 * t^2 * (1-t) + P3 * t^3, t ∈ [0,1]
*
* @param t 曲線長度比例
* @param p0 起始點
* @param p1 控制點1
* @param p2 控制點2
* @param p3 終止點
* @return t對應的點
*/
public static PointF calculateBezierPointForCubic(float t, PointF p0, PointF p1, PointF p2, PointF p3) {
PointF point = new PointF();
float temp = 1 - t;
point.x = p0.x * temp * temp * temp + 3 * p1.x * t * temp * temp + 3 * p2.x * t * t * temp + p3.x * t * t * t;
point.y = p0.y * temp * temp * temp + 3 * p1.y * t * temp * temp + 3 * p2.y * t * t * temp + p3.y * t * t * t;
return point;
}
/**
* 繪製標註*/
private void drawMark2(Canvas canvas){
if (mDownX == -1) return;
if (mDownX < mMarginLeftRight || mDownX > mWidth-mMarginLeftRight ) return;
List<BezierLineData> lineDataList;
BezierLineData lineData;
float t;//點在曲線上的長度比例
float intevalBezierX = (mWidth-2*mMarginLeftRight)/((float)(mBezierPointCount-1));
PointF linePoint;//曲線上的座標點
for(int i=0; i<mBezierLineDataList.size();i++){//曲線數量
//設置該條曲線的顏色和漸變,畫筆
initLineStyle(i);
//獲取該條曲線每段曲線的數據集合
lineDataList = mBezierLineDataList.get(i);
//判斷觸控點在哪一段曲線上
int bezierLinePosition = -1;//曲線段數索引
for (int n = 0;n<lineDataList.size();n++){
if ((mDownX > intevalBezierX*n + mMarginLeftRight) && ((mDownX < intevalBezierX*(n+1)+mMarginLeftRight))){
bezierLinePosition = n;
break;
}
}
if (bezierLinePosition == -1 ) return;
//根據段數獲取該段曲線的數據點集合(起點,終點,控制點)
lineData = lineDataList.get(bezierLinePosition);
//求觸控點在該段曲線上的長度比例
t = (mDownX-lineData.getStartP().x)/intevalBezierX;
linePoint = BezierUtil.calculateBezierPointForCubic(t,lineData.getStartP(),lineData.getCp1(),lineData.getCp2(),lineData.getEndP());
//求Marker上顯示的數值
float value =0;
//根據觸控點所在區間求y值(真實數據)
int position = -1;//觸摸點所在區間
float intervalDataX = (mWidth-2*mMarginLeftRight)/((float)(mOriginPointList.get(i).size()));
for (int m=0;m<mOriginPointList.get(i).size();m++){
if ((mDownX > (intervalDataX*m+mMarginLeftRight) ) && (mDownX < (intervalDataX*(m+1)+mMarginLeftRight))){
position = m;
break;
}
}
if (position != -1 )value = mOriginPointList.get(i).get(position).y;
value = (float)(Math.round(value*1000))/1000;
String drawText = value + " " + mLineDataSetList.get(i).getSportAnalysisType().getUnit();
//Marker的寬高
float dataBoxWidth = DensityUtil.dip2px(mContext,drawText.length()*7.7f);//標註邊框寬度//根據文字長度動態變化
float dataBoxHeight = DensityUtil.dip2px(mContext,30);
if (i%2==0){//根據曲線索引判斷在左邊還是在右邊繪製
//處理邊界,超出邊界時在另一邊繪製
if (mWidth-mMarginLeftRight-mDownX < dataBoxWidth/2){
//左邊繪製
drawLeft(canvas,linePoint,drawText,dataBoxWidth,dataBoxHeight);
}else {
//右邊繪製
drawRight(canvas,linePoint,drawText,dataBoxWidth,dataBoxHeight);
}
}else {
if (mDownX - mMarginLeftRight < dataBoxWidth/2){
//右邊繪製
drawRight(canvas,linePoint,drawText,dataBoxWidth,dataBoxHeight);
}else {
//左邊繪製
drawLeft(canvas,linePoint,drawText,dataBoxWidth,dataBoxHeight);
}
}
}
}
drawLeft()和drawRight()
private void drawRight(Canvas canvas, PointF linePoint, String drawText,float dataBoxWidth,float dataBoxHeight){
canvas.drawRoundRect(
new RectF(
mDownX + DensityUtil.dip2px(mContext,3f),
linePoint.y + DensityUtil.dip2px(mContext,5f),
mDownX + dataBoxWidth + DensityUtil.dip2px(mContext,3f),
linePoint.y + dataBoxHeight),
DensityUtil.dip2px(mContext,8f),
DensityUtil.dip2px(mContext,8f),
mRectPaint);
canvas.drawText(drawText,0,
drawText.length(),
mDownX +DensityUtil.dip2px(mContext,8f),
linePoint.y+DensityUtil.dip2px(mContext,21f),
mTextPaint);
}
private void drawLeft(Canvas canvas, PointF linePoint, String drawText,float dataBoxWidth,float dataBoxHeight){
canvas.drawRoundRect(
new RectF(
mDownX - dataBoxWidth - DensityUtil.dip2px(mContext,3f),
linePoint.y + DensityUtil.dip2px(mContext,5f),
mDownX - DensityUtil.dip2px(mContext,3f),
linePoint.y + dataBoxHeight),
DensityUtil.dip2px(mContext,8f),
DensityUtil.dip2px(mContext,8f),
mRectPaint);
canvas.drawText(drawText,0,
drawText.length(),
mDownX - dataBoxWidth + DensityUtil.dip2px(mContext,3f),
linePoint.y+DensityUtil.dip2px(mContext,21f),
mTextPaint);
}
繪製垂直線和底部三角形
/**
* 繪製標線及底部三角形*/
private void drawMarkLine2(Canvas canvas){
if (mDownX == -1) return;
if (mDownX < mMarginLeftRight || mDownX > mWidth-mMarginLeftRight ) return;
mVerticalPaint.setColor(Color.WHITE);
mVerticalPaint.setStrokeWidth(2f);
Path trianglePath = new Path();
canvas.drawLine(
mDownX,
mMarginTopBottom,
mDownX,
mHeight - mMarginTopBottom,
mVerticalPaint);
trianglePath.moveTo(mDownX, mHeight - mMarginTopBottom - 20f);
trianglePath.lineTo(mDownX - 20f, mHeight - mMarginTopBottom);
trianglePath.lineTo(mDownX + 20f, mHeight - mMarginTopBottom);
trianglePath.close();
canvas.drawPath(trianglePath,mVerticalPaint);
mDownX = -1;
}
處理滑動顯示
大概思路就是監聽到移動事件時,記錄當前按下x座標值,然後重繪製
@Override
public boolean onTouchEvent(MotionEvent event) {
float downX = event.getX();
switch (event.getAction()){
case MotionEvent.ACTION_DOWN:
mDownX = downX;
//postInvalidateDelayed(50);
Log.d(TAG, "onTouchEvent: ACTION_DOWN mDownX = " + mDownX);
invalidate();
break;
case MotionEvent.ACTION_MOVE:
Log.d(TAG, "onTouchEvent: ACTION_MOVE mDownX = " + mDownX);
mDownX = event.getX();
invalidate();
//postInvalidateDelayed(50);
break;
case MotionEvent.ACTION_UP:
break;
}
//postInvalidateDelayed(50);
return true;
}