PNChart 源碼解析

一. 框架介紹

PNChart是國內開發者開發的iOS圖表框架,如今已經9000多顆star了。它涵蓋了折線圖,餅圖,散點圖等圖表。圖表的可定製性很高,並且UI設計簡潔大方。前端

該框架分爲兩層:視圖層和數據層。視圖層裏有兩層繼承關係,第一層是全部類型圖表的父類PNGenericChart,第二層就是全部類型的圖表。提供一張圖來直觀感覺一下:git

層級圖

在這張圖裏,須要注意如下幾點:程序員

  1. 帶箭頭的線和不帶箭頭的線的區別。
  2. Data類對應圖表的一組數據,由於當前類型的圖表支持多組數據(例如:餅狀圖沒有Data類,由於餅狀圖沒有多組數據,而折線圖LineChart是支持多組數據的,因此有Data類。
  3. Item類負責將傳入圖表的某個真實值轉化爲圖表中顯示的值,具體作法會在下文詳細講解。
  4. BarChart類裏面的每一根柱子都是PNBar的實例(該類型的圖表不在本篇講解的範圍以內)。

今天就來介紹一下該框架裏的折線圖。一旦學會了折線圖的繪製,瞭解了繪圖原理,那麼其餘類型的圖表就能夠舉一反三。github

上文提到過,該框架的折線圖是支持多組數據的,也就是在同一張圖表上顯示多條折線。先帶你們看一下效果圖: 算法

折線圖

折線圖在效果上仍是很簡潔美觀的(並支持動畫效果),若是如今的你還不知道如何使用CAShapeLayerUIBezierPath畫圖並附加動畫效果,那麼本篇源碼解析很是適合你。編程

閱讀本文以後,你能夠掌握有關圖形繪製的相關知識,也能夠掌握自定義各類圖形(UIView)的方法,並且你也應該有能力做出這樣的圖表,甚至更好!後端

在開始講解以前,我先粗略介紹一下利用CAShapeLayer畫圖的過程。這個過程有三個大前提:數組

  • 由於UIView是對CALayer的封裝,因此咱們能夠經過改變UIView所持有的layer屬性來直接改變UIView的顯示效果。
  • CAShapeLayerCALayer的子類。
  • CAShapeLayer的使用是依賴於UIBezierPath的。UIBezierPath就是「路徑」,能夠理解爲形狀。不難理解,想象一下,若是咱們想畫一個圖形,那麼這個圖形的形狀(包括顏色)是必不可少的,而這個角色,就須要UIBezierPath來充當。

那麼了這三個大前提,咱們就能夠知道如何畫圖了:框架

  1. 實例化一個UIBezierPath,並賦給CAShapeLayer實例的path屬性。
  2. 將這個CAShapeLayer的實例添加到UIViewlayer上。

簡單的代碼演示上述過程:ide

UIBezierPath *path = [UIBezierPath bezierPath];
...自定義path...
CAShapeLayer *shapLayer = [CAShapeLayer alloc] init];
shapLayer.path = path;
[self.view.layer addSubLayer:shapeLayer];
複製代碼

如今大體瞭解了畫圖的過程,咱們來看一下該框架的做者是如何實現一個折線圖的吧!

二. 源碼解析

首先看一下整個繪製折線圖的步驟:

  1. 圖表的初始化。
  2. 獲取橫軸和縱軸的數據。
  3. 計算折線上全部拐點的x,y值。
  4. 計算每一個拐點中間的圓圈的貝塞爾曲線(UIBezierPath)。
  5. 生成每一個拐點上面的Label(無關緊要)。
  6. 計算每條線段的貝塞爾曲線(UIBezierPath)。
  7. 將上面獲得的貝塞爾曲線賦給每條線段和圓圈的layer(CAShapeLayer)。
  8. 繪製全部折線(全部線段+全部圓圈)。
  9. 添加動畫(無關緊要)。
  10. 繪製x,y座標軸。

在集合代碼具體講解以前,咱們要清楚三點(很是很是重要):

  1. 此折線圖框架是能夠設置拐點的樣式的:能夠設置爲沒有樣式,也能夠設置有樣式:圓圈,方塊,三角形。
  • 若是沒有樣式,則是簡單的線段與線段的鏈接,在拐點處沒有任何其餘控件。
  • 若是是有樣式的,那麼這條折線裏的每條線段(在本篇文章裏統一說成線段)之間是分離的,由於線段中間有一個拐點控件。本篇文章介紹的是圓圈樣式(如上圖所示,拐點控件是一個圓圈)。
  1. 上文提到過,該折線圖框架能夠在一張圖表裏同時顯示多條折線,也就是能夠設置多組數據(一條折線對應一組數據)。所以,上面的3,4,5,6,7項都是用各自不一樣的一個數組保存的,數組裏的每個元素對應一條折線的數據。
  2. 既然同一個張圖表能夠顯示多條折線:
  • 那麼有些屬性就是這些折線共有的,好比橫座標的value,這些屬性保存在PNLineChart的實例裏面。
  • 有些屬性是每條折線私有的,好比每條折線的顏色,縱座標value等等,這些屬性保存在PNLineChartData裏面。每一條折線對應一個PNLineChartData實例。這些實例彙總到一個數組裏面,這個數組由PNLineChart的實例管理。

