iOS程序猿iOS

CoreGraphics绘制图表教程(二)

2018-12-06  本文已影响12人  ChinaChong

本篇介绍如何使用CoreGraphics绘制一个折线图

有了上一篇绘制坐标系的基础,画一个折线图就变得非常简单了。上一篇的坐标系为了浅显易懂,并没有将代码和属性提炼出来,现在为了写代码方便需要把上一篇坐标系的类 CoordinateSystem 代码进行优化。逻辑思路都没有变,只是将代码整合了一下。

如下:
.h文件

#import <UIKit/UIKit.h>

/**
 一个简单的网格坐标系,带十字交叉线
 */
@interface CoordinateSystem : UIView

/**
 边框颜色
 */
@property (nonatomic, strong) UIColor *borderColor;

/**
 X轴颜色
 */
@property (nonatomic, strong) UIColor *axisXColor;

/**
 Y轴颜色
 */
@property (nonatomic, strong) UIColor *axisYColor;

/**
 网格线 纬线颜色
 */
@property (nonatomic, strong) UIColor *latitudeColor;

/**
 网格线 经线颜色
 */
@property (nonatomic, strong) UIColor *longitudeColor;

/**
 纬线刻度字体颜色
 */
@property (nonatomic, strong) UIColor *latitudeFontColor;

/**
 经线刻度字体颜色
 */
@property (nonatomic, strong) UIColor *longitudeFontColor;

/**
 十字交叉线颜色
 */
@property (nonatomic, strong) UIColor *crossLinesColor;

/**
 十字交叉线刻度字体颜色
 */
@property (nonatomic, strong) UIColor *crossLinesFontColor;

/**
 X轴标题
 */
@property (nonatomic, strong) NSMutableArray *latitudeTitles;

/**
 Y轴标题
 */
@property (nonatomic, strong) NSMutableArray *longitudeTitles;

/**
 轴线下边距
 */
@property (nonatomic, assign) CGFloat axisMarginBottom;

/**
 轴线左边距
 */
@property (nonatomic, assign) CGFloat axisMarginLeft;

/**
 轴线上边距
 */
@property (nonatomic, assign) CGFloat axisMarginTop;

/**
 轴线右边距
 */
@property (nonatomic, assign) CGFloat axisMarginRight;

/**
 纬线标题字体
 */
@property (nonatomic, strong) UIFont *latitudeFont;

/**
 经线标题字体
 */
@property (nonatomic, strong) UIFont *longitudeFont;

/**
 十字交叉线刻度字体
 */
@property (nonatomic, strong) UIFont *crossLinesFont;

/**
 单点触控的选中点
 */
@property(assign, nonatomic ,setter = setSingleTouchPoint:) CGPoint singleTouchPoint;

/**
 边框线宽
 */
@property (nonatomic, assign) CGFloat borderWidth;

/**
 XY轴线宽
 */
@property (nonatomic, assign) CGFloat axisXYWidth;

/**
 经线宽
 */
@property (nonatomic, assign) CGFloat longitudeWidth;

/**
 纬线宽
 */
@property (nonatomic, assign) CGFloat latitudeWidth;

/**
 十字交叉线宽
 */
@property (nonatomic, assign) CGFloat crossLinesWidth;

/**
 经线数量
 */
@property (nonatomic, assign) NSUInteger longitudeCount;

/**
 纬线数量
 */
@property (nonatomic, assign) NSUInteger latitudeCount;

/**
 显示经线
 */
@property (nonatomic, assign) BOOL displayLongitude;

/**
 显示纬线
 */
@property (nonatomic, assign) BOOL displayLatitude;

/**
 显示X轴刻度
 */
@property (nonatomic, assign) BOOL displayXTitles;

/**
 显示Y轴刻度
 */
@property (nonatomic, assign) BOOL displayYTitles;

- (void)initProperty;
- (void)drawData:(CGRect)rect;

@end



.m文件中的 drawRect: 方法

