使用CAGradientLayer实现颜色渐变折线图

CAGradientLayer是CALayer的一个子类,包含在QuartzCore框架中,支持两种或更多颜色平滑渐变,其绘制使用了硬件加速。本章我们介绍下如何使用CAGradientLayer绘制渐变折线图。

属性说明

CAGradientLayer共有5个主要属性:

/* 
CGColorRef对象数组,定义了每个渐变区间的渐变色 
*/
@property(nullable, copy) NSArray *colors;

/* 
NSNumber对象的可选数组定义了[0,1]范围内的每个渐变停止的位置,值必须是单调增加的。
如果给出一个nil数组,则渐变在[0,1]范围内均匀分布。
渲染时,颜色在插值之前映射到输出空间。 默认为nil。
*/
@property(nullable, copy) NSArray *locations;

/*
绘制到图层坐标空间时渐变的起点和终点。
起点对应于第一个梯度停止,终点对应于最后一个梯度停止。
两个点都在单位坐标空间中定义,然后在绘制时将其映射到图层的边界矩形。
边界矩形即[0,0]是左下角图层的一角,[1,1]是右上角。
默认值分别是[.5,0]和[.5,1]。 
*/
@property CGPoint startPoint;
@property CGPoint endPoint;

/*
渐变绘制类型,可设置`axial' (默认值), `radial', and `conic'.
*/
@property(copy) CAGradientLayerType type;

基础渐变

实现颜色渐变非常简单,只需设置colorsstartPointendPoint属性即可。
startPointendPoint决定了渐变的方向,这两个参数是以单位坐标系进行的定义,所以左上角坐标是{0, 0},右下角坐标是{1, 1}

下面是实现紫红和淡红对角线渐变代码:

@interface ViewController ()
@property (nonatomic, strong) UIView *gradientContentView;
@property (nonatomic, strong) CAGradientLayer *gradientLayer;
@end

@implementation ViewController

- (void)makeConstraints
{
    __weak typeof(self) weakSelf = self;
    _gradientContentView = [[UIView alloc]init];
    [self.view addSubview:_gradientContentView];
    [self.gradientContentView mas_makeConstraints:^(MASConstraintMaker *make) {
        __strong typeof(weakSelf) strongSelf = weakSelf;
        make.centerX.equalTo(strongSelf.view.mas_centerX);
        make.centerY.equalTo(strongSelf.view.mas_centerY);
        make.width.equalTo(@(240));
        make.height.equalTo(@(300));
    }];
}

- (void)viewDidLayoutSubviews
{
    [super viewDidLayoutSubviews];
    _gradientLayer.frame = self.gradientContentView.bounds;
}

- (void)viewDidLoad {
    [super viewDidLoad];
    self.view.backgroundColor = [UIColor whiteColor];
    [self makeConstraints];
    
    _gradientLayer = [CAGradientLayer layer];
    // 设置渐变颜色值
    _gradientLayer.colors = @[(__bridge id)[UIColor colorWithHexString:@"#FF4275"].CGColor,
                              (__bridge id)[UIColor colorWithHexString:@"#FDF4FA"].CGColor];
    // 设置开始位置和结束点
    _gradientLayer.startPoint = CGPointMake(0, 0);
    _gradientLayer.endPoint = CGPointMake(1, 1);
    [self.gradientContentView.layer addSublayer:_gradientLayer];
}


@end

效果如图:

使用CAGradientLayer实现颜色渐变折线图_第1张图片
均匀渐变

修改渐变位置:

// 设置渐变分割位置
_gradientLayer.locations = @[@0.0, @0.35,@1.0];

效果如图:

使用CAGradientLayer实现颜色渐变折线图_第2张图片
非均匀渐变

如果你愿意,colors属性可以包含很多颜色,所以创建一个彩虹一样的多重渐变也是很简单的。默认情况下,这些颜色在空间上均匀地被渲染,但是我们可以用locations属性来调整空间。locations属性是一个浮点数值的数组(以NSNumber包装)。这些浮点数定义了colors属性中每个不同颜色的位置,同样的,也是以单位坐标系进行标定。0.0代表着渐变的开始,1.0代表着结束。
locations数组并不是强制要求的,但是如果你给它赋值了就一定要确保locations的数组大小和colors数组大小一定要相同,否则你将会得到一个空白的渐变。

