【iOS】Quartz 2D之基本操作

Quartz 2D是一个二维绘图引擎,同时支持iOS和Mac系统。

使用Quartz 2D可以完成以下事情:

  • 绘制图形:线条/三角形/矩形/圆/弧等
  • 绘制文字
  • 绘制/生成图片
  • 读取/生成PDF
  • 截图/裁剪图片
  • 自定义UI控件

图表/涂鸦/画板/手势解锁等比较个性化功能使用系统提供的UI控件无法实现,可以利用Quartz 2D把控件的结构画出来,代码执行效率高,而且非常灵活。其实,iOS中大部分控件的内容都是通过Quartz 2D画出来的。

一、初识上下文

图形上下文(Graphics Context)是一个CGContextRef类型的数据。可以理解为一个画布,所有绘画操作都是在画布上进行的。

作用:

  • 保存绘图信息、绘图状态;
  • 决定绘制的内容输出形式是什么样子的(输出形式可以是PDF、Bitmap、Window、Layer等)。

1.1. 自定义view

1.1.1. 如何利用Quartz 2D自定义view?

  • 首先,需要有图形上下文,因为它能保存绘图信息,并且决定着绘制到什么地方去;
  • 其次,那个图形上下文必须跟view相关联,才能将内容绘制到view上面。

1.1.2. 自定义view的步骤

  1. 新建一个类,继承自UIView
  2. - (void)drawRect:(CGRect)rect方法内部:
    • 取得跟当前view相关联的图形上下文(该方法内部会自动创建和当前view相关联的上下文对象,可以直接获取。无论是创建还是获取,上下文都是以UIGrapnics开头);
    • 开始绘制相应的图形内容;
    • 利用图形上下文将绘制的所有内容渲染显示到view上面;
    • 参数rect是当前viewbounds

每次新建view类时,系统都会在实现类中默认创建drawRect代码,并且给出相应的使用说明:

1
2
3
4
5
6
7
8
/*
// Only override drawRect: if you perform custom drawing.
// An empty implementation adversely affects performance during animation.
- (void)drawRect:(CGRect)rect {
// Drawing code
}
*/

如果需要自定义绘制,只需要重写drawRect:方法即可。如果重写了该方法但是没有实现任何内容,在动画期间会影响性能。

drawRect:方法调用时机:

  1. 调用时机在viewWillAppearviewDidAppear之间
  2. viewsize不为0时调用setNeedsDisplaysetNeedsDisplayInRect:,会触发drawRect:
  3. 调用sizeToFit,会触发drawRect:
  4. UIViewcontentMode属性设置成UIViewContentModeRedraw,每一次设置或更改frame都会触发drawRect:

二、UIBezierPath(贝塞尔曲线)

2.1. 基本线条(直线)

空界面:

场景:新建DBDrawView: UIView,实现drawRect:方法:在界面上绘制不同颜色的线条。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
- (void)drawRect:(CGRect)rect {
// 1.获取当前view相关联的上下文
CGContextRef ctx = UIGraphicsGetCurrentContext();

// 2.描述路径(贝塞尔曲线)
UIBezierPath *path = [UIBezierPath bezierPath];
// 2.1 添加起点(所有路径和点都是在rect范围内的, 坐标原点相对于当前view是{0, 0})
[path moveToPoint:CGPointMake(50.0, 50.0)];
// 2.2 添加路径点()
[path addLineToPoint:CGPointMake(150.0, 150.0)];
// 2.3 可以继续添加点
[path addLineToPoint:CGPointMake(200.0, 150.0)];

// 2.4 路径可以添加多条线
[path moveToPoint:CGPointMake(100.0, 100.0)];
[path addLineToPoint:CGPointMake(200.0, 100.0)];

// 2.5 把上一路径的终点作为下一路径的起点
[path addLineToPoint:CGPointMake(80.0, 80.0)];

// 3. 路径添加到上下文(注意传入的路径是CGPathRef类型)
CGContextAddPath(ctx, path.CGPath);

// 4. 把上下文当中需要绘制的所有内容渲染到view的layer层上
// 渲染方式有两种:
// 描边:stroke -> CGContextStrokePath(ctx);
// 填充:fill -> CGContextFillPath(ctx);
CGContextStrokePath(ctx);
}

