# MGradientLineDraw **Repository Path**: iCodingMan/MGradientLineDraw ## Basic Information - **Project Name**: MGradientLineDraw - **Description**: 渐变折线图绘制Demo - **Primary Language**: Objective-C - **License**: MIT - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 3 - **Forks**: 1 - **Created**: 2019-09-03 - **Last Updated**: 2023-09-18 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README > CAGradientLayer是CALayer的一个子类,包含在QuartzCore框架中,支持两种或更多颜色平滑渐变,其绘制使用了硬件加速。本章我们介绍下如何使用CAGradientLayer绘制渐变折线图。 ## 属性说明 CAGradientLayer共有5个主要属性: ```swift /* 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; ``` ## 基础渐变 实现颜色渐变非常简单,只需设置`colors`、`startPoint`和`endPoint`属性即可。 `startPoint`和`endPoint`决定了渐变的方向,这两个参数是以单位坐标系进行的定义,所以左上角坐标是`{0, 0}`,右下角坐标是`{1, 1}`。 下面是实现紫红和淡红对角线渐变代码: ```swift @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 ``` 效果如图: ![均匀渐变](https://images.gitee.com/uploads/images/2019/0908/140250_29e6cfd5_90754.png) 修改渐变位置: ```swift // 设置渐变分割位置 _gradientLayer.locations = @[@0.0, @0.35,@1.0]; ``` 效果如图: ![非均匀渐变](https://images.gitee.com/uploads/images/2019/0908/140250_867d0580_90754.png) > 如果你愿意,colors属性可以包含很多颜色,所以创建一个彩虹一样的多重渐变也是很简单的。默认情况下,这些颜色在空间上均匀地被渲染,但是我们可以用locations属性来调整空间。locations属性是一个浮点数值的数组(以NSNumber包装)。这些浮点数定义了colors属性中每个不同颜色的位置,同样的,也是以单位坐标系进行标定。0.0代表着渐变的开始,1.0代表着结束。 locations数组并不是强制要求的,但是如果你给它赋值了就一定要确保locations的数组大小和colors数组大小一定要相同,否则你将会得到一个空白的渐变。 ## 目标动画效果 熟悉CAGradientLayer的基础用法后,我们结合其他Layer实现下图动画展示的复杂渐变折线: ![渐变折线图.gif](https://images.gitee.com/uploads/images/2019/0908/140250_6c375cd8_90754.gif) 如图:Y轴是数值,X轴是日期,每个日期值对应一个小红点记录(红点支持点击),小红点连成折线,折线以下区域根据Y轴值域进行渐变,从透明到淡紫红色,折线图在记录较多时支持左右滑动 ## 准备JSON数据 下面是一组服务器的肤质记录数据,包含测肤时间、照片分数、测试记录id。 ```swift { 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`如下: ```swift @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序列化 提取上述`JSON`的`list`数组并进行对象序列化 ```swift 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`* 图层结构如下: ![图层结构](https://images.gitee.com/uploads/images/2019/0908/140250_8a0199cc_90754.png) ## 开始绘图 ### 1、绘制底层背景 按照上述图层设计,我们从下往上先绘制底层的背景如下: ![底层坐标轴](https://images.gitee.com/uploads/images/2019/0908/140250_9b3c959c_90754.png) 如图,最底层视图主要由Y坐标、虚线、实线和白色背景组成,我们依次绘制: #### 1.1、绘制Y坐标轴 ```swift - (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、绘制辅助虚线 ```swift - (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、绘制底部实线 ```swift - (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`背景色 ![添加UIScrollView](https://images.gitee.com/uploads/images/2019/0908/140250_89ba5ca7_90754.png) 布局如下: ```swift @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`高度,宽度根据折线图绘制的宽度计算。 ```swift _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)); }]; ``` 效果如图: ![画板布局图](https://images.gitee.com/uploads/images/2019/0908/140251_f5b97c4b_90754.gif) #### 3.1、背景图层接口配置 背景图层接口调用进行绘制更新时,需要根据记录个数调整画板的宽度,记录过少时在画板绘制区间均匀分布,记录过多时固定宽度分布,左右滚动查看 ```swift /** 折线图容器,包含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刷新。 ```swift /** 画板,用于折线图内容绘制 */ @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、设置画板渐变和折线图层 ```swift @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路径设置和按钮、标签坐标更新放到一起计算 ```swift - (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]; } ``` 最终完成效果同上: ![渐变折线图.gif](https://images.gitee.com/uploads/images/2019/0908/140250_6c375cd8_90754.gif) 注:实际上画板上的渐变、折线、按钮和标签都是相互独立的部分,经过组合得到图示效果。 ## 源代码 附上[Demo](https://gitee.com/iCodingBoy_admin/MGradientLineDraw.git)源代码