目标动画效果

熟悉CAGradientLayer的基础用法后,我们结合其他Layer实现下图动画展示的复杂渐变折线:

使用CAGradientLayer实现颜色渐变折线图_第3张图片
渐变折线图.gif

如图:Y轴是数值,X轴是日期,每个日期值对应一个小红点记录(红点支持点击),小红点连成折线,折线以下区域根据Y轴值域进行渐变,从透明到淡紫红色,折线图在记录较多时支持左右滑动

准备JSON数据

下面是一组服务器的肤质记录数据,包含测肤时间、照片分数、测试记录id。

{
    avgScore = 83;
    list =     (
                {
            measureTime = "2019-03-25 17:46:46";
            photoScore = 64;
            testHistoryId = 1594780;
        },
                {
            measureTime = "2019-01-25 15:01:13";
            photoScore = 89;
            testHistoryId = 1359786;
        },
                {
            measureTime = "2019-01-24 16:34:30";
            photoScore = 90;
            testHistoryId = 1355844;
        },
                {
            measureTime = "2019-01-02 15:18:56";
            photoScore = 74;
            testHistoryId = 1271310;
        },
                {
            measureTime = "2018-12-29 15:00:02";
            photoScore = 80;
            testHistoryId = 1255037;
        },
                {
            measureTime = "2018-12-28 09:50:38";
            photoScore = 78;
            testHistoryId = 1250126;
        },
                {
            measureTime = "2018-12-27 16:54:13";
            photoScore = 85;
            testHistoryId = 1248122;
        },
                {
            measureTime = "2018-12-26 17:53:59";
            photoScore = 84;
            testHistoryId = 1244881;
        },
                {
            measureTime = "2018-11-07 09:45:26";
            photoScore = 80;
            testHistoryId = 1033831;
        },
                {
            measureTime = "2018-10-30 16:48:12";
            photoScore = 82;
            testHistoryId = 1000827;
        },
                {
            measureTime = "2018-10-29 19:42:25";
            photoScore = 76;
            testHistoryId = 996757;
        },
                {
            measureTime = "2018-08-21 17:49:42";
            photoScore = 88;
            testHistoryId = 690325;
        },
                {
            measureTime = "2018-08-17 14:38:39";
            photoScore = 90;
            testHistoryId = 672420;
        },
                {
            measureTime = "2018-08-16 20:03:22";
            photoScore = 89;
            testHistoryId = 669666;
        },
                {
            measureTime = "2018-08-10 20:47:43";
            photoScore = 90;
            testHistoryId = 642235;
        },
                {
            measureTime = "2018-07-19 17:27:44";
            photoScore = 88;
            testHistoryId = 537337;
        },
                {
            measureTime = "2018-07-18 16:43:24";
            photoScore = 89;
            testHistoryId = 533668;
        },
                {
            measureTime = "2018-07-17 21:03:19";
            photoScore = 92;
            testHistoryId = 530970;
        }
    );
    maxScore = 92;
}

定义数据模型

1、定义数据模型

针对以上JSON数据,定义数据模型MGLDRecordModel如下:

@interface MGLDRecordModel : NSObject
@property (nonatomic, strong) NSString *testHistoryId;
@property (nonatomic, strong) NSNumber *photoScore;
@property (nonatomic, strong) NSString *measureTime;
// 按照图表 `月/日` 进行时间格式化
- (NSString*)formattedTime;
@end

@implementation MGLDRecordModel
- (NSString*)formattedTime
{
    if (self.measureTime) {
        return nil;
    }
    NSDate *date = [NSDate dateWithString:self.measureTime formatString:@"yyyy-MM-dd HH:mm:ss"];
    return [NSString stringWithFormat:@"%@/%@",@(date.month),@(date.day)];
}
@end

2、JSON-Model序列化

提取上述JSONlist数组并进行对象序列化

NSDictionary *jsonObject = [self getResponseJSON];
if (jsonObject && [jsonObject.allKeys containsObject:@"list"]) 
{
    NSArray *list = jsonObject[@"list"];
        
    NSMutableArray *tmpArray = [[NSMutableArray alloc]init];
    for (int index = 0; index < list.count; index++) {
       NSDictionary *param = list[index];
         MGLDRecordModel *aModel = [MGLDRecordModel modelWithJSON:param];
        if (aModel) {
             [tmpArray addObject:aModel];
         }
    }
        // 绘图
}

