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;
基础渐变
实现颜色渐变非常简单,只需设置colors
、startPoint
和endPoint
属性即可。
startPoint
和endPoint
决定了渐变的方向,这两个参数是以单位坐标系进行的定义,所以左上角坐标是{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
效果如图:
修改渐变位置:
// 设置渐变分割位置
_gradientLayer.locations = @[@0.0, @0.35,@1.0];
效果如图:
如果你愿意,colors属性可以包含很多颜色,所以创建一个彩虹一样的多重渐变也是很简单的。默认情况下,这些颜色在空间上均匀地被渲染,但是我们可以用locations属性来调整空间。locations属性是一个浮点数值的数组(以NSNumber包装)。这些浮点数定义了colors属性中每个不同颜色的位置,同样的,也是以单位坐标系进行标定。0.0代表着渐变的开始,1.0代表着结束。
locations数组并不是强制要求的,但是如果你给它赋值了就一定要确保locations的数组大小和colors数组大小一定要相同,否则你将会得到一个空白的渐变。
目标动画效果
熟悉CAGradientLayer的基础用法后,我们结合其他Layer实现下图动画展示的复杂渐变折线:
如图: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序列化
提取上述JSON
的list
数组并进行对象序列化
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
图层结构如下:
开始绘图
1、绘制底层背景
按照上述图层设计,我们从下往上先绘制底层的背景如下:
如图,最底层视图主要由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
背景色
布局如下:
@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));
}];
效果如图:
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];
}
最终完成效果同上:
注:实际上画板上的渐变、折线、按钮和标签都是相互独立的部分,经过组合得到图示效果。
源代码
附上Demo源代码