PNChart是国内开发者开发的iOS图表框架,如今已经9000多颗star了。它涵盖了折线图,饼图,散点图等图表。图表的可定制性很高,并且UI设计简洁大方。前端
该框架分为两层:视图层和数据层。视图层里有两层继承关系,第一层是全部类型图表的父类PNGenericChart
,第二层就是全部类型的图表。提供一张图来直观感觉一下:git
在这张图里,须要注意如下几点:程序员
Data
类对应图表的一组数据,由于当前类型的图表支持多组数据(例如:饼状图没有Data
类,由于饼状图没有多组数据,而折线图LineChart
是支持多组数据的,因此有Data
类。Item
类负责将传入图表的某个真实值转化为图表中显示的值,具体作法会在下文详细讲解。BarChart
类里面的每一根柱子都是PNBar
的实例(该类型的图表不在本篇讲解的范围以内)。今天就来介绍一下该框架里的折线图。一旦学会了折线图的绘制,了解了绘图原理,那么其余类型的图表就能够举一反三。github
上文提到过,该框架的折线图是支持多组数据的,也就是在同一张图表上显示多条折线。先带你们看一下效果图: 算法
折线图在效果上仍是很简洁美观的(并支持动画效果),若是如今的你还不知道如何使用CAShapeLayer
和UIBezierPath
画图并附加动画效果,那么本篇源码解析很是适合你。编程
阅读本文以后,你能够掌握有关图形绘制的相关知识,也能够掌握自定义各类图形(UIView
)的方法,并且你也应该有能力做出这样的图表,甚至更好!后端
在开始讲解以前,我先粗略介绍一下利用CAShapeLayer
画图的过程。这个过程有三个大前提:数组
UIView
是对CALayer
的封装,因此咱们能够经过改变UIView
所持有的layer
属性来直接改变UIView
的显示效果。CAShapeLayer
是CALayer
的子类。CAShapeLayer
的使用是依赖于UIBezierPath
的。UIBezierPath
就是“路径”,能够理解为形状。不难理解,想象一下,若是咱们想画一个图形,那么这个图形的形状(包括颜色)是必不可少的,而这个角色,就须要UIBezierPath
来充当。那么了这三个大前提,咱们就能够知道如何画图了:框架
UIBezierPath
,并赋给CAShapeLayer
实例的path
属性。CAShapeLayer
的实例添加到UIView
的layer
上。简单的代码演示上述过程:ide
UIBezierPath *path = [UIBezierPath bezierPath];
...自定义path...
CAShapeLayer *shapLayer = [CAShapeLayer alloc] init];
shapLayer.path = path;
[self.view.layer addSubLayer:shapeLayer];
复制代码
如今大体了解了画图的过程,咱们来看一下该框架的做者是如何实现一个折线图的吧!
首先看一下整个绘制折线图的步骤:
在集合代码具体讲解以前,咱们要清楚三点(很是很是重要):
PNLineChart
的实例里面。PNLineChartData
里面。每一条折线对应一个PNLineChartData
实例。这些实例汇总到一个数组里面,这个数组由PNLineChart
的实例管理。在充分了解了这三点以后,咱们结合一下代码来看一下具体的实现:
- (id)initWithFrame:(CGRect)frame {
self = [super initWithFrame:frame];
if (self) {
[self setupDefaultValues];
}
return self;
}
- (void)setupDefaultValues {
[super setupDefaultValues];
...
//四个内边距
_chartMarginLeft = 25.0;
_chartMarginRight = 25.0;
_chartMarginTop = 25.0;
_chartMarginBottom = 25.0;
...
//真正绘制图表的画布(CavanWidth)的宽高
_chartCavanWidth = self.frame.size.width - _chartMarginLeft - _chartMarginRight;
_chartCavanHeight = self.frame.size.height - _chartMarginBottom - _chartMarginTop;
...
}
复制代码
上面这段代码我刻意省去了其余一些基本的设置,突出了图表布局的设置。
布局的设置是图表绘制的前提,由于在最开始的时候,就应该计算出“画布”,也就是图表内容(不包括坐标轴和坐标label)的具体大小和位置(内边距之内的部分)。
在这里,咱们须要获取真正绘制图表的画布的宽高(
_chartCavanWidth
和_chartCavanHeight
)。并且,要留意的是_chartMarginLeft
在未来是要用做y轴Label的宽度,而_chartMarginBottom
在未来是要用做x轴Label的高度的。
用一张图直观看一下:
如今画布的位置和大小肯定了,咱们能够来看一下折线图是怎么画的了。 整个图表的绘制都基于三组数据(也能够是两组,为何是两组,我稍后会给出解释),在讲解该框架是如何利用这些数据以前,咱们来看一下这些数据是如何传进图表的:
...
//设置x轴的数据
[self.lineChart setXLabels:@[@"SEP 1", @"SEP 2", @"SEP 3", @"SEP 4", @"SEP 5", @"SEP 6", @"SEP 7"]];
//设置y轴的数据
[self.lineChart setYLabels:@[
@"0",@"50",@"100",@"150",@"200",@"250",@"300",
]
];
// Line Chart
//设置每一个点的y值
NSArray *dataArray = @[@0.0, @180.1, @26.4, @202.2, @126.2, @167.2, @276.2];
PNLineChartData *data = [PNLineChartData new];
data.pointLabelColor = [UIColor blackColor];
data.color = PNTwitterColor;
data.alpha = 0.5f;
data.itemCount = dataArray.count;
data.inflexionPointStyle = PNLineChartPointStyleCircle;
//这个block的做用是将上面的dataArray里的每个值传给line chart。
data.getData = ^(NSUInteger index) {
CGFloat yValue = [dataArray[index] floatValue];
return [PNLineChartDataItem dataItemWithY:yValue];
};
//由于只有一条折线,因此只有一组数据
self.lineChart.chartData = @[data];
//绘制图表
[self.lineChart strokeChart];
//设置代理,响应点击
self.lineChart.delegate = self;
[self.view addSubview:self.lineChart];
复制代码
上面的代码我能够略去了不少多余的设置,目的是突出图表数据的设置。
不难看出,这里有三个数据传给了lineChart:
1.x轴的数据:
[self.lineChart setXLabels:@[@"SEP 1", @"SEP 2", @"SEP 3", @"SEP 4", @"SEP 5", @"SEP 6", @"SEP 7"]];
复制代码
这段代码调用以后,实现了:
- 根据传入的xLabel数组里元素的数量,内容宽度(
_chartCavanWidth
)和下边距(_chartMarginBottom
),计算每一个xlabel的size。- 根据xLabel所须要展现的内容(
NSString
)和宽度,实例化全部的xLabel(包括内容,位置)并显示出来,最后保存在_xChartLabels
里面。
2.y轴的数据:
[self.lineChart setYLabels:@[
@"0",@"50",@"100",@"150",@"200",@"250",@"300",
]
];
复制代码
这段代码调用以后,实现了:
- 根据传入的yLabel数组里元素的数量,内容高度(
_chartCavanHeight
)和左边距(_chartMarginLeft
),计算出每一个ylabel的size。- 根据xLabel所须要展现的内容(
NSString
)和宽度,实例化全部的yLabel(包括内容,位置)并显示出来,最后保存在_yChartLabels
里面。
3.一条折线上每一个点的实际值:
NSArray *dataArray = @[@0.0, @180.1, @26.4, @202.2, @126.2, @167.2, @276.2];
data.getData = ^(NSUInteger index) {
CGFloat yValue = [dataArray[index] floatValue];
return [PNLineChartDataItem dataItemWithY:yValue];
};
self.lineChart.chartData = @[data];
复制代码
着重讲一下block:为何不直接把这个数组(
dataArray
)做为line chart的属性传进去呢?我认为做者是想提供一个接口给用户一个本身转化y值的机会。
像上文所说的,这里1,2是属于
lineChart
的数据,它适用于这张图表上全部的折线的。而3是属于某一条折线的。
如今回答一下为何能够只传入两组数据:由于y轴数据能够由每一个点的实际值数组得出。能够简单想一下,咱们能够获取这些真实值里面的最大值,而后将它n等分,就天然获得了y轴数据了。
咱们已经布局了x轴和y轴的全部label,如今开始真正计算图表的数据了。
注意:下面要介绍的3,4,5,6项都是在同一方法中计算出来,为了不代码过长,我将每一个部分分解开来作出解释。由于在同一方法里,因此这些涉及到for循环的语句是一致的。
整个图表的绘制都是依赖于数据的处理,因此3,4,5,6项也是理解该框架的一个关键!
首先,咱们须要计算每一个数据点(拐点)的准确位置:
//遍历图表里每条折线
//还记得chartData属性么?它是用来保存多组折线的数据的,在这里只有一个折线,因此这个循环只循环一次)
for (NSUInteger lineIndex = 0; lineIndex < self.chartData.count; lineIndex++) {
//保存每条折线上的全部点的CGPoint
NSMutableArray *linePointsArray = [[NSMutableArray alloc] init];
//遍历每条折线里的每一个点
for (NSUInteger i = 0; i < chartData.itemCount; i++) {
//传入index,获取y值(调用的是上文提到的block)
yValue = chartData.getData(i).y;
//当前点的x: _chartMarginLeft + _xLabelWidth / 2.0为0坐标,每多一个点就多一个_xLabelWidth
int x = (int) (i * _xLabelWidth + _chartMarginLeft + _xLabelWidth / 2.0);
//当前点的y:根据当前点的值和当前点所在的数组里的最大值的比例 以及 图表的总高度,算出当前点在图表里的y坐标
int y = (int)[self yValuePositionInLineChart:yValue];
//保存全部拐点的坐标
[linePointsArray addObject:[NSValue valueWithCGPoint:CGPointMake(x, y)]];
}
//保存多条折线的CGPoint(这里只有一条折线,因此该数组只有一个元素)
[pathPoints addObject:[linePointsArray copy]];
}
复制代码
在这里须要注意两点:
- 这里的
pathPoints
对应的是lineChart
的_pathPoints
属性。它是一个二维数组,保存每条折线上全部点的CGPoint
。- y值的计算:是须要从y的真实值转化为这个拐点在图表里的y坐标,转化方法的实现(仔细看几遍就懂了):
- (CGFloat)yValuePositionInLineChart:(CGFloat)y {
CGFloat innerGrade;//真实的最大值与最小值的差 与 当前点与最小值的差 的比值
if (!(_yValueMax - _yValueMin)) {
//特殊状况:当_yValueMax和_yValueMin相等的时候
innerGrade = 0.5;
} else {
innerGrade = ((CGFloat) y - _yValueMin) / (_yValueMax - _yValueMin);
}
//innerGrade 与画布的高度(_chartCavanHeight)相乘,就能得出在画布中的高度
return _chartCavanHeight - (innerGrade * _chartCavanHeight) - (_yLabelHeight / 2) + _chartMarginTop;
}
复制代码
//遍历图表里每条折线
for (NSUInteger lineIndex = 0; lineIndex < self.chartData.count; lineIndex++) {
//每条折线全部圆圈的贝塞尔曲线
UIBezierPath *pointPath = [UIBezierPath bezierPath];
//inflexionWidth默认是6,是两个线段中间的距离(由于中间有一个圈圈,因此须要定一个距离)
CGFloat inflexionWidth = chartData.inflexionPointWidth;
//遍历每条折线里的每一个点
for (NSUInteger i = 0; i < chartData.itemCount; i++) {
//1. 计算圆圈的rect:已当前点为中心,以inflexionWidth为半径
CGRect circleRect = CGRectMake(x - inflexionWidth / 2, y - inflexionWidth / 2, inflexionWidth, inflexionWidth);
//2. 计算圆圈的中心:由圆圈的x,y和inflexionWidth算出
CGPoint circleCenter = CGPointMake(circleRect.origin.x + (circleRect.size.width / 2), circleRect.origin.y + (circleRect.size.height / 2));
//3. 绘制
//3.1 移动到圆圈的右中部
[pointPath moveToPoint:CGPointMake(circleCenter.x + (inflexionWidth / 2), circleCenter.y)];
//3.2 画线(圆形)
[pointPath addArcWithCenter:circleCenter radius:inflexionWidth / 2 startAngle:0 endAngle:(CGFloat) (2 * M_PI) clockwise:YES];
}
//保存到pointsPath数组里
[pointsPath insertObject:pointPath atIndex:lineIndex];
}
复制代码
在这里,
pointsPath
对应的是lineChart
的_pointsPath
属性。它是一个一维数组,保存每条折线上的圆圈贝塞尔曲线(UIBezierPath)。
//遍历图表里每条折线
for (NSUInteger lineIndex = 0; lineIndex < self.chartData.count; lineIndex++) {
//遍历每条折线里的每一段
for (NSUInteger i = 0; i < chartData.itemCount; i++) {
if (chartData.showPointLabel) {
[gradePathArray addObject:[self createPointLabelFor:chartData.getData(i).rawY pointCenter:circleCenter width:inflexionWidth withChartData:chartData]];
}
}
}
复制代码
注意,在这里,这些label的实现是经过一个
CATextLayer
实现的,并非生成一个个Label
放在数组里保存,具体实现方法以下:
- (CATextLayer *)createPointLabelFor:(CGFloat)grade pointCenter:(CGPoint)pointCenter width:(CGFloat)width withChartData:(PNLineChartData *)chartData {
//grade:提供textLayer显示的数值
//pointCenter:根据pointCenter算出textLayer的x,y
//width:根据width获得textLayer的总宽度
//chartData:获取chartData里保存的textLayer上应该保存的字体大小和颜色
CATextLayer *textLayer = [[CATextLayer alloc] init];
[textLayer setAlignmentMode:kCAAlignmentCenter];
//设置textLayer的背景色
[textLayer setForegroundColor:[chartData.pointLabelColor CGColor]];
[textLayer setBackgroundColor:self.backgroundColor.CGColor];
//设置textLayer的字体大小和颜色
if (chartData.pointLabelFont != nil) {
[textLayer setFont:(__bridge CFTypeRef) (chartData.pointLabelFont)];
textLayer.fontSize = [chartData.pointLabelFont pointSize];
}
//设置textLayer的高度
CGFloat textHeight = (CGFloat) (textLayer.fontSize * 1.1);
CGFloat textWidth = width * 8;
CGFloat textStartPosY;
textStartPosY = pointCenter.y - textLayer.fontSize;
[self.layer addSublayer:textLayer];
//设置textLayer的文字显示格式
if (chartData.pointLabelFormat != nil) {
[textLayer setString:[[NSString alloc] initWithFormat:chartData.pointLabelFormat, grade]];
} else {
[textLayer setString:[[NSString alloc] initWithFormat:_yLabelFormat, grade]];
}
//设置textLayer的位置和scale(1x,2x,3x)
[textLayer setFrame:CGRectMake(0, 0, textWidth, textHeight)];
[textLayer setPosition:CGPointMake(pointCenter.x, textStartPosY)];
textLayer.contentsScale = [UIScreen mainScreen].scale;
return textLayer;
}
复制代码
//遍历图表里每条折线
for (NSUInteger lineIndex = 0; lineIndex < self.chartData.count; lineIndex++) {
//每一条线段的贝塞尔曲线(UIBezierPath),用数组装起来
NSMutableArray<UIBezierPath *> *progressLines = [NSMutableArray new];
//chartPath(二维数组):保存全部折线上全部线段的贝塞尔曲线。如今只有一条折线,因此只有一个元素
[chartPath insertObject:progressLines atIndex:lineIndex];
//progressLinePaths的每一个元素是一个字典,字典里存放每一条线段的端点(from,to)
NSMutableArray<NSDictionary<NSString *, NSValue *> *> *progressLinePaths = [NSMutableArray new];
int last_x = 0;
int last_y = 0;
//遍历每条折线里的每一段
for (NSUInteger i = 0; i < chartData.itemCount; i++) {
if (i > 0) {
//x,y的算法参考上文第三项
// 计算index为0之后的点的位置
float distance = (float) sqrt(pow(x - last_x, 2) + pow(y - last_y, 2));
float last_x1 = last_x + (inflexionWidth / 2) / distance * (x - last_x);
float last_y1 = last_y + (inflexionWidth / 2) / distance * (y - last_y);
float x1 = x - (inflexionWidth / 2) / distance * (x - last_x);
float y1 = y - (inflexionWidth / 2) / distance * (y - last_y);
//当前线段的端点
from = [NSValue valueWithCGPoint:CGPointMake(last_x1, last_y1)];
to = [NSValue valueWithCGPoint:CGPointMake(x1, y1)];
if(from != nil && to != nil) {
//保存每一段的端点
[progressLinePaths addObject:@{@"from": from, @"to":to}];
//保存全部的端点
[lineStartEndPointsArray addObject:from];
[lineStartEndPointsArray addObject:to];
}
//保存全部折点的坐标
[linePointsArray addObject:[NSValue valueWithCGPoint:CGPointMake(x, y)]];
//将当前的x转化为下一个点的last_x(y也同样)
last_x = x;
last_y = y;
}
}
//pointsOfPath:保存全部折线里的全部线段两端的端点
[pointsOfPath addObject:[lineStartEndPointsArray copy]];
//根据每一条线段的两个端点,成生每条线段的贝塞尔曲线
for (NSDictionary<NSString *, NSValue *> *item in progressLinePaths) {
NSArray<NSDictionary *> *calculatedRanges =
...
for (NSDictionary *range in calculatedRanges) {
UIBezierPath *currentProgressLine = [UIBezierPath bezierPath];
[currentProgressLine moveToPoint:[range[@"from"] CGPointValue]];
[currentProgressLine addLineToPoint:[range[@"to"] CGPointValue]];
[progressLines addObject:currentProgressLine];
}
}
}
复制代码
- (void)populateChartLines {
//遍历每条线段
for (NSUInteger lineIndex = 0; lineIndex < self.chartData.count; lineIndex++) {
NSArray<UIBezierPath *> *progressLines = self.chartPath[lineIndex];
...
//_chartLineArray:二维数组,装载每一个chartData对应的一个数组。这个数组的元素是这一条折线上全部线段对应的CAShapeLayer
[self.chartLineArray[lineIndex] removeAllObjects];
NSUInteger progressLineIndex = 0;;
//遍历含有UIBezierPath对象元素的数组。在每一个循环里新建一个CAShapeLayer对象,将UIBezierPath赋给它。
for (UIBezierPath *progressLinePath in progressLines) {
PNLineChartData *chartData = self.chartData[lineIndex];
CAShapeLayer *chartLine = [CAShapeLayer layer];
...
//将当前线段的UIBezierPath赋给当前线段的CAShapeLayer
chartLine.path = progressLinePath.CGPath;
//添加layer
[self.layer addSublayer:chartLine];
//保存当前线段的layer
[self.chartLineArray[lineIndex] addObject:chartLine];
progressLineIndex++;
}
}
}
复制代码
- (void)recreatePointLayers {
-
for (PNLineChartData *chartData in _chartData) {
// create as many chart line layers as there are data-lines
[self.chartLineArray addObject:[NSMutableArray new]];
// create point
CAShapeLayer *pointLayer = [CAShapeLayer layer];
pointLayer.strokeColor = [[chartData.color colorWithAlphaComponent:chartData.alpha] CGColor];
pointLayer.lineCap = kCALineCapRound;
pointLayer.lineJoin = kCALineJoinBevel;
pointLayer.fillColor = nil;
pointLayer.lineWidth = chartData.lineWidth;
[self.layer addSublayer:pointLayer];
[self.chartPointArray addObject:pointLayer];
}
}
复制代码
注意,这里并无将全部圆圈的
UIBezierPath
赋给对应的layer
,而是在下一步,绘图的时候作的。
- (void)strokeChart {
...
// 绘制全部折线(全部线段+全部圆圈)
// 遍历全部折线
for (NSUInteger lineIndex = 0; lineIndex < self.chartData.count; lineIndex++) {
PNLineChartData *chartData = self.chartData[lineIndex];
//当前折线的全部线段的CAShapeLayer
NSArray<CAShapeLayer *> *chartLines =self.chartLineArray[lineIndex];
//当前折线的全部圆圈的CAShapeLayer
CAShapeLayer *pointLayer = (CAShapeLayer *) self.chartPointArray[lineIndex];
//开始绘制折线
UIGraphicsBeginImageContext(self.frame.size);
...
//当前折线的全部线段的UIBezierPath
NSArray<UIBezierPath *> *progressLines = _chartPath[lineIndex];
//当前折线的全部圆圈的UIBezierPath
UIBezierPath *pointPath = _pointPath[lineIndex];
//7.2将圆圈的UIBezierPath赋给了圆圈的CAShapeLayer
pointLayer.path = pointPath.CGPath;
//添加动画
[CATransaction begin];
for (NSUInteger index = 0; index < progressLines.count; index++) {
CAShapeLayer *chartLine = chartLines[index];
//chartLine strokeColor is already set. no need to override here
[chartLine addAnimation:self.pathAnimation forKey:@"strokeEndAnimation"];
chartLine.strokeEnd = 1.0;
}
// if you want cancel the point animation, comment this code, the point will show immediately
if (chartData.inflexionPointStyle != PNLineChartPointStyleNone) {
[pointLayer addAnimation:self.pathAnimation forKey:@"strokeEndAnimation"];
}
//提交动画
[CATransaction commit];
...
//绘制完毕
UIGraphicsEndImageContext();
}
[self setNeedsDisplay];
}
复制代码
这里要注意两点:
1.若是想给layer添加动画,只须要实例化一个animation(在这里是
CABasicAnimation
)并调用layer的addAnimation:
方法便可。咱们看一下关于CABasicAnimation
的实例化代码:
- (CABasicAnimation *)pathAnimation {
if (self.displayAnimated && !_pathAnimation) {
_pathAnimation = [CABasicAnimation animationWithKeyPath:@"strokeEnd"];
//持续时间
_pathAnimation.duration = 1.0;
//类型
_pathAnimation.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseInEaseOut];
_pathAnimation.fromValue = @0.0f;
_pathAnimation.toValue = @1.0f;
}
if(!self.displayAnimated) {
_pathAnimation = nil;
}
return _pathAnimation;
}
复制代码
2.在这里调用了
setNeedsDisplay
方法以后,会调用drawRect:
方法,在这个方法里,完成了x,y坐标轴的绘制:
- (void)drawRect:(CGRect)rect {
//绘制坐标轴和背景竖线
if (self.isShowCoordinateAxis) {
CGFloat yAxisOffset = 10.f;
CGContextRef ctx = UIGraphicsGetCurrentContext();
UIGraphicsPopContext();
UIGraphicsPushContext(ctx);
CGContextSetLineWidth(ctx, self.axisWidth);
CGContextSetStrokeColorWithColor(ctx, [self.axisColor CGColor]);
CGFloat xAxisWidth = CGRectGetWidth(rect) - (_chartMarginLeft + _chartMarginRight) / 2;
CGFloat yAxisHeight = _chartMarginBottom + _chartCavanHeight;
// 绘制xy轴
CGContextMoveToPoint(ctx, _chartMarginBottom + yAxisOffset, 0);
CGContextAddLineToPoint(ctx, _chartMarginBottom + yAxisOffset, yAxisHeight);
CGContextAddLineToPoint(ctx, xAxisWidth, yAxisHeight);
CGContextStrokePath(ctx);
// 绘制y轴的箭头
CGContextMoveToPoint(ctx, _chartMarginBottom + yAxisOffset - 3, 6);
CGContextAddLineToPoint(ctx, _chartMarginBottom + yAxisOffset, 0);
CGContextAddLineToPoint(ctx, _chartMarginBottom + yAxisOffset + 3, 6);
CGContextStrokePath(ctx);
// 绘制x轴的箭头
CGContextMoveToPoint(ctx, xAxisWidth - 6, yAxisHeight - 3);
CGContextAddLineToPoint(ctx, xAxisWidth, yAxisHeight);
CGContextAddLineToPoint(ctx, xAxisWidth - 6, yAxisHeight + 3);
CGContextStrokePath(ctx);
//绘制x轴和y轴的label
if (self.showLabel) {
// 绘制x轴的小分割线
CGPoint point;
for (NSUInteger i = 0; i < [self.xLabels count]; i++) {
point = CGPointMake(2 * _chartMarginLeft + (i * _xLabelWidth), _chartMarginBottom + _chartCavanHeight);
CGContextMoveToPoint(ctx, point.x, point.y - 2);
CGContextAddLineToPoint(ctx, point.x, point.y);
CGContextStrokePath(ctx);
}
// 绘制y轴的小分割线
CGFloat yStepHeight = _chartCavanHeight / _yLabelNum;
for (NSUInteger i = 0; i < [self.xLabels count]; i++) {
point = CGPointMake(_chartMarginBottom + yAxisOffset, (_chartCavanHeight - i * yStepHeight + _yLabelHeight / 2));
CGContextMoveToPoint(ctx, point.x, point.y);
CGContextAddLineToPoint(ctx, point.x + 2, point.y);
CGContextStrokePath(ctx);
}
}
UIFont *font = [UIFont systemFontOfSize:11];
// 绘制y轴单位
if ([self.yUnit length]) {
CGFloat height = [PNLineChart sizeOfString:self.yUnit withWidth:30.f font:font].height;
CGRect drawRect = CGRectMake(_chartMarginLeft + 10 + 5, 0, 30.f, height);
[self drawTextInContext:ctx text:self.yUnit inRect:drawRect font:font color:self.yLabelColor];
}
// 绘制x轴的单位
if ([self.xUnit length]) {
CGFloat height = [PNLineChart sizeOfString:self.xUnit withWidth:30.f font:font].height;
CGRect drawRect = CGRectMake(CGRectGetWidth(rect) - _chartMarginLeft + 5, _chartMarginBottom + _chartCavanHeight - height / 2, 25.f, height);
[self drawTextInContext:ctx text:self.xUnit inRect:drawRect font:font color:self.xLabelColor];
}
}
//绘制竖线
if (self.showYGridLines) {
CGContextRef ctx = UIGraphicsGetCurrentContext();
CGFloat yAxisOffset = _showLabel ? 10.f : 0.0f;
CGPoint point;
//每一条竖线的跨度
CGFloat yStepHeight = _chartCavanHeight / _yLabelNum;
//颜色
if (self.yGridLinesColor) {
CGContextSetStrokeColorWithColor(ctx, self.yGridLinesColor.CGColor);
} else {
CGContextSetStrokeColorWithColor(ctx, [UIColor lightGrayColor].CGColor);
}
//绘制每一条竖线
for (NSUInteger i = 0; i < _yLabelNum; i++) {
//拿到起点
point = CGPointMake(_chartMarginLeft + yAxisOffset, (_chartCavanHeight - i * yStepHeight + _yLabelHeight / 2));
//将画笔移动到起点
CGContextMoveToPoint(ctx, point.x, point.y);
//设置线的属性
CGFloat dash[] = {6, 5};
CGContextSetLineWidth(ctx, 0.5);
CGContextSetLineCap(ctx, kCGLineCapRound);
CGContextSetLineDash(ctx, 0.0, dash, 2);
//设置这条线的终点
CGContextAddLineToPoint(ctx, CGRectGetWidth(rect) - _chartMarginLeft + 5, point.y);
//画线
CGContextStrokePath(ctx);
}
}
[super drawRect:rect];
}
复制代码
到这里,一张完整的图表就能够画出来了。可是当前绘制的图表的折线都是直线,在上面还展现了一张曲线图。那么若是想绘制带有曲线的折线图应该怎么作呢?对,就是在贝塞尔曲线上下功夫。
当咱们获取了全部线段的端点数组后,咱们能够经过他们绘制弯曲的贝塞尔曲线(注意:该方法是对应上面对第6项的下半部分:生成每个线段对贝塞尔曲线):
//_showSmoothLines是用来控制是否绘制曲线折线的开关属性
if (self.showSmoothLines && chartData.itemCount >= 4) {
for (NSDictionary<NSString *, NSValue *> *item in progressLinePaths) {
...
for (NSDictionary *range in calculatedRanges) {
UIBezierPath *currentProgressLine = [UIBezierPath bezierPath];
CGPoint segmentP1 = [range[@"from"] CGPointValue];
CGPoint segmentP2 = [range[@"to"] CGPointValue];
[currentProgressLine moveToPoint:segmentP1];
CGPoint midPoint = [PNLineChart midPointBetweenPoint1:segmentP1 andPoint2:segmentP2];
//以每条线段以中间点为分割点,分红两组。每一组造成柔和的外凸曲线,而不是内凹
[currentProgressLine addQuadCurveToPoint:midPoint
controlPoint:[PNLineChart controlPointBetweenPoint1:midPoint andPoint2:segmentP1]];
[currentProgressLine addQuadCurveToPoint:segmentP2
controlPoint:[PNLineChart controlPointBetweenPoint1:midPoint andPoint2:segmentP2]];
[progressLines addObject:currentProgressLine];
[progressLineColors addObject:range[@"color"]];
}
}
}
复制代码
注意一下生成弯曲的贝塞尔曲线的方法:controlPointBetweenPoint1:andPoint2
:
//返回的点的x:是两点的中间;返回的点的y:与第二个点保持一致
+ (CGPoint)controlPointBetweenPoint1:(CGPoint)point1 andPoint2:(CGPoint)point2 {
//线段两端的中间点
CGPoint controlPoint = [self midPointBetweenPoint1:point1 andPoint2:point2];
//末端点 和 中间点y的差
CGFloat diffY = abs((int) (point2.y - controlPoint.y));
if (point1.y < point2.y)
//若是前端点更高
controlPoint.y += diffY;
else if (point1.y > point2.y)
//若是后端点更高
controlPoint.y -= diffY;
return controlPoint;
}
复制代码
OK,这样一来,直线的曲线图还有曲线的曲线图就大概掌握了。不过还差一个东西,就是图表对点击的响应。
咱们须要思考一下:既然一张图表里能够显示多条折线,因此,当手指点击图表上的点之后,应该同时返回两个数据:
该框架的做者很好地完成了这两个任务,咱们来看一下他是如何实现的:
- (void)touchPoint:(NSSet *)touches withEvent:(UIEvent *)event {
// Get the point user touched
UITouch *touch = [touches anyObject];
CGPoint touchPoint = [touch locationInView:self];
for (NSUInteger p = 0; p < _pathPoints.count; p++) {
NSArray *linePointsArray = _endPointsOfPath[p];
//遍历每一个端点
for (NSUInteger i = 0; i < (int) linePointsArray.count - 1; i += 2) {
CGPoint p1 = [linePointsArray[i] CGPointValue];
CGPoint p2 = [linePointsArray[i + 1] CGPointValue];
// Closest distance from point to line
//触摸点到线段的距离
float distance = (float) fabs(((p2.x - p1.x) * (touchPoint.y - p1.y)) - ((p1.x - touchPoint.x) * (p1.y - p2.y)));
distance /= hypot(p2.x - p1.x, p1.y - p2.y);
//若是距离小于5,则判断为“点击了当前的线段”,剩下的工做是判断具体点击了哪一条线段
if (distance <= 5.0) {
// Conform to delegate parameters, figure out what bezier path this CGPoint belongs to.
NSUInteger lineIndex = 0;
for (NSArray<UIBezierPath *> *paths in _chartPath) {
for (UIBezierPath *path in paths) {
//若是当前点处于UIBezierPath曲线上
BOOL pointContainsPath = CGPathContainsPoint(path.CGPath, NULL, p1, NO);
if (pointContainsPath) {
//点击了某一条折线
[_delegate userClickedOnLinePoint:touchPoint lineIndex:lineIndex];
return;
}
}
lineIndex++;
}
}
}
}
}
复制代码
- (void)touchKeyPoint:(NSSet *)touches withEvent:(UIEvent *)event {
// Get the point user touched
UITouch *touch = [touches anyObject];
CGPoint touchPoint = [touch locationInView:self];
for (NSUInteger p = 0; p < _pathPoints.count; p++) {
NSArray *linePointsArray = _pathPoints[p];
//遍历全部的点
for (NSUInteger i = 0; i < (int) linePointsArray.count - 1; i += 1) {
CGPoint p1 = [linePointsArray[i] CGPointValue];
CGPoint p2 = [linePointsArray[i + 1] CGPointValue];
//获取到前一点的距离和后一点的距离
float distanceToP1 = (float) fabs(hypot(touchPoint.x - p1.x, touchPoint.y - p1.y));
float distanceToP2 = (float) hypot(touchPoint.x - p2.x, touchPoint.y - p2.y);
float distance = MIN(distanceToP1, distanceToP2);
//若是较小的距离小于10,则断定为点击了某个点
if (distance <= 10.0) {
//点击了某一条折线上的某个点
[_delegate userClickedOnLineKeyPoint:touchPoint
lineIndex:p
pointIndex:(distance == distanceToP2 ? i + 1 : i)];
return;
}
}
}
}
复制代码
这下就完整了,一个带有响应功能的图表就作好啦!
这里只是将图表的layer
加在了UIView
的layer上,那若是想彻底自定义view的话,只需将图表的layer
彻底赋给UIView
的layer便可,这样一来,想要画出任意形状的UIView
均可以。
关于图表的绘制,相对贝塞尔曲线与CALayer
来讲,数据的处理是一个比较麻烦的点。可是一旦学会了折线图的绘制,了解了绘图原理,那么其余类型的图表就能够举一反三。
本篇文章已经同步到我我的博客:PNChart源码解析
---------------------------- 2018年7月17日更新 ----------------------------
注意注意!!!
笔者在近期开通了我的公众号,主要分享编程,读书笔记,思考类的文章。
由于公众号天天发布的消息数有限制,因此到目前为止尚未将全部过去的精选文章都发布在公众号上,后续会逐步发布的。
并且由于各大博客平台的各类限制,后面还会在公众号上发布一些短小精干,以小见大的干货文章哦~
扫下方的公众号二维码并点击关注,期待与您的共同成长~