设计图层结构

从目标动画gif图可以看出,折线图的宽度大于可见区域,不能一次性全部显示,X轴的日期跟随折线图移动,Y轴和背景虚实线保持静止,可拆解主要组成元素为以下三部分:

  • 底部背景视图(MGLDHistoryGraphView类)

背景图层主要用于显示Y坐标、虚实线,并容纳滚动容器,本例中Y坐标使用UILabel标签布局组成,虚实线采用Draw方法绘制.

  • 中间滚动容器(UIScrollView类)

中间滚动容器采用UIScrollView,边界为父容器的Y轴右边边界区域,contentSize与画板size保持一直

  • 核心画板(MGLDDrawingBoard类)

画板用于绘制渐变区域、渐变折线,容纳交互按钮、X轴日期标签等,画板的宽度根据记录的数量动态计算

视图类自底而上依次为MGLDHistoryGraphView->UIScrollView->MGLDDrawingBoard

图层结构如下:

使用CAGradientLayer实现颜色渐变折线图_第4张图片
图层结构

开始绘图

1、绘制底层背景

按照上述图层设计,我们从下往上先绘制底层的背景如下:


使用CAGradientLayer实现颜色渐变折线图_第5张图片
底层坐标轴

如图,最底层视图主要由Y坐标、虚线、实线和白色背景组成,我们依次绘制:

1.1、绘制Y坐标轴

- (void)drawYAxisValuesInRect:(CGRect)rect
{
    CGFloat height = self.bounds.size.height;
    // static CGFloat KTopSpaceValue = 20.0f; static CGFloat KBottomSpaceValue = 34.0f;
    CGFloat drawContainerHeight = height - KTopSpaceValue - KBottomSpaceValue;
    CGFloat dashLinePadding = drawContainerHeight / 5.0;
    
    NSArray *yAxisValue = @[@"100",@"80",@"60",@"40",@"20"];
    NSUInteger count = yAxisValue.count;
    for (int index = 0; index < count; index++)
    {
        CGFloat originY = KTopSpaceValue + index*dashLinePadding - 9;
        UILabel *label = [[UILabel alloc]initWithFrame:CGRectMake(0, originY, KYAxisLeftSpaceValue-5, 18)];
        label.textAlignment = NSTextAlignmentRight;
        label.backgroundColor = [UIColor clearColor];
        label.font = [UIFont systemFontOfSize:12];
        label.textColor = [UIColor colorWithWhite:0.5  alpha:1.0];
        label.text = yAxisValue[index];
        [self addSubview:label];
    }
}

1.2、绘制辅助虚线

- (void)drawAuxiliaryDashedLineInRect:(CGRect)rect
{
    CGFloat height = self.bounds.size.height;
    CGFloat width = self.bounds.size.width;
    CGFloat drawContainerHeight = height - KTopSpaceValue - KBottomSpaceValue;
    CGFloat dashLinePadding = drawContainerHeight / 5.0;
    
    [[UIColor colorWithHexString:@"#E0E1E7"]setStroke];
    UIBezierPath *path = [UIBezierPath bezierPath];
    path.lineWidth = 1.0;
    CGFloat dash[] = {6,5};
    [path setLineDash:dash count:2 phase:0];
    for (int index = 0; index < 5; index ++) {
        [path moveToPoint:CGPointMake(KYAxisLeftSpaceValue, KTopSpaceValue + index*dashLinePadding)];
        [path addLineToPoint:CGPointMake(width, KTopSpaceValue + index*dashLinePadding)];
    }
    [path stroke];
}

1.3、绘制底部实线

- (void)drawBottomLineInRect:(CGRect)rect
{
    CGFloat height = self.bounds.size.height;
    CGFloat width = self.bounds.size.width;
    
    UIBezierPath *path = [UIBezierPath bezierPath];
    
    [path moveToPoint:CGPointMake(KYAxisLeftSpaceValue, height - KBottomSpaceValue)];
    [path addLineToPoint:CGPointMake(width, height - KBottomSpaceValue)];
    
    [[UIColor colorWithHexString:@"#E0E1E7"]setStroke];
    path.lineWidth = 1.0;
    [path stroke];
}