在充分了解了這三點以後,咱們結合一下代碼來看一下具體的實現:

1. 圖表的初始化

- (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的高度的。

用一張圖直觀看一下:

整個控件的大小和畫布的大小

2. 獲取橫軸和縱軸的數據

如今畫布的位置和大小肯定了,咱們能夠來看一下折線圖是怎麼畫的了。 整個圖表的繪製都基於三組數據(也能夠是兩組,爲何是兩組,我稍後會給出解釋),在講解該框架是如何利用這些數據以前,咱們來看一下這些數據是如何傳進圖表的:

...
    //設置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"]];
複製代碼

這段代碼調用以後,實現了:

  1. 根據傳入的xLabel數組裏元素的數量,內容寬度(_chartCavanWidth)和下邊距(_chartMarginBottom),計算每一個xlabel的size。
  2. 根據xLabel所須要展現的內容(NSString)和寬度,實例化全部的xLabel(包括內容,位置)並顯示出來,最後保存在_xChartLabels裏面。

2.y軸的數據:

[self.lineChart setYLabels:@[
                @"0",@"50",@"100",@"150",@"200",@"250",@"300",
                ]
    ];
複製代碼

這段代碼調用以後,實現了:

  1. 根據傳入的yLabel數組裏元素的數量,內容高度(_chartCavanHeight)和左邊距(_chartMarginLeft),計算出每一個ylabel的size。
  2. 根據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項也是理解該框架的一個關鍵!

首先,咱們須要計算每一個數據點(拐點)的準確位置:

3. 計算折線上全部拐點的x,y值。

//遍歷圖表裏每條折線
//還記得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]];

}
複製代碼

在這裏須要注意兩點:

  1. 這裏的pathPoints對應的是lineChart_pathPoints屬性。它是一個二維數組,保存每條折線上全部點的CGPoint
  2. 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;
}
複製代碼

4. 計算每一個拐點中間的圓圈的貝塞爾曲線(UIBezierPath)

//遍歷圖表裏每條折線
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)。

5. 生成每一個拐點上面的Label(無關緊要)

//遍歷圖表裏每條折線
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;
}

複製代碼

6. 計算每條線段的貝塞爾曲線(UIBezierPath)

//遍歷圖表裏每條折線
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];

        }
    }    
}
複製代碼

7. 將上面獲得的貝塞爾曲線賦給每條線段和圓圈的layer(CAShapeLayer)。

7.1 全部線段的layer:

- (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++;
        }
    }
}
複製代碼

7.2 全部圓圈的layer:

- (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,而是在下一步,繪圖的時候作的。

8.繪製全部折線(全部線段+全部圓圈)&& 9. 添加動畫

- (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座標軸的繪製:

10.繪製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,這樣一來,直線的曲線圖還有曲線的曲線圖就大概掌握了。不過還差一個東西,就是圖表對點擊的響應。

咱們須要思考一下:既然一張圖表裏能夠顯示多條折線,因此,當手指點擊圖表上的點之後,應該同時返回兩個數據:

  1. 點擊了哪條折線上的這個點。
  2. 點擊了這條折線上的哪一個點。

該框架的做者很好地完成了這兩個任務,咱們來看一下他是如何實現的:

響應點擊的代理方法

點擊了哪條折線的判斷

- (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;
            }
        }
    }
}
複製代碼

這下就完整了,一個帶有響應功能的圖表就作好啦!

關於自定義UIView

這裏只是將圖表的layer加在了UIView的layer上,那若是想徹底自定義view的話,只需將圖表的layer徹底賦給UIView的layer便可,這樣一來,想要畫出任意形狀的UIView均可以。

三. 最後的話

關於圖表的繪製,相對貝塞爾曲線與CALayer來講,數據的處理是一個比較麻煩的點。可是一旦學會了折線圖的繪製,瞭解了繪圖原理,那麼其餘類型的圖表就能夠舉一反三。


本篇文章已經同步到我我的博客:PNChart源碼解析

---------------------------- 2018年7月17日更新 ----------------------------

注意注意!!!

筆者在近期開通了我的公衆號,主要分享編程,讀書筆記,思考類的文章。

  • 編程類文章:包括筆者之前發佈的精選技術文章,以及後續發佈的技術文章(以原創爲主),而且逐漸脫離 iOS 的內容,將側重點會轉移到提升編程能力的方向上。
  • 讀書筆記類文章:分享編程類思考類心理類職場類書籍的讀書筆記。
  • 思考類文章:分享筆者平時在技術上生活上的思考。

由於公衆號天天發佈的消息數有限制,因此到目前爲止尚未將全部過去的精選文章都發布在公衆號上,後續會逐步發佈的。

並且由於各大博客平臺的各類限制,後面還會在公衆號上發佈一些短小精幹,以小見大的乾貨文章哦~

掃下方的公衆號二維碼並點擊關注,期待與您的共同成長~

公衆號:程序員維他命
相關文章
相關標籤/搜索