Charts 细节分析及实践

本文以 CombinedChartView 为例分析, 源码

chart 对象创建 及 基本属性设置

1. 先大概认识一下 chart 的创建过程

- (void)updateHistoryChartView {
    _containerScrollView.contentSize = CGSizeMake(self.historys.count * YHPerBarWidth, YHChartHeight);
    _containerScrollView.contentOffset = CGPointMake((self.historys.count-YHVisibleHistorysCount) * YHPerBarWidth, 0);
    _containerScrollView.backgroundColor = [UIColor blackColor];
    CGRect chartRect = CGRectMake(0, 0, _containerScrollView.contentSize.width, _containerScrollView.contentSize.height);
    if (!_stepHistoryChart) {
        _stepHistoryChart = [[CombinedChartView alloc] initWithFrame:chartRect];
        [_stepHistoryChart setScaleEnabled:NO];
        _stepHistoryChart.drawOrder = @[@(CombinedChartDrawOrderLine), @(CombinedChartDrawOrderBar)]; // 绘制层级

        ChartXAxis *xAxis = _stepHistoryChart.xAxis;
        xAxis.labelPosition = XAxisLabelPositionTop;
        xAxis.labelFont = [UIFont systemFontOfSize:12];
        xAxis.drawGridLinesEnabled = NO;
        xAxis.drawAxisLineEnabled = NO;
        xAxis.spaceMax = 0.5; // 坐标轴额外的偏移
        xAxis.spaceMin = xAxis.spaceMax;
        xAxis.enabled = YES;
        xAxis.labelTextColor = [UIColor colorWithWhite:1 alpha:0.6];

        ChartXAxisRenderer *render = _stepHistoryChart.xAxisRenderer;
        YHXAxisRenderer *xAxisRenderer = [[YHXAxisRenderer alloc] initWithViewPortHandler:render.viewPortHandler xAxis:(ChartXAxis *)(render.axis) transformer:render.transformer];
        xAxisRenderer.selectedXLabelFont = [UIFont systemFontOfSize:14];
        xAxisRenderer.selectedXLabelTextColor = [UIColor whiteColor];
        _stepHistoryChart.xAxisRenderer = xAxisRenderer;

        _stepHistoryChart.delegate = self;
        _stepHistoryChart.drawBarShadowEnabled = NO;
        _stepHistoryChart.drawValueAboveBarEnabled = NO;
        _stepHistoryChart.leftAxis.enabled = NO;
        _stepHistoryChart.leftAxis.axisMinimum = 0;
        _stepHistoryChart.rightAxis.enabled = NO;
        _stepHistoryChart.legend.enabled = NO;
        _stepHistoryChart.chartDescription = nil;
        _stepHistoryChart.dragEnabled = NO;
        _stepHistoryChart.extraTopOffset = 5;
        _stepHistoryChart.minOffset = 0;
        [self.containerScrollView addSubview:self.stepHistoryChart];
    }
    _stepHistoryChart.frame = chartRect;
    ChartXAxis *xAxis = _stepHistoryChart.xAxis;
    xAxis.axisMaxLabels = self.historys.count;
    xAxis.labelCount = self.historys.count;
    xAxis.valueFormatter = [[ShortDateValueFormatter alloc] initWithVisibleLabelsCount:YHVisibleHistorysCount allDataCount:self.historys.count];

    CombinedChartData *chartData = [[CombinedChartData alloc] init];
    chartData.lineData = [self generateLineData];
    chartData.barData = [self generateBarData];

    _stepHistoryChart.data = chartData;
    CombinedChartRenderer *combineRender = (CombinedChartRenderer *)_stepHistoryChart.renderer;
    BarChartRenderer *barRender = (BarChartRenderer *)[combineRender getSubRendererWithIndex:1];
    YHBarChartRenderer *yhRender = [[YHBarChartRenderer alloc] initWithDataProvider:_stepHistoryChart animator:barRender.animator viewPortHandler:barRender.viewPortHandler];
    yhRender.delegate = self;
    combineRender.subRenderers = @[[combineRender getSubRendererWithIndex:0],yhRender];

    _stepHistoryChart.leftAxis.axisMaximum = chartData.yMax+10;
}