- (void)drawRect:(CGRect)rect {
    //清理当前画面,设置背景色
    CGContextRef context = UIGraphicsGetCurrentContext();
    CGContextSetFillColorWithColor(context, self.backgroundColor.CGColor);
    CGContextFillRect(context, rect);
    
    //消除锯齿
    CGContextSetAllowsAntialiasing(context, YES);
    
    //绘制边框
    [self drawBorder:rect];

    //绘制XY轴
    [self drawXAxis:rect];
    [self drawYAxis:rect];

    //绘制纬线
    [self drawLatitudeLines:rect];
    //绘制经线
    [self drawLongitudeLines:rect];
    //绘制数据
    [self drawData:rect];
    //绘制X轴标题
    [self drawXAxisTitles:rect];
    //绘制Y轴标题
    [self drawYAxisTitles:rect];

    // 绘制十字交叉线
    [self drawCrossLines:rect];
    
}

.h 文件中的所有属性都是在上一篇出现过然后提炼出来的,我逐个加了注释,很容易看的懂。

后面加了两个个方法,分别是:

绘制折线图

下面是折线图的最终效果图

折线图最终效果


先说一下画折线图的整体思路吧,直接上代码有点仓促,显得不够意思,还得让各位客官自己理解。

首先新建一个继承自 CoordinateSystem 名为 LineChart 的类,这就是折线图的类,用来绘制并显示我们要的折线图。

折线图里要显示的就是那几条代表走势的折线,所以创建一个名为 LineData 的数据类,每一个 LineData 表示一条折线。

一条折线需要 n 个点连接而成,所以创建一个名为 LinePointData 的数据类,用来表示折线每一个点的信息。

好了,现在我们有了三个关键的类,接下来要给这三个类加点属性。

折线图 LineChart 需要折线的数据源,所以加一个数据源数组,里面保存的是 n 条你将要绘制的折线的数据 LineData

#import "CoordinateSystem.h"

@class LineData;
@interface LineChart : CoordinateSystem

/**
 LineData数据源
 */
@property (nonatomic, strong) NSMutableArray<LineData *> *linesArray;

@end

折线 LineData 需要所有点的数据,所以加一个点的数据源数组,里面保存的是 n 个绘制折线需要的点的信息数据。既然 LineData 表示的是整条折线,那么折线的线宽 CGFloat lineWidth 和颜色 UIColor *lineColor 也在这里保存。

#import <UIKit/UIKit.h>

@class LinePointData;
@interface LineData : NSObject

@property (nonatomic, strong) NSMutableArray<LinePointData *> *linePointsDataArray;
@property (nonatomic, strong) UIColor *lineColor;
@property (nonatomic, assign) CGFloat lineWidth;

@end

折线上的点 LinePointData 只需要保存这一点的XY轴信息即可

#import <UIKit/UIKit.h>

@interface LinePointData : NSObject

@property (nonatomic, copy) NSString *valueForX;
@property (nonatomic, copy) NSString *valueForY;

- (instancetype)initWithValueForX:(NSString *)valueForX valueForY:(NSString *)valueForY;

@end


梳理完整体结构,下面要搞个坐标系出来了。看过上一篇绘制坐标系的知道,其实只要给定XY轴数据源,坐标系就可以画出来了。

但是有个问题就是,当折线图用来展示例如股票或者会有大量数据进来的时候,那我们还都要一一将刻度显示在XY轴上吗?显然不能够啊,如果几万几十万条数据的话,那XY轴万千的刻度岂不是成了张飞的络腮胡。

所以,这里需要优化一下,就是加了两个属性:NSUInteger longitudeCountNSUInteger latitudeCount。用来限制我们要显示的经纬线和对应刻度的绘制数量。

确定坐标系要先有数据源,所以在 ViewController 中先给出数据:

NSMutableArray *points = [NSMutableArray array];

