关于开源图表hellocharts-android的一些使用心得
转载请注明出处:http://blog.csdn.net/binbinqq86/article/details/71698336
项目GitHub地址:https://github.com/lecho/hellocharts-android(ps:本文不适合对hellocharts不了解的人)
关于hellocharts-android简单的使用,作者在GitHub上介绍的很详细,而且网上大把大把的文章介绍其基本使用方式,所以我们在这里就不再说这些简单使用了,今天主要就是记录一下自己使用过程中遇到的一些坑,当然这些问题在GitHub的Issues里面也有很多人提问,但回答却不太让人满意,网上也找不到相关答案,所以只能自己去分析源码来寻求答案了。。。
上图是我们看到的小米天气,对比之下,hellocharts的主要问题:
- 当列表内容很多的时候,怎么进行滚动(默认出来是显示所有数据,很拥挤)
- 当数据跳跃过大的时候,部分曲线的绘制被遮挡不能完全显示
- 有些坐标轴标签显示不出来,当数据过多的时候,只有放大后才能显示
- 同上的问题出现在坐标轴上垂直的分割线,不能显示完全
- 分割线怎么去自定义效果
下面我门就来看看怎么解决这些问题。关于滑动的,其实它内置的就已经写好了滑动逻辑,比如在放大缩小的时候,上下左右滑动很流畅,我们肯定畅想调用一下或者设置一下某个方法就可以实现我们想要的功能了,但是找来找去,始终找不到想要的那个方法或属性,怎么设置都没有用。最后通过源码我发现其实这一切滑动都是跟ViewPort有关系,我们来看下AbstractChartView里面的touch事件:
@Override
public boolean onTouchEvent(MotionEvent event) {
super.onTouchEvent(event);
if (isInteractive) {
boolean needInvalidate;
if (isContainerScrollEnabled) {
needInvalidate = touchHandler.handleTouchEvent(event, getParent(), containerScrollType);
} else {
needInvalidate = touchHandler.handleTouchEvent(event);
}
if (needInvalidate) {
ViewCompat.postInvalidateOnAnimation(this);
}
return true;
} else {
return false;
}
}
可以看到,所有的触摸事件都交给了一个touchHandler的类来处理,跟进去看:
/**
* Handle chart touch event(gestures, clicks). Return true if gesture was handled and chart needs to be
* invalidated.
*/
public boolean handleTouchEvent(MotionEvent event) {
boolean needInvalidate = false;
// TODO: detectors always return true, use class member needInvalidate instead local variable as workaround.
// This flag should be computed inside gesture listeners methods to avoid invalidation.
needInvalidate = gestureDetector.onTouchEvent(event);
needInvalidate = scaleGestureDetector.onTouchEvent(event) || needInvalidate;
if (isZoomEnabled && scaleGestureDetector.isInProgress()) {
// Special case: if view is inside scroll container and user is scaling disable touch interception by
// parent.
disallowParentInterceptTouchEvent();
}
if (isValueTouchEnabled) {
needInvalidate = computeTouch(event) || needInvalidate;
}
return needInvalidate;
}
这里把手势都交给了GestureDetector和ScaleGestureDetector这两个类,我们应该很熟悉,专门用来处理手势事件的两个类:
protected class ChartScaleGestureListener extends ScaleGestureDetector.SimpleOnScaleGestureListener {
@Override
public boolean onScale(ScaleGestureDetector detector) {
if (isZoomEnabled) {
float scale = 2.0f - detector.getScaleFactor();
if (Float.isInfinite(scale)) {
scale = 1;
}
return chartZoomer.scale(computator, detector.getFocusX(), detector.getFocusY(), scale);
}
return false;
}
}
protected class ChartGestureListener extends GestureDetector.SimpleOnGestureListener {
protected ScrollResult scrollResult = new ScrollResult();
@Override
public boolean onDown(MotionEvent e) {
if (isScrollEnabled) {
disallowParentInterceptTouchEvent();
return chartScroller.startScroll(computator);
}
return false;
}
@Override
public boolean onDoubleTap(MotionEvent e) {
if (isZoomEnabled) {
return chartZoomer.startZoom(e, computator);
}
return false;
}
@Override
public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
if (isScrollEnabled) {
boolean canScroll = chartScroller
.scroll(computator, distanceX, distanceY, scrollResult);
allowParentInterceptTouchEvent(scrollResult);
return canScroll;
}
return false;
}
@Override
public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
if (isScrollEnabled) {
return chartScroller.fling((int) -velocityX, (int) -velocityY, computator);
}
return false;
}
}
里面我们发现跟ChartScroller和ChartZoomer两个类相关,具体的滑动和缩放操作计算都封装在这里了。这里我们先不管缩放,我们先看ChartScroller:
public boolean scroll(ChartComputator computator, float distanceX, float distanceY, ScrollResult scrollResult) {
// Scrolling uses math based on the viewport (as opposed to math using pixels). Pixel offset is the offset in
// screen pixels, while viewport offset is the offset within the current viewport. For additional
// information on
// surface sizes and pixel offsets, see the docs for {@link computeScrollSurfaceSize()}. For additional
// information about the viewport, see the comments for {@link mCurrentViewport}.
final Viewport maxViewport = computator.getMaximumViewport();
final Viewport visibleViewport = computator.getVisibleViewport();
final Viewport currentViewport = computator.getCurrentViewport();
final Rect contentRect = computator.getContentRectMinusAllMargins();
final boolean canScrollLeft = currentViewport.left > maxViewport.left;
final boolean canScrollRight = currentViewport.right < maxViewport.right;
final boolean canScrollTop = currentViewport.top < maxViewport.top;
final boolean canScrollBottom = currentViewport.bottom > maxViewport.bottom;
boolean canScrollX = false;
boolean canScrollY = false;
if (canScrollLeft && distanceX <= 0) {
canScrollX = true;
} else if (canScrollRight && distanceX >= 0) {
canScrollX = true;
}
if (canScrollTop && distanceY <= 0) {
canScrollY = true;
} else if (canScrollBottom && distanceY >= 0) {
canScrollY = true;
}
if (canScrollX || canScrollY) {
computator.computeScrollSurfaceSize(surfaceSizeBuffer);
float viewportOffsetX = distanceX * visibleViewport.width() / contentRect.width();
float viewportOffsetY = -distanceY * visibleViewport.height() / contentRect.height();
computator
.setViewportTopLeft(currentViewport.left + viewportOffsetX, currentViewport.top + viewportOffsetY);
}
scrollResult.canScrollX = canScrollX;
scrollResult.canScrollY = canScrollY;
return canScrollX || canScrollY;
}
这里就是滑动的主要逻辑了,首先就是计算能否滑动,和滑动的距离,最后看41行,跟进去:
/**
* Checks if new viewport doesn't exceed max available viewport.
*/
public void constrainViewport(float left, float top, float right, float bottom) {
if (right - left < minViewportWidth) {
// Minimum width - constrain horizontal zoom!
right = left + minViewportWidth;
if (left < maxViewport.left) {
left = maxViewport.left;
right = left + minViewportWidth;
} else if (right > maxViewport.right) {
right = maxViewport.right;
left = right - minViewportWidth;
}
}
if (top - bottom < minViewportHeight) {
// Minimum height - constrain vertical zoom!
bottom = top - minViewportHeight;
if (top > maxViewport.top) {
top = maxViewport.top;
bottom = top - minViewportHeight;
} else if (bottom < maxViewport.bottom) {
bottom = maxViewport.bottom;
top = bottom + minViewportHeight;
}
}
currentViewport.left = Math.max(maxViewport.left, left);
currentViewport.top = Math.min(maxViewport.top, top);
currentViewport.right = Math.min(maxViewport.right, right);
currentViewport.bottom = Math.max(maxViewport.bottom, bottom);
viewportChangeListener.onViewportChanged(currentViewport);
}
/**
* Sets the current viewport (defined by {@link #currentViewport}) to the given X and Y positions.
*/
public void setViewportTopLeft(float left, float top) {
/**
* Constrains within the scroll range. The scroll range is simply the viewport extremes (AXIS_X_MAX,
* etc.) minus
* the viewport size. For example, if the extrema were 0 and 10, and the viewport size was 2, the scroll range
* would be 0 to 8.
*/
final float curWidth = currentViewport.width();
final float curHeight = currentViewport.height();
left = Math.max(maxViewport.left, Math.min(left, maxViewport.right - curWidth));
top = Math.max(maxViewport.bottom + curHeight, Math.min(top, maxViewport.top));
constrainViewport(left, top, left + curWidth, top - curHeight);
}
其实最终就是去设置currentViewport的上下左右边界值,来达到移动的目的,既然如此,我们何不自己去实现这些触摸事件?于是就在Activity中给ChartView加入了触摸监听:
chart.setViewportCalculationEnabled(false);
final Viewport v = new Viewport(chart.getMaximumViewport());
// Log.e(TAG, "onCreate: "+v.left+"#"+v.top+"#"+v.right+"$"+v.bottom );
v.left = 0;
v.right= chartWidth;
v.top=37;
v.bottom=10;
chart.setCurrentViewport(v);
final Rect rect=chart.getChartComputator().getContentRectMinusAllMargins();
chart.setOnTouchListener(new View.OnTouchListener() {
@Override
public boolean onTouch(View view, MotionEvent motionEvent) {
float fx=chart.getChartComputator().computeRawDistanceX(5);
float fy=chart.getChartComputator().computeRawDistanceY(100);
Rect r=chart.getChartComputator().getContentRectMinusAllMargins();
float x=motionEvent.getX()/(r.width()/v.width());
float y=motionEvent.getY()/(r.height()/v.height());
chart.moveTo(x,y);
switch (motionEvent.getAction()){
case MotionEvent.ACTION_DOWN:
mFirstX=motionEvent.getX();
mLastX=motionEvent.getX();
// Log.e(TAG, "onTouch: "+mFirstX );
break;
case MotionEvent.ACTION_MOVE:
float totalDeltaX=motionEvent.getX()-mLastX;
mLastX=motionEvent.getX();
float realX=v.width()*totalDeltaX/rect.width();
Log.e(TAG, "onTouch: "+realX +"#"+totalDeltaX+"$$$"+rect.width()+"$"+v.width()+"###");
Log.e(TAG, "onTouch:before... "+v.left+"#"+v.top+"$"+v.right+"$"+v.bottom );
Viewport vTemp=new Viewport(v);
vTemp.left += -realX;
vTemp.right = vTemp.left+chartWidth;
if(vTemp.left<0){
vTemp.left=0;
vTemp.right=chartWidth;
}
if(vTemp.left>score.length-1-chartWidth){
vTemp.left=score.length-1-chartWidth;
vTemp.right=score.length-1;
}
if(vTemp.right>score.length-1){
vTemp.right=score.length-1;
vTemp.left=score.length-1-chartWidth;
}
if(vTemp.right-vTemp.left!=chartWidth){
break;
}
v.set(vTemp);
chart.setMaximumViewport(v);
chart.setCurrentViewport(v);
Log.e(TAG, "onTouch:after... "+v.left+"#"+v.top+"$"+v.right+"$"+v.bottom );
break;
case MotionEvent.ACTION_UP:
default:
break;
}
return true;
}
});
首先就是设置当前的viewport,里面的left,right,top,bottom就是要显示的你的图表数据集合里面的个数(这里我以x轴为主),设置完这个,就会按照你设定的来显示,而不是所有数据全部显示在坐标轴上。运行一下,看看效果果然不出我们所料,一切都可以动起来了。但是停下来想想,这么麻烦才实现这个功能,不应该啊,评价这么好的开源项目怎么可能不提供这种功能呢,于是乎继续研究,终于看到一个关键性的注释指明了我的道路,我们看Chart这个接口:
/**
* Set maximum viewport. If you set bigger maximum viewport data will be more concentrate and there will be more
* empty spaces on sides. Note. MaxViewport have to be set after chartData has been set.
*/
public void setMaximumViewport(Viewport maxViewport);
/**
* Returns current viewport. Don't modify it directly, use {@link #setCurrentViewport(Viewport)} instead.
*
* @see #setCurrentViewport(Viewport)
*/
public Viewport getCurrentViewport();
/**
* Sets current viewport. Note. viewport have to be set after chartData has been set.
*/
public void setCurrentViewport(Viewport targetViewport);
看里面的注释:Sets current viewport. Note. viewport have to be set after chartData has been set.上面的两个方法都是这样说的,意思就是必须要设置完图表的数据以后,才能去设置当前显示窗口和最大显示窗口,回过来一看,果然,自己写反了(拿来主义惹的祸啊,在网上随便复制别人的demo),其实反过来想一下,currentViewport和maxViewport不就是用来滑动的吗,只是一开始没有数据就去设置这两个东西,所以起不到相应的作用。
chart.setLineChartData(data);
//viewport必须设置在setLineChartData后面,设置一个当前viewport,再设置一个maxviewport,就可以实现滚动,高度要设置数据的上下限
chart.setViewportCalculationEnabled(false);
final Viewport v = new Viewport(chart.getMaximumViewport());
// Log.e(TAG, "onCreate: "+v.left+"#"+v.top+"#"+v.right+"$"+v.bottom );
v.left = 0;
v.right= chartWidth;
v.top=37;
v.bottom=10;
chart.setCurrentViewport(v);
// Log.e(TAG, "onCreate: "+v.left+"#"+v.top+"#"+v.right+"$"+v.bottom );
final Viewport maxV=new Viewport(chart.getMaximumViewport());
maxV.left=0;
maxV.right=score.length-1;
maxV.top=37;
maxV.bottom=10;
chart.setMaximumViewport(maxV);
这样一改,果然出现了神奇的效果,哈哈~里面的left,right,top,bottom其实就是我的数据集合的个数和值的上下限,再加一个chart.setZoomEnabled(false);整个小米天气的滚动效果就实现了啊。。。
好了,第一个问题终于算是解决了,看下第二个问题:
这就是我说的图表数据跳跃过大被遮挡问题。同样来看AbstractChartView:
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
if (isEnabled()) {
axesRenderer.drawInBackground(canvas);
int clipRestoreCount = canvas.save();
canvas.clipRect(chartComputator.getContentRectMinusAllMargins());
chartRenderer.draw(canvas);
canvas.restoreToCount(clipRestoreCount);
chartRenderer.drawUnclipped(canvas);
axesRenderer.drawInForeground(canvas);
} else {
canvas.drawColor(ChartUtils.DEFAULT_COLOR);
}
}
看第八行,明白了吧,这里对画布进行了裁剪,看ChartComputator里面有三个变量:
//contentRectMinusAllMargins <= contentRectMinusAxesMargins <= maxContentRect
protected Rect contentRectMinusAllMargins = new Rect();
protected Rect contentRectMinusAxesMargins = new Rect();
protected Rect maxContentRect = new Rect();
这三个变量控制这图表的绘制区域,第一个是最小的,减去所有的margin值,第二个减去了坐标轴的,第三个是整个控件的宽高,这里我们改成第三个就可以了,或者把裁剪画布注释掉也行,同时我们还需要在上方绘制一个轴线,这样就可以留出空间来显示图表,代码如下:
//坐标轴
Axis axisX = new Axis(); //X轴
// axisX.setHasTiltedLabels(true); //X坐标轴字体是斜的显示还是直的,true是斜的显示
axisX.setTextColor(Color.BLACK); //设置字体颜色
//axisX.setName("date"); //表格名称
axisX.setTextSize(20);//设置字体大小
// axisX.setMaxLabelChars(8); //
axisX.setValues(mAxisXValues); //填充X轴的坐标名称
//data.setAxisXTop(axisX); //x 轴在顶部
axisX.setHasLines(true); //x 轴分割线
axisX.setHasSeparationLine(false);//设置标签跟图表之间的轴线
axisX.setLineColor(Color.YELLOW);
// axisX.generateAxisFromRange(0,0,1);//从已知当中截取
data.setAxisXBottom(axisX); //x 轴在底部
//设置上下两个轴线,为了防止绘制的曲线被遮挡,可以留出空隙==========================================
Axis axisXTop = new Axis(); //X轴
axisXTop.setTextColor(Color.TRANSPARENT); //设置字体颜色
// axisXTop.setTextSize(20);//设置字体大小
// axisXTop.setValues(mAxisXValues); //填充X轴的坐标名称
// axisXTop.setHasLines(true); //x 轴分割线
// axisXTop.setHasSeparationLine(false);//设置标签跟图表之间的轴线
// axisXTop.setLineColor(Color.RED);
data.setAxisXTop(axisXTop); //x 轴在底部
让字体颜色透明就可以给人造成一种没有轴线的感觉,看效果:
可以看出,很完美的解决了这个问题,这个图是解决了后三个问题的效果图,我们来看一下之前的模样:
可以看出当过于密集的时候,分割线和坐标轴标签就显示不全了,并且分割线只能自定义颜色,要修改这些,就需要去动源码了,我们来看AxesRender这个类,它的主要功能就是渲染坐标轴:
private void prepareCustomAxis(Axis axis, int position) {
//代码省略。。。
for (AxisValue axisValue : axis.getValues()) {
// Draw axis values that are within visible viewport.
final float value = axisValue.getValue();
if (value >= viewportMin && value <= viewportMax) {
// Draw axis values that have 0 module value, this will hide some labels if there is no place for them.
if (0 == valueIndex % module) {
if (isAxisVertical) {
rawValue = computator.computeRawY(value);
} else {
rawValue = computator.computeRawX(value);
}
if (checkRawValue(contentRect, rawValue, axis.isInside(), position, isAxisVertical)) {
rawValuesTab[position][valueToDrawIndex] = rawValue;
valuesToDrawTab[position][valueToDrawIndex] = axisValue;
++valueToDrawIndex;
}
}
// If within viewport - increment valueIndex;
++valueIndex;
}
}
valuesToDrawNumTab[position] = valueToDrawIndex;
}
看到第七行的那句注释没,在准备渲染数据的时候就动了手脚了,罪魁祸首就是下面的那句if判断,可能作者也是好意,防止太多坐标轴标签,绘制重叠,其实去掉这句话就可以了,所有的分割线和标签都会被绘制出来,那么分割线怎么去自定义呢?我们看绘制轴线和分割线的函数:
private void drawAxisLines(Canvas canvas, Axis axis, int position) {
//代码省略。。。
// Draw separation line with the same color as axis labels and name.
if (axis.hasSeparationLine()) {
canvas.drawLine(separationX1, separationY1, separationX2, separationY2, labelPaintTab[position]);
}
if (axis.hasLines()) {
int valueToDrawIndex = 0;
for (; valueToDrawIndex < valuesToDrawNumTab[position]; ++valueToDrawIndex) {
if (isAxisVertical) {
lineY1 = lineY2 = rawValuesTab[position][valueToDrawIndex];
} else {
lineX1 = lineX2 = rawValuesTab[position][valueToDrawIndex];
}
linesDrawBufferTab[position][valueToDrawIndex * 4 + 0] = lineX1;
linesDrawBufferTab[position][valueToDrawIndex * 4 + 1] = lineY1;
linesDrawBufferTab[position][valueToDrawIndex * 4 + 2] = lineX2;
linesDrawBufferTab[position][valueToDrawIndex * 4 + 3] = lineY2;
}
Paint paint=linePaintTab[position];
// paint.setPathEffect(new DashPathEffect(new float[]{20, 20}, 0));
// paint.setStrokeWidth(5);
// for (int i = 0; i < valueToDrawIndex; i++) {
// Path path=new Path();
// path.moveTo(linesDrawBufferTab[position][i*4],linesDrawBufferTab[position][i*4+1]);
// path.lineTo(linesDrawBufferTab[position][i*4+2],linesDrawBufferTab[position][i*4+3]);
// canvas.drawPath(path,paint);
// }
canvas.drawLines(linesDrawBufferTab[position], 0, valueToDrawIndex * 4, paint);
}
}
hasSeparationLine就是用来判断坐标轴是否绘制,hasLines就是判断垂直于坐标轴的那些数据分割线是否绘制,看到那些注释的代码没,源码里面没有,这是我自己加上去的,在这里就可以随意定制画笔paint,随意实现我们想要的效果了:
怎么样,还不错吧~至于图表上的点的背景色,需要同时设置这几个参数才会起作用:
//三个同时设置,才能设置标签背景色
data.setValueLabelBackgroundEnabled(true);
data.setValueLabelBackgroundAuto(false);
data.setValueLabelBackgroundColor(Color.GREEN);
最终的运行效果图:(请忽略这虚拟机拙略的画质)
至此,整个hellocharts所遇到的问题都得到了解决,下面给出demo下载地址,有不明白的童鞋也可以在下方留言。