- (LineChartData *)generateLineData {
    LineChartData *d = [[LineChartData alloc] init];
    NSMutableArray *entries = [[NSMutableArray alloc] init];
    for (int index = 0; index < self.stepDatas.count; index++){
        NSUInteger step = 1000;
        [entries addObject:[[ChartDataEntry alloc] initWithX:index y:step]];
    }

    LineChartDataSet *set = [[LineChartDataSet alloc] initWithValues:entries label:@""];
    [set setColor:[UIColor whiteColor]];
    set.lineWidth = 1;
    set.drawCirclesEnabled = NO;
    set.mode = LineChartModeLinear;
    set.drawVerticalHighlightIndicatorEnabled = NO;
    set.lineDashLengths = @[@5,@10,@15];
    set.drawValuesEnabled = NO;
    set.highlightEnabled = NO;
    set.axisDependency = AxisDependencyLeft;
    [d addDataSet:set];
    return d;
}

- (BarChartData *)generateBarData {
    BarChartDataSet *set = [[BarChartDataSet alloc] initWithValues:self.historys label:@""];
    set.axisDependency = AxisDependencyRight;
    set.drawValuesEnabled = NO;
    BarChartData *d = [[BarChartData alloc] initWithDataSets:@[set]];
    d.barWidth = 0.9f;
    set.highlightColor = [UIColor greenColor];
    set.highlightAlpha = 1;
    return d;
}

2. 属性对比分析

属性分析 1:

属性区域图1.png

标注数字的视图及其对应的属性如下:

  • ① -> xAxis.drawAxisLineEnabled = YES; // 绘制 X 轴与绘图区域的分割线
  • ② -> xAxis.drawGridLinesEnabled = YES; // 绘制网格线 enable
  • ③ -> xAxis.axisMaxLabels = self.historys.count / 2.0; // X 轴最多显示 label 的个数。 xAxis.labelCount = self.historys.count; // 总 label 个数 。如果 axisMaxLabels < xAxis.labelCount ,必然导致有些柱体不会绘制对应的 label
  • ④ -> xAxis.spaceMax = 0; // 坐标轴额外的偏移。由于第一个柱子的中心线与原点的 Y 轴重合,导致柱子的一半宽度处于绘图区域外
  • ⑤ -> d.barWidth = 1.0; // 控制两个柱子之前的间隔,取值 0 ~ 1.0 。1.0 代表两个柱子间无间隔
  • ⑥ -> 此处框选住的是用自定义的 YHBarChartRenderer 绘制的柱形图,⑧、⑨ 分别对应的是选中柱子和未选中柱子的绘制效果
  • ⑦ -> 此处框选住的是用自定义的 YHXAxisRenderer 绘制的 X 轴 label,提供 selectedEntryX、selectedXLabelFont 和 selectedXLabelTextColor 三个属性,实现被选中的 label 发生字体及颜色的变化
  • ⑩ -> set.lineDashLengths = @[@5,@10]; // 控制折线图的虚线绘制

属性分析 2:

属性区域图2.png

标注数字的视图及其对应的属性如下:

  • ③ -> xAxis.axisMaxLabels = self.historys.count; xAxis.labelCount = self.historys.count; // 由于 xAxis.axisMaxLabels == xAxis.axisMaxLabels ,柱体的个数与 label 的个数一一对应
  • ④ -> xAxis.spaceMax = 0.5; // X 轴左右两端额外增加了 0.5 倍的柱子宽度,这样就可以是第一个柱子完全展示出来
  • ⑤ -> d.barWidth = 0.9; // 两个柱子之前有了 (1 - 0.9) 倍的柱子宽度的间隔
  • ⑩ -> set.lineDashLengths = @[@5,@10,@15]; // 线图的虚线绘制

属性分析 3:

charts绘图区域标注.png

不同颜色矩形框及其对应的属性如下:

  • 粉色矩形框:_stepHistoryChart = [[CombinedChartView alloc] initWithFrame:chartRect] 中的 chartRect
  • 红色矩形框:chartRect 减去对应的 extraTopOffset、extraBottomOffset、extraRightOffset、extraLeftOffset
  • 原谅色矩形框:X 轴 label 的文字高度
  • 白色矩形框:红色矩形框 - 原谅色矩形框 - minOffset