[points addObject:[[LinePointData alloc] initWithValueForX:@"11/19" valueForY:@"4000"]];
[points addObject:[[LinePointData alloc] initWithValueForX:@"11/21" valueForY:@"3987"]];
[points addObject:[[LinePointData alloc] initWithValueForX:@"12/3"  valueForY:@"3102"]];
[points addObject:[[LinePointData alloc] initWithValueForX:@"12/10" valueForY:@"3567"]];
[points addObject:[[LinePointData alloc] initWithValueForX:@"12/17" valueForY:@"3456"]];
[points addObject:[[LinePointData alloc] initWithValueForX:@"12/24" valueForY:@"3001"]];
[points addObject:[[LinePointData alloc] initWithValueForX:@"1/7"   valueForY:@"3293"]];

LineData *line = [[LineData alloc] init];
line.linePointsDataArray = points;
line.lineColor = [UIColor blueColor];
line.lineWidth = 1;

NSMutableArray *lines = [NSMutableArray array];
[lines addObject:line];

LineChart *lineChart = [[LineChart alloc] initWithFrame:CGRectMake(50, 100, self.view.bounds.size.width - 50*2, self.view.bounds.size.width - 50*2)];

lineChart.linesArray = lines;
lineChart.backgroundColor = [UIColor clearColor];
lineChart.displayXTitles = YES;
lineChart.displayYTitles = YES;
lineChart.displayLatitude = YES;
lineChart.displayLongitude = YES;
[self.view addSubview:lineChart];

有了源数据之后,我们需要按照需求把数据进行处理,变成符合XY轴刻度要求的数据,然后只需要把数据灌进 CoordinateSystem 类里面就可以画出坐标系了,所以在折线图 LineChart 里面先处理源数据:

- (void)drawRect:(CGRect)rect {
    [self initAxisY];
    [self initAxisX];
    // 初始化父类 drawRect
    [super drawRect:rect];
}

initAxisY 方法是用来确定Y轴刻度和纬线的数据,initAxisX 是用来确定X轴刻度和经线的数据。这两组数据确定完之后,在调用父类 CoordinateSystemdrawRect: 方法就可以完成坐标系的绘制。


先说 initAxisY 方法。

initAxisY 方法最核心的一点是要确定Y轴显示的数据范围,即所有点中最大值和最小值是多少。因为我们不能画着画着发现折线图的峰值超过了们刻度能表示的范围。

另外,Y轴的刻度要给出一些预留空间,为了美观不能让折线的点画到了坐标系的最顶或者最底,所以要给最大值和最小值修改一下范围。最大值扩大10%,最小值缩小10%,这样折线不会顶格。

确定范围最后一步就是要让纬线在均分后最好能够整除,比如最大值4000,最小值0,让你画3条纬线,4000除以3是多少?每个刻度都显示一大堆小数吗? 看起来不美观,所以最后再把最大值和最小值进行可以整除的处理。

经过三步处理,把最大值和最小值修改了一通之后,Y轴的刻度也就都确定了。根据需要显示的个数,把最大值和最小值差值这个取值范围均分,把每一级的数据放到数组中就搞定了Y轴的刻度。

- (void)initAxisY {
    // 计算Y轴取值范围
    [self calcValueRangeForY];
    
    if (self.maxValue == 0.0f && self.minValue == 0.0f) {
        self.latitudeTitles = nil;
        return;
    }
    
    NSMutableArray *TitleY = [[NSMutableArray alloc] init];
    // 纬线间距代表的数值范围
    long average = (long) ((self.maxValue - self.minValue) / self.latitudeCount);
    
    // 处理刻度,可以在这里处理刻度的缩放显示比例
    for (int i = 0; i < self.latitudeCount; i++) {
        long degree = floor(self.minValue + i * average);
        NSString *value = [[NSNumber numberWithInteger:degree] stringValue];
        [TitleY addObject:value];
    }
    
    // 最后在纬线标题数组中再加上一个最大值的标题
    long degree = (long)self.maxValue;
    NSString *value = [[NSNumber numberWithInteger:degree] stringValue];
    [TitleY addObject:value];
    
    // 设置Y轴标题数组
    self.latitudeTitles = TitleY;
}
#pragma mark - 计算Y轴取值范围
- (void)calcValueRangeForY {
    if (self.linesArray && self.linesArray.count) {
        // 第一步:确定数据源中的最大值和最小值
        [self calcMaxAndMin];
        // 第二步:扩大最大值和最小值范围
        [self enlargeMaxAndMinRange];
        // 第三步:去 “毛茬”
        [self trimMaxAndMin];
    }
    else {
        self.maxValue = 0;
        self.minValue = 0;
    }
}