示例效果:

设置线条宽度、颜色

1
2
3
4
5
6
7
8
9
// 设置上下文状态(线宽、颜色等)
// 设置线条宽度
CGContextSetLineWidth(ctx, 6.0);
// 设置线条连接样式(CGLineJoin枚举类型:)
CGContextSetLineJoin(ctx, kCGLineJoinRound);
// 设置线条顶端样式(CGLineCap枚举类型:)
CGContextSetLineCap(ctx, kCGLineCapRound);
// 设置线条颜色
[[UIColor orangeColor] set];

设置颜色有三种形式:

  1. [[UIColor orangeColor] setStroke]; 对应绘制渲染CGContextStrokePath(ctx),否则无效;
  2. [[UIColor orangeColor] setFill]; 对应绘制渲染CGContextFillPath(ctx),否则无效;
  3. [[UIColor orangeColor] set]; 自动匹配渲染样式。

上面绘制直线的时候已经看到UIBezierPath,但他的功能不仅仅是画直线。可以说只要用到自定义绘制,大概率都会用到UIBezierPath,甚至一些动画也是按照指定的曲线路径运动的。

注意:只要是在view上自定义绘制内容,必须在drawRect执行,否则获取不到上下文。

2.2. 曲线

曲线由两个端点、一个控制点构成,端点决定了曲线的范围,控制点决定了曲线的曲率。

1
2
3
4
5
6
7
8
- (void)drawRect:(CGRect)rect {
CGContextRef ctx = UIGraphicsGetCurrentContext();
UIBezierPath *path = [UIBezierPath bezierPath];
[path moveToPoint:CGPointMake(50.0, 150.0)];
[path addQuadCurveToPoint:CGPointMake(200.0, 150.0) controlPoint:CGPointMake(80.0, 10.0)];
CGContextAddPath(ctx, path.CGPath);
CGContextStrokePath(ctx);
}

2.3. 矩形

1
2
3
4
5
6
- (void)drawRect:(CGRect)rect {
CGContextRef ctx = UIGraphicsGetCurrentContext();
UIBezierPath *path = [UIBezierPath bezierPathWithRect:CGRectMake(50.0, 50.0, 200.0, 100.0)];
CGContextAddPath(ctx, path.CGPath);
CGContextStrokePath(ctx);
}

注意:宽度和高度相等时就是正方形。

2.4. 圆角矩形

1
2
3
4
5
6
- (void)drawRect:(CGRect)rect {
CGContextRef ctx = UIGraphicsGetCurrentContext();
UIBezierPath *path = [UIBezierPath bezierPathWithRoundedRect:CGRectMake(50.0, 50.0, 200.0, 100.0) cornerRadius:20.0];
CGContextAddPath(ctx, path.CGPath);
CGContextStrokePath(ctx);
}

可以指定边角是否加圆角

1
2
3
4
5
6
- (void)drawRect:(CGRect)rect {
CGContextRef ctx = UIGraphicsGetCurrentContext();
UIBezierPath *path = [UIBezierPath bezierPathWithRoundedRect:CGRectMake(50.0, 50.0, 200.0, 100.0) byRoundingCorners:UIRectCornerTopLeft | UIRectCornerBottomLeft cornerRadii:CGSizeMake(20.0, 20.0)];
CGContextAddPath(ctx, path.CGPath);
CGContextStrokePath(ctx);
}

注意:cornerRadii出入的size是以宽度为准的,高度可以忽略。

2.5. 椭圆

1
2
3
4
5
6
- (void)drawRect:(CGRect)rect {
CGContextRef ctx = UIGraphicsGetCurrentContext();
UIBezierPath *path = [UIBezierPath bezierPathWithOvalInRect:CGRectMake(50.0, 50.0, 200.0, 100.0)];
CGContextAddPath(ctx, path.CGPath);
CGContextStrokePath(ctx);
}

当传入的Rect宽度和高度相等的时候,就是圆形

1
UIBezierPath *path = [UIBezierPath bezierPathWithOvalInRect:CGRectMake(50.0, 50.0, 100.0, 100.0)];

2.6. 简洁写法

上面的示例中,都必须手动编写上下文并设置对应状态。其实UIBezierPath可以直接设置相关状态,不需要编写上下文的代码(内部已经帮忙实现)。