YHXAxisRenderer 支持 X 轴 label 被选中时改变字体及颜色

YHXAxisRenderer 继承自 XAxisRenderer 。XAxisRenderer 负责绘制 X 轴相关视图,其中就包含 X 轴上 label 的绘制。通过方法 func drawLabels(context: CGContext, pos: CGFloat, anchor: CGPoint) 遍历每一个 label ,计算 label 的位置、颜色、字体等,然后 交由 方法

func drawLabel(
       context: CGContext,
       formattedLabel: String,
       x: CGFloat,
       y: CGFloat,
       attributes: [NSAttributedStringKey : Any],
       constrainedToSize: CGSize,
       anchor: CGPoint,
       angleRadians: CGFloat)

去绘制。在 YHXAxisRenderer 中增加 selectedEntryX、selectedXLabelFont、selectedXLabelTextColor 属性,对选中的 label 重设 字体和颜色。

YHBarChartRenderer 支持自定义绘制普通状态及选中状态时柱子的形状

YHBarChartRenderer 继承自 BarChartRenderer,BarChartRenderer 的 func drawDataSet(context: CGContext, dataSet: IBarChartDataSet, index: Int) 方法负责绘制未选中状态下的每一个柱子。func drawHighlighted(context: CGContext, indices: [Highlight]) 方法负责绘制选中状态下的每一个柱子。定义一个协议:

@objc public protocol YHBarChartRendererDelegate {
    /// Called when drawning a bar's shape.
    @objc optional func drawBarChartShape(context: CGContext, barRect: CGRect) -> Void
    
    /// Called when drawning a highlight bar's shape.
    @objc optional func drawBarChartHighlightShape(context: CGContext, barRect: CGRect) -> Void
}

YHBarChartRenderer 定义一个id< YHBarChartRendererDelegate > delegate属性, 在绘制柱子前先判断 delegate 是否实现了 自定义绘制柱子的方法 ,从而可以替代默认的绘制方法。
其中需要注意的一点是:每个柱子的 barRect.origin.y 参考坐标是 contentRect ,真实的绘图区域的 y 需要调整:

 if self.delegate != nil, self.delegate!.drawBarChartShape != nil
{
    var extraTopOffset = CGFloat(0.0);
    if let chartView = self.dataProvider as? ChartViewBase
    {
        extraTopOffset = chartView.extraTopOffset;
    }
    barRect.origin.y += viewPortHandler.contentTop + extraTopOffset;
    self.delegate!.drawBarChartShape!(context: context, barRect: barRect)
}

弹窗内容支持属性文本

Charts 的弹窗 是 id<IMarker> 类型的对象,官方 Demo 创建了一个类:XYMarkerView ,实现了 IMarker 协议。弹窗内显示的内容,由 id<IAxisValueFormatter> 类型的对象实现 func stringForValue(_ value: Double, axis: AxisBase?) -> String方法来提供。
该协议返回的是无属性的字符串,为了使弹窗中展示属性字符,可以新建一个继承自 IAxisValueFormatter 的新协议:IAxisAttributeValueFormatter ,在该协议中再定义一个方法:func attributeStringForValue(_ value: Double, axis: AxisBase?) -> NSDictionary,该方法返回一个字典,可包含丰富的信息。
参考 XYMarkerView ,我们可以创建一个类:KeepXYMarkerView ,在实现协议里的 func refreshContent(entry: ChartDataEntry, highlight: Highlight)方法中调用 IAxisAttributeValueFormatter 的 func attributeStringForValue(_ value: Double, axis: AxisBase?) -> NSDictionary方法,最终交由 func draw(context: CGContext, point: CGPoint)进行属性文本的绘制。

24小时步数视图底部刻度视图

