本文以 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:
标注数字的视图及其对应的属性如下:
- ① ->
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:
标注数字的视图及其对应的属性如下:
- ③ ->
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:
不同颜色矩形框及其对应的属性如下:
- 粉色矩形框:
_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;
的方法可以简单实现,但效果并不理想,如下图:
而理想的需求效果图如下图:
实际效果图之所以不能做到高于或低于一定的值使用不同的颜色绘制,是因为,在两个数据点之间,只使用一种颜色绘制,而两个数据点可能跨越多个颜色区间。为了实现理想的效果,我们可以判断两个数据点是否跨越了不同的颜色区间,一旦跨越了,我们就在跨越点之间添加数据点,在每两个数据点之间用对应的颜色绘制即可。基于此原理我新建了一个类: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 的右坐标轴。