2、添加UIScrollView

添加UIScrollView,保持top、bottom、right与父视图一致,左边从Y坐标轴右边区域开始,背景透明,为了便于观察,我们添加#FF4275背景色

使用CAGradientLayer实现颜色渐变折线图_第6张图片
添加UIScrollView

布局如下:

@weakify(self);
_scrollView = [[UIScrollView alloc]init];
// 设置紫红背景色便于观察
_scrollView.backgroundColor = [UIColor colorWithHexString:@"#FF4275"];
_scrollView.showsVerticalScrollIndicator = NO;
_scrollView.showsHorizontalScrollIndicator = NO;
[self addSubview:_scrollView];
[self.scrollView mas_makeConstraints:^(MASConstraintMaker *make) {
    @strongify(self);
    make.left.equalTo(self).offset(KYAxisLeftSpaceValue);
    make.right.top.bottom.equalTo(self);
}];

3、添加画板

画板是整个折线图的核心部分,用来绘制渐变色,X轴坐标、折线、交互按钮。

添加MGLDDrawingBoard画板到UIScrollView,设置边缘与UIScrollView保持一直,高度等于UIScrollView高度,宽度根据折线图绘制的宽度计算。

_drawingBoard = [[MGLDDrawingBoard alloc]init];
// 设置橙色背景色便于观察
_drawingBoard.backgroundColor = [UIColor orangeColor];   
[self.scrollView addSubview:_drawingBoard];
[self.drawingBoard mas_makeConstraints:^(MASConstraintMaker *make) {
    @strongify(self);
    make.edges.equalTo(self.scrollView);
    make.height.equalTo(self.scrollView.mas_height);
    // 初始默认宽度,绘制曲线时需要更新
    make.width.equalTo(@(10));
}];

效果如图:

使用CAGradientLayer实现颜色渐变折线图_第7张图片
画板布局图

3.1、背景图层接口配置

背景图层接口调用进行绘制更新时,需要根据记录个数调整画板的宽度,记录过少时在画板绘制区间均匀分布,记录过多时固定宽度分布,左右滚动查看

/**
 折线图容器,包含UIScrollView、画板以及其他元素
 */
@interface MGLDHistoryGraphView : UIView
- (void)drawGraphWithValues:(NSArray*)values;
@end

@implementation MGLDHistoryGraphView
- (void)drawGraphWithValues:(NSArray *)values
{
    NSUInteger count = values.count;
    self.itemCount = count;
    [self setNeedsLayout];
    // 绘制渐变折线图
    [self.drawingBoard drawGraphWithValues:values];
}

/*
 折线图在记录过少需要在可见屏幕均匀分布,记录过多左右滚动
 我们需要在此函数调用时刷新画板的边界
*/
- (void)layoutSubviews
{
    [super layoutSubviews];
    CGFloat width = CGRectGetWidth(self.bounds);
    // 如果可绘制的记录总宽度小于当前容器的宽度,则让记录在容器空间均匀分布
    if (self.itemCount*KItemSpaceValue < width)
    {
        [self.drawingBoard mas_updateConstraints:^(MASConstraintMaker *make) {
            make.width.equalTo(@(width));
        }];
    }
    else
    {
        CGFloat drawingBoardWidth = (self.itemCount+2)*KItemSpaceValue;
        [self.drawingBoard mas_updateConstraints:^(MASConstraintMaker *make) {
            make.width.equalTo(@(drawingBoardWidth));
        }];
    }
}
@end

3.2、画板接口配置

在画板页面,我们将交互按钮和X轴坐标分别用UIButton和UILabel进行展示,这里预先缓存控件用于后面的layout刷新。

/**
 画板,用于折线图内容绘制
 */
@interface MGLDDrawingBoard : UIView
- (void)drawGraphWithValues:(NSArray*)values;
@end