24小时柱状图要求每20分钟内累积的步数绘制一个柱体,若20分钟内的步数累积为零,则绘制一个灰色的柱子。
实现方法:遍历,用每一个20分钟内累积的步数,创建一个 BarChartDataEntry 类型的对象,同时创建一个填充该柱子的颜色的 UIColor 对象。遍历完毕后,得到一个包含 BarChartDataEntry 对象的数组 stepRecords,和一个包含 UIColor 对象的数组 colors ,

BarChartDataSet *dataSet = [[BarChartDataSet alloc] initWithValues:stepRecords];
dataSet.colors = colors;
BarChartData *chartData = [[BarChartData alloc] initWithDataSets:@[dataSet]];

如此,便可实现需求了。折线图在不同取值范围使用不同颜色的折线绘制,使用于此相似的实现方法。

折线图的折线支持颜色分层

折线图(LineChartDataSet.mode = LineChartModeLinear || LineChartDataSet.mode = LineChartModeStepped)在不同取值范围使用不同颜色的折线绘制,使用 dataSet.colors = colors; 的方法可以简单实现,但效果并不理想,如下图:

实际效果.png

而理想的需求效果图如下图:
理想折线图.png

实际效果图之所以不能做到高于或低于一定的值使用不同的颜色绘制,是因为,在两个数据点之间,只使用一种颜色绘制,而两个数据点可能跨越多个颜色区间。为了实现理想的效果,我们可以判断两个数据点是否跨越了不同的颜色区间,一旦跨越了,我们就在跨越点之间添加数据点,在每两个数据点之间用对应的颜色绘制即可。基于此原理我新建了一个类:YHLineChartRenderer,继承自 LineChartRenderer。在这个类里添加了一个属性 @objc open var hierarchyColors: [HierarchyColor]? = [], HierarchyColor是一个结构体,包含 y 值和一个低于 y 值时绘制折线的颜色。并重写了func drawLinear(context: CGContext, dataSet: ILineChartDataSet)方法,在这个方法里对 LineChartDataSet.mode = LineChartModeLinear 的折线绘制方法进行了重写,对。在实例化折线图 Chart 时,用 YHLineChartRenderer 替换掉默认的 LineChartRenderer ,并给 hierarchyColors 属性赋值, LineChartDataSet.mode = LineChartModeLinear即可。示例:

YHLineChartRenderer *yhLineRender = [[YHLineChartRenderer alloc] initWithDataProvider:_stepHistoryChart animator:lineRender.animator viewPortHandler:lineRender.viewPortHandler];
yhLineRender.hierarchyColors = @[[[HierarchyColor alloc] initWithY:1000 color:[UIColor greenColor]],[[HierarchyColor alloc] initWithY:3000 color:[UIColor blueColor]],[[HierarchyColor alloc] initWithY:5000 color:[UIColor redColor]]];
......
set.mode = LineChartModeLinear;

滚动选中时柱体颜色对应改变

Charts 可通过调用 func highlightValue(_ highlight: Highlight?, callDelegate: Bool)方法触发视图的选中效果:

ChartHighlight *high = [[ChartHighlight alloc] initWithX:@(selected).doubleValue y:entry.y dataSetIndex:0 dataIndex:1];
[self.sleepHistoryStackBarChart highlightValue:nil];
[self.sleepHistoryStackBarChart highlightValue:high callDelegate:YES];

主要是要创建一个 ChartHighlight 对象。以本文章开头中的代码为例,

_stepHistoryChart.drawOrder = @[@(CombinedChartDrawOrderLine), @(CombinedChartDrawOrderBar)];
......
CombinedChartData *chartData = [[CombinedChartData alloc] init];
chartData.lineData = [self generateLineData]; // 柱状图数据源 chartData.barData = [self generateBarData]; // 折线图数据源

折线图和柱状图的绘制顺序有 drawOrder 决定,本例中柱状图的层级在折线图的层级之上,柱状图对应的 dataIndex = 1; dataSetIndex = 0;
先执行[self.sleepHistoryStackBarChart highlightValue:nil];可取消 已选中的柱子。
callDelegate 会调用

func chartValueSelected(_ chartView: ChartViewBase, entry: ChartDataEntry, highlight: Highlight)
func chartValueNothingSelected(_ chartView: ChartViewBase)

为什么采用 scrollview 嵌套 chart 的方案