2.6.1. 描边

1
2
3
4
5
6
7
8
9
- (void)drawRect:(CGRect)rect {
UIBezierPath *path = [UIBezierPath bezierPathWithOvalInRect:CGRectMake(50.0, 50.0, 100.0, 100.0)];
// 设置线宽
path.lineWidth = 6.0;
// 设置颜色
[[UIColor orangeColor] set];
// 绘制形式:描边
[path stroke];
}

2.6.2. 填充

1
2
3
4
5
- (void)drawRect:(CGRect)rect {
UIBezierPath *path = [UIBezierPath bezierPathWithOvalInRect:CGRectMake(50.0, 50.0, 100.0, 100.0)];
[[UIColor orangeColor] set];
[path fill];
}

注意:所有设置必须写在[path stroke][path fill]前面。

三、弧和扇形的绘制

3.1. 弧

弧的本质其实是圆的一部分,所以弧和圆的圆心及半径有直接关系。

UIBezierPath提供了一个绘制弧路径的方法:

1
2
3
4
5
6
7
8
9
/* 
* @brief: 绘制弧
* @param: center:弧所在圆的圆心
* @param: radius:弧所在圆的半径
* @param: startAngle:开始角度
* @param: endAngle:结束角度
* @param: clockwise:是否顺时针方向绘制(如果是逆时针,角度必须是负数)
*/
+ (instancetype)bezierPathWithArcCenter:(CGPoint)center radius:(CGFloat)radius startAngle:(CGFloat)startAngle endAngle:(CGFloat)endAngle clockwise:(BOOL)clockwise;

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
- (void)drawRect:(CGRect)rect {
// 圆形(辅助查看)
CGRect circleRect = CGRectMake(30.0, 30.0, rect.size.width-160.0, rect.size.width-160.0);
UIBezierPath *circlePath = [UIBezierPath bezierPathWithOvalInRect:circleRect];
[[UIColor grayColor] set];
[circlePath stroke];

// 弧(和辅助圆同心同径)
CGPoint center = CGPointMake(30.0+circleRect.size.width*0.5, 30.0+circleRect.size.height*0.5);
CGFloat radius = circleRect.size.width*0.5;
UIBezierPath *path = [UIBezierPath bezierPathWithArcCenter:center radius:radius startAngle:0 endAngle:-M_PI_4 clockwise:NO];
path.lineWidth = 4.0;
[[UIColor redColor] set];
[path stroke];
}

上面所有参数不变,只把clockwise换成顺时针,看下效果:

3.2. 扇形

扇形就是弧度路径结束后,添加一条到圆心的直线,关闭路径即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
- (void)drawRect:(CGRect)rect {
// 圆形(辅助查看)
CGRect circleRect = CGRectMake(30.0, 30.0, rect.size.width-160.0, rect.size.width-160.0);
UIBezierPath *circlePath = [UIBezierPath bezierPathWithOvalInRect:circleRect];
[[UIColor grayColor] set];
[circlePath stroke];

// 弧(和辅助圆同心同径)
CGPoint center = CGPointMake(30.0+circleRect.size.width*0.5, 30.0+circleRect.size.height*0.5);
CGFloat radius = circleRect.size.width*0.5;
UIBezierPath *path = [UIBezierPath bezierPathWithArcCenter:center radius:radius startAngle:0 endAngle:-M_PI_4 clockwise:NO];
path.lineWidth = 4.0;
[[UIColor redColor] set];

// 扇形:添加一条到圆心的线
[path addLineToPoint:center];
// 扇形:关闭路径(自动绘制一条从上个路径到弧度另一端的直线)
[path closePath];

[path stroke];
}

核心代码:

1
2
3
4
// 扇形:添加一条到圆心的线
[path addLineToPoint:center];
// 扇形:关闭路径(自动绘制一条从路径终点到路径起点的线)
[path closePath];

把填充方式换成fill

1
[path fill];

注意:如果使用的是fill,会自动关闭路径,可以不写[path closePath]

四、案例

4.1. 圆形进度条

ViewController.m

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#import "ViewController.h"
#import "DBCircleProgressView.h"

@interface ViewController ()

@property (weak, nonatomic) IBOutlet DBCircleProgressView *progressView;
@property (weak, nonatomic) IBOutlet UISlider *slider;