- (void)drawGraphWithValues:(NSArray *)values
{
// 加锁,防止重新绘制和layout布局同时调用造成资源访问冲突
    [_lock lock];
    self.drawValues = values;
    if (values.count > 0) {
         // 默认选中最后一个
        MGLDRecordModel *aModel = [values lastObject];
        self.selectedRecordId = aModel.testHistoryId;
    }
    // 移除按钮和标签
    [self.subviews makeObjectsPerformSelector:@selector(removeFromSuperview)];
    
    // 缓存画板上的按钮和标签
    NSMutableArray *buttonsArray = [NSMutableArray array];
    NSMutableArray *labelsArray = [NSMutableArray array];
    for (int index = 0; index < values.count; index++) {
        MGLDRecordModel *aModel = [values objectAtIndex:index];
        UIButton *button = [self buttonWithRecordId:aModel.testHistoryId tag:index];
        [self addSubview:button];
        [buttonsArray addObject:button];
        
        UILabel *label = [self labelWithText:[aModel formattedTime]];
        [self addSubview:label];
        [labelsArray addObject:label];
    }
    self.buttons = buttonsArray;
    self.labels = labelsArray;
    [_lock unlock];
    // 触发layout布局
    [self setNeedsLayout];
}

3.3、设置画板渐变和折线图层

@interface MGLDDrawingBoard ()
@property (nonatomic, strong) CAGradientLayer *gradientLayer;
@property (nonatomic, strong) CAShapeLayer *lineLayer;
@property (nonatomic, strong) NSArray *drawValues;
@property (nonatomic, strong) NSArray *buttons;
@property (nonatomic, strong) NSArray *labels;
@property (nonatomic, strong) NSLock *lock;
@property (nonatomic, strong) NSNumber *selectedRecordId;
@end

@implementation MGLDDrawingBoard
- (void)setUp
{
    self.backgroundColor = [UIColor clearColor];
    _lock = [[NSLock alloc]init];
    // 渐变图层
    _gradientLayer = [CAGradientLayer layer];
    NSArray *colors = @[(__bridge id)[UIColor colorWithHexString:@"#FF4275"].CGColor,
                        (__bridge id)[UIColor colorWithHexString:@"#FFC7D6"].CGColor,
                        (__bridge id)[UIColor colorWithHexString:@"#FFE4EC"].CGColor,
                        (__bridge id)[UIColor colorWithHexString:@"#FFF5F7"].CGColor,
                        (__bridge id)[UIColor whiteColor].CGColor];
    _gradientLayer.colors = colors;
    _gradientLayer.locations = @[@0.0,@0.4,@0.6,@0.8,@1.0];
    _gradientLayer.startPoint = CGPointMake(0.5,0.0);
    _gradientLayer.endPoint = CGPointMake(0.5,1.0);
    _gradientLayer.opacity = 0.5;
    [self.layer addSublayer:_gradientLayer];
    
    // 折线图层
    _lineLayer = [CAShapeLayer layer];
    _lineLayer.allowsEdgeAntialiasing = YES;
    _lineLayer.strokeColor = [UIColor colorWithHexString:@"#FF4275"].CGColor;
    _lineLayer.fillColor = [UIColor clearColor].CGColor;
    _lineLayer.lineWidth = 2.0;
    _lineLayer.lineJoin = kCALineJoinRound;
    _lineLayer.lineCap = kCALineCapRound;
    [self.layer addSublayer:_lineLayer];
}
@end

3.4、图层绘制和按钮标签布局

这里将layer路径设置和按钮、标签坐标更新放到一起计算