// MARK:第一步:确定数据源中的最大值和最小值
- (void)calcMaxAndMin {
    CGFloat maxValue = 0;
    CGFloat minValue = LONG_MAX;
    
    for (LineData *line in self.linesArray) {
        if (line && line.linePointsDataArray && line.linePointsDataArray.count) {
            for (LinePointData *linePoint in line.linePointsDataArray) {
                if (linePoint.valueForY.floatValue < minValue) {
                    minValue = linePoint.valueForY.floatValue;
                }
                
                if (linePoint.valueForY.floatValue > maxValue) {
                    maxValue = linePoint.valueForY.floatValue;
                }
            }
        }
    }
    self.maxValue = maxValue;
    self.minValue = minValue;
}

// MARK:第二步:扩大最大值和最小值范围
- (void)enlargeMaxAndMinRange {
    CGFloat maxValue = self.maxValue;
    CGFloat minValue = self.minValue;
    
    if (maxValue > minValue) {
        // 10以内的上下限扩大 1
        if ((maxValue - minValue) < 10.0f && minValue > 1.0f) {
            self.maxValue = (long)maxValue + 1;
            self.minValue = (long)minValue - 1;
        } else {
            // 10以上的上下限扩大 10%
            self.maxValue = (long)(maxValue + (maxValue - minValue) * 0.1);
            self.minValue = (long)(minValue - (maxValue - minValue) * 0.1);
            
            if (self.minValue < 0) {
                self.minValue = 0;
            }
        }
    }
    else if (maxValue == minValue) {
        /*
         2 - 10               1
         11 - 100             10
         101 - 1000           100
         1001 - 10000         1000
         ...
         */
        // 按照区间,上下限扩大 10%
        long power = 0;
        long temp = (long)maxValue;
        while (temp > 10) {
            temp = temp / 10;
            power++;
        }
        
        long enlargeValue = (long)(pow(10, power));
        self.maxValue = (long)maxValue + enlargeValue;
        self.minValue = (long)minValue - enlargeValue;
        if (self.minValue < 0) {
            self.minValue = 0;
        }
    }
    else {
        self.maxValue = 0;
        self.minValue = 0;
    }
}

// MARK:第三步:去 “毛茬”
- (void)trimMaxAndMin {
    /*
     范围           取余基准值
     1-99           %1
     100-999        %10
     1000-9999      %100
     10000-99999    %1000
     ...
     */
    long power = 0;
    long temp = (long)self.maxValue;
    while (temp >= 100) {
        temp = temp / 10;
        power++;
    }
    // 用来计算扣除部分的取余基准值
    long deduction = (long)pow(10, power);
    
    // 处理最小值
    if (self.latitudeCount > 0 && deduction > 1 && (long)self.minValue % deduction != 0) {
        self.minValue = (long)self.minValue - ((long)(self.minValue) % deduction);
    }
    
    // 处理最大值,为了让纬线刻度等分,取余基准值再乘以纬线份数
    if (self.latitudeCount > 0 && (long)(self.maxValue - self.minValue) % (self.latitudeCount * deduction) != 0) {
        self.maxValue = (long)self.maxValue + (self.latitudeCount * deduction) - (long)(self.maxValue - self.minValue) % (self.latitudeCount * deduction);
    }
}

接下来说说 initAxisX 方法。

相比 initAxisY 方法而言 initAxisX 方法的思路基本一致。由于X轴是显示的横向标题,所以不能做最大值和最小值的显示方式。在需要绘制 n 条折线的时候,我这里是以第一条线的刻度为准,具体需求具体分析,需要显示什么还得看你的需求是怎样的。