@end

@implementation ViewController

- (void)viewDidLoad {
[super viewDidLoad];
// 最大值和最小值
CGFloat minValue = 0;
CGFloat maxValue = 100;
self.slider.minimumValue = minValue;
self.slider.maximumValue = maxValue;

self.progressView.minValue = minValue;
self.progressView.maxValue = maxValue;
}

// 滑块值改变时事件
- (IBAction)progressValueChanged:(UISlider *)sender {
self.progressView.value = sender.value;
}

@end

DBCircleProgressView

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// DBCircleProgressView.h
@interface DBCircleProgressView : UIView

@property (nonatomic, assign) CGFloat value;
@property (nonatomic, assign) CGFloat minValue;
@property (nonatomic, assign) CGFloat maxValue;

@end

// DBCircleProgressView.m
@interface DBCircleProgressView ()

@property (nonatomic, weak) IBOutlet UILabel *valueLabel;

@end

@implementation DBCircleProgressView

- (void)setValue:(CGFloat)value {
_value = value;
self.valueLabel.text = [NSString stringWithFormat:@"%.2f%%", value];
[self setNeedsDisplay];
}

- (void)drawRect:(CGRect)rect {
// 圆心
CGPoint center = CGPointMake(rect.size.width*0.5, rect.size.height*0.5);
// 半径
CGFloat radius = rect.size.width*0.5-60.0;
// 角度
CGFloat startAngle = -M_PI_2;
// 计算1%是多少角度
CGFloat angleValue = (M_PI*2)/self.maxValue;
CGFloat endAngle = startAngle+self.value*angleValue;

UIBezierPath *path = [UIBezierPath bezierPathWithArcCenter:center radius:radius startAngle:startAngle endAngle:endAngle clockwise:YES];
path.lineWidth = 4.0;
[[UIColor redColor] set];
[path stroke];
}

@end

视图层次

效果

4.2. 扇形饼图

ViewController.m

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@interface ViewController ()

@property (weak, nonatomic) IBOutlet DBSectorView *sectorView;

@end

@implementation ViewController

- (void)viewDidLoad {
[super viewDidLoad];
self.sectorView.numbers = @[@20, @30, @10, @40];
}

@end

DBSectorView

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
@implementation DBSectorView

- (void)setNumbers:(NSArray<NSNumber *> *)numbers {
_numbers = numbers;
[self setNeedsDisplay];
}

- (void)drawRect:(CGRect)rect {
CGPoint center = CGPointMake(rect.size.width*0.5, rect.size.height*0.5);
CGFloat radius = rect.size.width*0.5-60.0;
CGFloat startAngle = 0.0;
CGFloat endAngle = 0.0;
for (NSNumber *number in self.numbers) {
startAngle = endAngle;
endAngle = startAngle + number.floatValue/100.0 * M_PI * 2;
UIBezierPath *path = [UIBezierPath bezierPathWithArcCenter:center radius:radius startAngle:startAngle endAngle:endAngle clockwise:YES];
[path addLineToPoint:center];

[[self getRandomColor] set];
[path fill];
}
}

// 获取随机颜色
- (UIColor *)getRandomColor {
CGFloat color_r = arc4random_uniform(256)/255.0;
CGFloat color_g = arc4random_uniform(256)/255.0;
CGFloat color_b = arc4random_uniform(256)/255.0;
return [UIColor colorWithRed:color_r green:color_g blue:color_b alpha:1.0];
}

@end

效果

五、drawRect方法的其他用法

drawRect还可以绘制文字、图片等,只要能显示的任何UI内容,都可以通过drawRect绘制。

5.1. 绘制文字

DBDrawTextView

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
- (void)drawRect:(CGRect)rect {
NSString *text = @"idbeny";
NSMutableDictionary *attriDict = [NSMutableDictionary dictionary];
// 字体
[attriDict setObject:[UIFont systemFontOfSize:36.0] forKey:NSFontAttributeName];
// 颜色
[attriDict setObject:[UIColor redColor] forKey:NSForegroundColorAttributeName];
// 描边
[attriDict setObject:[UIColor blueColor] forKey:NSStrokeColorAttributeName];
// 描边宽度
[attriDict setObject:@3 forKey:NSStrokeWidthAttributeName];
// 阴影
NSShadow *shadow = [[NSShadow alloc] init];
shadow.shadowOffset = CGSizeMake(5.0, 5.0);
shadow.shadowColor = [UIColor yellowColor];
[attriDict setObject:shadow forKey:NSShadowAttributeName];

[text drawAtPoint:CGPointZero withAttributes:attriDict];
}