- (void)layoutSubviews
{
    [super layoutSubviews];
    // 调整layer的框架
    _lineLayer.frame = self.bounds;
    _gradientLayer.frame = self.bounds;
    
    [_lock lock];
    if (self.drawValues.count <= 0) {
        [_lock unlock];
        return;
    }
    
    CGFloat width = CGRectGetWidth(self.bounds);
    CGFloat height = CGRectGetHeight(self.bounds);
    CGFloat drawSpaceHeight = height - KTopSpaceValue - KBottomSpaceValue;
    
    
    MGLDRecordModel *firstModel = [self.drawValues firstObject];
    MGLDRecordModel *lastModel = [self.drawValues lastObject];
    
    CGFloat firstPointY = [self getOriginY:firstModel.photoScore.floatValue spaceHeight:drawSpaceHeight];
    CGFloat lastPointY = [self getOriginY:lastModel.photoScore.floatValue spaceHeight:drawSpaceHeight];
    
    UIBezierPath *gradientPath = [UIBezierPath bezierPath];
    UIBezierPath *linePath = [UIBezierPath bezierPath];
    [gradientPath moveToPoint:CGPointMake(-1, firstPointY)];
    [linePath moveToPoint:CGPointMake(0, firstPointY)];
    
    NSUInteger count = self.drawValues.count;
    CGFloat superWidth =  CGRectGetWidth(self.superview.frame);
    if (count*KItemSpaceValue < superWidth)
    {
        CGFloat padding = width/(count + 1);
        CGFloat originX = (width - MAX(0, count-1)*padding)/2;
        for (int index = 0; index < count; index++)
        {
            MGLDRecordModel *aModel = [self.drawValues objectAtIndex:index];
            CGFloat pointY = [self getOriginY:aModel.photoScore.floatValue spaceHeight:drawSpaceHeight];
            // 添加路径
            CGPoint pathPoint = CGPointMake(index*padding + originX, pointY);
            [gradientPath addLineToPoint:pathPoint];
            [linePath addLineToPoint:pathPoint];
            
            // 更新按钮和标签的center
            if (index < self.buttons.count) {
                UIButton *button = [self.buttons objectAtIndex:index];
                button.center = pathPoint;
            }
            CGPoint labelPoint = CGPointMake(index * padding + originX, height - KBottomSpaceValue*0.5);
            if (index < self.labels.count) {
                UILabel *label = [self.labels objectAtIndex:index];
                label.center = labelPoint;
            }
        }
    }
    else
    {
        for (int index = 0; index < count; index++)
        {
            MGLDRecordModel *aModel = [self.drawValues objectAtIndex:index];
            CGFloat pointY = [self getOriginY:aModel.photoScore.floatValue spaceHeight:drawSpaceHeight];
            CGPoint pathPoint = CGPointMake((index + 1)*KItemSpaceValue, pointY);
            [gradientPath addLineToPoint:pathPoint];
            [linePath addLineToPoint:pathPoint];
            if (index < self.buttons.count) {
                UIButton *button = [self.buttons objectAtIndex:index];
                button.center = pathPoint;
            }
            CGPoint labelPoint = CGPointMake((index + 1)*KItemSpaceValue, height - KBottomSpaceValue*0.5);
            if (index < self.labels.count) {
                UILabel *label = [self.labels objectAtIndex:index];
                [label sizeToFit];
                label.center = labelPoint;
            }
        }
    }
    
    [gradientPath addLineToPoint:CGPointMake(width+1, lastPointY)];
    [linePath addLineToPoint:CGPointMake(width, lastPointY)];
    
    // 构造渐变layer闭环
    [gradientPath addLineToPoint:CGPointMake(width+1, height - KBottomSpaceValue)];
    [gradientPath addLineToPoint:CGPointMake(-1, height - KBottomSpaceValue)];
    [gradientPath addLineToPoint:CGPointMake(-1, firstPointY)];
    
    CAShapeLayer *shapeLayer = [CAShapeLayer layer];
    shapeLayer.path = gradientPath.CGPath;
    _gradientLayer.mask = shapeLayer;
    _lineLayer.path = linePath.CGPath;
    
    // 设置选中状态,第一次排版时刷新布局并滚动到对应位置
    for (UIButton *button in self.buttons) {
        if (self.selectedRecordId && [button.recordId isEqualToNumber:self.selectedRecordId]) {
            button.selected = YES;
            CGFloat originX = button.frame.origin.x;
            UIScrollView *scrollView = (UIScrollView*)self.superview;
            if (scrollView.frame.size.width < scrollView.contentSize.width) {
                CGFloat width = scrollView.frame.size.width;
                CGFloat offsetX = MAX(0, originX + KItemSpaceValue-width);
                [(UIScrollView*)self.superview setContentOffset:CGPointMake(offsetX, 0) animated:YES];
            }
        }
        else
        {
            button.selected = NO;
        }
    }
    
    [_lock unlock];
}

最终完成效果同上:


使用CAGradientLayer实现颜色渐变折线图_第8张图片
渐变折线图.gif

注:实际上画板上的渐变、折线、按钮和标签都是相互独立的部分,经过组合得到图示效果。

源代码

附上Demo源代码

你可能感兴趣的:(使用CAGradientLayer实现颜色渐变折线图)