由于本人很懒,没有对X轴刻度进行处理,我是有几个刻度就显示几个刻度。但是在数据量大的时候这样做显然是不合适的。所以有需要的同学要给X轴的刻度也做一下处理,最好不要全部都显示出来。比如你有1000条数据,而X轴刻度和经线只需要显示5个的时候,每隔200条取出一个数据赋值给 CoordinateSystem 标题数组就行了。

// 以第一条线作为X轴标识
- (void)initAxisX {
    NSMutableArray *TitleX = [[NSMutableArray alloc] init];
    if (self.linesArray && self.linesArray.count) {
        LineData *line = self.linesArray.firstObject;
        if (line.linePointsDataArray && line.linePointsDataArray.count) {
            for (LinePointData *linePoint in line.linePointsDataArray) {
                [TitleX addObject:[NSString stringWithFormat:@"%@", linePoint.valueForX]];
            }
        }
    }
    self.longitudeTitles = TitleX;
}

XY 轴的刻度数据都确定了之后,就可以直接调用父类 CoordinateSystemdrawRect: 方法进行坐标系的绘制了。

在父类的 drawRect: 方法中我们调用了一个名为 drawData: 的预留方法,这就是现在要用到的接口。绘制折线图就覆写这个方法,在这里进行绘制折线。

画折线你只要计算好每一个点的XY坐标就可以正常的画出来了。

X轴坐标呢,要先确定各点之间的间距,比如你有8个点,那么X轴方向你就要把坐标系宽度均分成7份,然后循环累加间距就是每个点的X轴坐标。

Y轴坐标呢,要联系刚刚计算过的取值范围。比如坐标系的取值范围我们计算出来下限是2000上限是4000,你现在要确定一个值为2500的Y轴坐标,整个范围的大小是2000内浮动,所以用2500-2000得到500,500在整个范围里占比多少,再乘以坐标系的高度就是它的Y轴坐标了。

#pragma mark - 绘制折线
- (void)drawData:(CGRect)rect {
    if (!self.linesArray || !self.linesArray.count) return;
    
    CGContextRef context = UIGraphicsGetCurrentContext();
    CGContextSetAllowsAntialiasing(context, YES); // 消除锯齿
    
    // 经线间距
    CGFloat longitudeSpacing = 0;
    // 坐标
    __block CGFloat valueX = 0;
    __block CGFloat lastY = 0;
    
    // 逐条绘制
    for (LineData *line in self.linesArray) {
        if (!line || !line.linePointsDataArray || line.linePointsDataArray.count < 2) continue;
        // 配置线条
        CGContextSetStrokeColorWithColor(context, line.lineColor.CGColor);
        CGContextSetLineWidth(context, line.lineWidth);
        
        longitudeSpacing = (rect.size.width - self.axisMarginLeft - self.axisMarginRight) / (line.linePointsDataArray.count - 1);
        
        valueX = self.axisMarginLeft;
        
        [line.linePointsDataArray enumerateObjectsUsingBlock:^(LinePointData * _Nonnull point, NSUInteger idx, BOOL * _Nonnull stop) {
            // 计算点的Y坐标
            CGFloat valueY = (1 - (point.valueForY.floatValue - self.minValue) / (self.maxValue - self.minValue)) * (rect.size.height - self.axisMarginTop - self.axisMarginBottom) + self.axisMarginTop;
            // 第一个初始点不画线
            if (idx == 0) {
                CGContextMoveToPoint(context, valueX, valueY);
                lastY = valueY;
            }
            else {
                CGContextAddLineToPoint(context, valueX, valueY);
                lastY = valueY;
            }
            // X坐标移动
            valueX = valueX + longitudeSpacing;
        }];
        // 绘制路径
        CGContextStrokePath(context);
    }
}



最后再来看一下效果图:

折线图最终效果

Github示例源码

链接地址:CoreGraphicsDrawChart

上一篇下一篇

猜你喜欢

热点阅读