效果:

注意:设置NSStrokeColorAttributeName后,NSForegroundColorAttributeName就会失效。

5.2. 绘制图片

DBDrawImageView

1
2
3
4
5
6
7
8
@implementation DBDrawImageView

- (void)drawRect:(CGRect)rect {
UIImage *image = [UIImage imageNamed:@"test"];
[image drawInRect:rect];
}

@end

效果:

六、深入理解上下文(状态栈)

抽象上来说上下文分为两块区域,一块区域存放路径,另一块区域存放状态。每次渲染都是从存放路径的区域取出路径,并应用当前状态。上下文状态默认只有一份,为了能够有多种状态可重复利用,就有了上下文状态栈。上下文状态栈可以保存和恢复对应的状态,是一个栈结构,每保存一次相当于向栈中添加一次状态,恢复一次就是把栈顶的状态取出并应用。

小技巧:上下文状态栈也可以理解为历史记录。

保存当前上下文状态:

1
CGContextSaveGState(ctx);

取出(恢复)上下文状态栈中栈顶状态:

1
CGContextRestoreGState(ctx);

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
- (void)drawRect:(CGRect)rect {
// 1.获取上下文
CGContextRef ctx = UIGraphicsGetCurrentContext();
// 2.描述路径
UIBezierPath *path = [UIBezierPath bezierPath];
[path moveToPoint:CGPointMake(50.0, 20.0)];
[path addLineToPoint:CGPointMake(250.0, 20.0)];
// 3.路径添加到上下文
CGContextAddPath(ctx, path.CGPath);
// 4.设置上下文状态
CGContextSetLineWidth(ctx, 10.0);
[[UIColor redColor] set];

// 保存当前状态到上下文状态栈中(线宽10,红色)
CGContextSaveGState(ctx);
// 5.渲染
CGContextStrokePath(ctx);

// 1.新增路径
UIBezierPath *path2 = [UIBezierPath bezierPath];
[path2 moveToPoint:CGPointMake(50.0, 70.0)];
[path2 addLineToPoint:CGPointMake(250.0, 70.0)];
// 2.路径添加到上下文
CGContextAddPath(ctx, path2.CGPath);
// 3.设置上下文状态
CGContextSetLineWidth(ctx, 20.0);
[[UIColor blueColor] set];
// 保存当前状态到上下文状态栈中(线宽20,蓝色)
CGContextSaveGState(ctx);
// 4.渲染
CGContextStrokePath(ctx);

// 1.新增路径
UIBezierPath *path3 = [UIBezierPath bezierPath];
[path3 moveToPoint:CGPointMake(50.0, 120.0)];
[path3 addLineToPoint:CGPointMake(250.0, 120.0)];
// 2.路径添加到上下文
CGContextAddPath(ctx, path3.CGPath);
// 3.取出上下文状态栈栈顶状态并应用(取出:线宽20,蓝色)
CGContextRestoreGState(ctx);
// 4.渲染
CGContextStrokePath(ctx);

// 1.新增路径
UIBezierPath *path4 = [UIBezierPath bezierPath];
[path4 moveToPoint:CGPointMake(50.0, 170.0)];
[path4 addLineToPoint:CGPointMake(250.0, 170.0)];
// 2.路径添加到上下文
CGContextAddPath(ctx, path4.CGPath);
// 3.取出上下文状态栈栈顶状态并应用(取出:线宽10,红色)
CGContextRestoreGState(ctx);
// 4.渲染
CGContextStrokePath(ctx);
}

效果:

扩展-上下文的矩阵操作
上下文内容在添加到渲染进程中前可以对内容进行一些矩阵变换(形变)。

  • CGContextTranslateCTM 平移
  • CGContextScaleCTM 缩放
  • CGContextRotateCTM 旋转

注意:上下文中的矩阵变换坐标系和UIView中的坐标系不一样。