chart 默认会将所有的柱子在一屏内绘制出来,为了实现首次加载时一屏内只展示最右边的9个柱子,并将 chart 的中心移到9个柱子中的最中间的一个柱子,我考虑了两种方案:

方案1. 将 chart 的 X 轴放大一定比例,然后将视图的偏移移动到指定位置。实现方案:

NSUInteger visiableBarCount = 9;
BarChartDataEntry *centerEntry = yVals[yVals.count - 5];
[_chartView zoomWithScaleX:1.0 * yVals.count / visiableBarCount scaleY:1 xValue:centerEntry.x yValue:centerEntry.y axis:AxisDependencyLeft];

方案2. 采用 scrollview 嵌套 chart 的方式,设置 scrollview.contentSize 为合适的 size, 然后设置 scrollview.contentOffset。实现方案:

_containerScrollView.contentSize = CGSizeMake(self.historys.count * KBPerBarWidth, KBChartHeight); 
_containerScrollView.contentOffset = CGPointMake((self.historys.count-KBVisibleHistorysCount) * KBPerBarWidth, 0);

很明显,如果没有其他的需求,方案1更好些。
重点是有一个需求:当视图停止滚动时,将离视图中心线最接近的柱子的中心线与视图中心线保持重合。
用方案2很简单,用scrollview 的代理方法即可,实现方案:

- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView {
    [self adjustContentOffset:scrollView];
}

如果用方案1,在 chart 中通过 UIPanGestureRecognizer 的代理方法:@objc private func panGestureRecognized(_ recognizer: NSUIPanGestureRecognizer) 来实现视图的滚动刷新效果。在 UIGestureRecognizerState.changed时,提供了一个代理方法:

// Callbacks when the chart is moved / translated via drag gesture.
@objc optional func chartTranslated(_ chartView: ChartViewBase, dX: CGFloat, dY: CGFloat)

获取拖拽手势的在 chart 中的位置变化量。
。在拖拽手势结束后,用 DisplayLink 来模拟松手后惯性滚动的效果:

if recognizer.state == NSUIGestureRecognizerState.ended && isDragDecelerationEnabled {
      stopDeceleration()
      _decelerationLastTime = CACurrentMediaTime()
      _decelerationVelocity = recognizer.velocity(in: self)
      _decelerationDisplayLink = NSUIDisplayLink(target: self, selector: #selector(BarLineChartViewBase.decelerationLoop))
      _decelerationDisplayLink.add(to: RunLoop.main, forMode: RunLoopMode.commonModes)  }

由于在 DisplayLink 的 selector(BarLineChartViewBase.decelerationLoop)中并没有对外通知 DisplayLink 何时终止的方法,因此不适合用来实现滚动停止时,适当调整 chart 偏移的需求。

心率图右边沿坐标

Chart 有 rightAxis 属性代表的是右坐标轴,有于本 demo 使用的是 scrollview + chart 的方案,scrollview 内的 chart 移动时,chart 的右坐标轴会随之移动。简单的解决方案:在 scrollview 的父视图中添加三个 label ,置于 scrollview 的上层,代替 chart 的右坐标轴。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,240评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,328评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 162,182评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,121评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,135评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,093评论 1 295
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,013评论 3 417
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,854评论 0 273
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,295评论 1 310
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,513评论 2 332
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,678评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,398评论 5 343
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,989评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,636评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,801评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,657评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,558评论 2 352

推荐阅读更多精彩内容

  • This chapter covers the basic setup for using this librar...
    ngugg阅读 995评论 0 1
  • 图表控件库 MPAndroidChart 的使用 使用方法 项目源码地址,包含了很多类型的图标 https://g...
    jinchuang阅读 817评论 0 0
  • 8. Setting Colors Since release v1.4.0, the ColorTemplate...
    ngugg阅读 705评论 0 0
  • OC中如何配置 Chart 近期项目需要使用到K线图、折线图等图表功能,因此接触到了Charts这个框架,不得不说...
    Peter_song阅读 5,089评论 1 7
  • 文章转载自:https://github.com/tuteng/MPAndroidCharthttps://git...
    no白菜阅读 5,138评论 0 8