前言
app在渲染视图时,需要在坐标系中指定绘制区域。
这个概念看似乎简单,事实并非如此。
When an app draws something in iOS, it has to locate the drawn content in a two-dimensional space defined by a coordinate system.
This notion might seem straightforward at first glance, but it isn’t.
正文
我们先从一段最简单的代码入手,在drawRect中显示一个普通的UILabel;
为了方便判断,我把整个view的背景设置成黑色:
- (void)drawRect:(CGRect)rect {
[super drawRect:rect];
CGContextRef context = UIGraphicsGetCurrentContext();
NSLog(@"CGContext default CTM matrix %@", NSStringFromCGAffineTransform(CGContextGetCTM(context)));
UILabel *testLabel = [[UILabel alloc] initWithFrame:CGRectMake(0, 0, 100, 28)];
testLabel.text = @"测试文本";
testLabel.font = [UIFont systemFontOfSize:14];
testLabel.textColor = [UIColor whiteColor];
[testLabel.layer renderInContext:context];
}
这段代码首先创建一个UILabel,然后设置文本,显示到屏幕上,没有修改坐标。
所以按照UILabel.layer默认的坐标(0, 0),在左上角进行了绘制。
UILabel绘制
接着,我们尝试使用CoreText来渲染一段文本。
- (void)drawRect:(CGRect)rect {
[super drawRect:rect];
CGContextRef context = UIGraphicsGetCurrentContext();
NSLog(@"CGContext default matrix %@", NSStringFromCGAffineTransform(CGContextGetCTM(context)));
NSAttributedString *attrStr = [[NSAttributedString alloc] initWithString:@"测试文本" attributes:@{
NSForegroundColorAttributeName:[UIColor whiteColor],
NSFontAttributeName:[UIFont systemFontOfSize:14],
}];
CTFramesetterRef frameSetter = CTFramesetterCreateWithAttributedString((__bridge CFAttributedStringRef) attrStr); // 根据富文本创建排版类CTFramesetterRef
UIBezierPath * bezierPath = [UIBezierPath bezierPathWithRect:CGRectMake(0, 0, 100, 20)];
CTFrameRef frameRef = CTFramesetterCreateFrame(frameSetter, CFRangeMake(0, 0), bezierPath.CGPath, NULL); // 创建排版数据
CTFrameDraw(frameRef, context);
}
首先用NSString创建一个富文本,然后根据富文本创建CTFramesetterRef,结合CGRect生成的UIBezierPath,我们得到CTFrameRef,最终渲染到屏幕上。
但是结果与上文不一致:文字是上下颠倒。
CoreText的文本绘制
从这个不同的现象开始,我们来理解iOS的坐标系。
坐标系概念
在iOS中绘制图形必须在一个二维的坐标系中进行,但在iOS系统中存在多个坐标系,常需要处理一些坐标系的转换。
先介绍一个图形上下文(graphics context)的概念,比如说我们常用的CGContext就是Quartz 2D的上下文。图形上下文包含绘制所需的信息,比如颜色、线宽、字体等。用我们在Windows常用的画图来参考,当我们使用画笔🖌在白板中写字时,图形上下文就是画笔的属性设置、白板大小、画笔位置等等。
iOS中,每个图形上下文都会有三种坐标:
1、绘制坐标系(也叫用户坐标系),我们平时绘制所用的坐标系;
2、视图(view)坐标系,固定左上角为原点(0,0)的view坐标系;
3、物理坐标系,物理屏幕中的坐标系,同样是固定左上角为原点;
根据我们绘制的目标不同(屏幕、位图、PDF等),会有多个context;
Quartz常见的绘制目标
不同context的绘制坐标系各不相同,比如说UIKit的坐标系为左上角原点的坐标系,CoreGraphics的坐标系为左下角为原点的坐标系;
CoreGraphics坐标系和UIKit坐标系的转换
CoreText基于CoreGraphics,所以坐标系也是CoreGraphics的坐标系。
我们回顾下上文提到的两个渲染结果,我们产生如下疑问:
UIGraphicsGetCurrentContext返回的是CGContext,代表着是左下角为原点的坐标系,用UILabel(UIKit坐标系)可以直接renderInContext,并且“测”字对应为UILabel的(0,0)位置,是在左上角?
当用CoreText渲染时,坐标是(0,0),但是渲染的结果是在左上角,并不是在左下角;并且文字是上下颠倒的。
为了探究这个问题,我在代码中加入了一行log:
NSLog(@"CGContext default matrix %@", NSStringFromCGAffineTransform(CGContextGetCTM(context)));
其结果是CGContext default matrix [2, 0, 0, -2, 0, 200];
CGContextGetCTM返回是CGAffineTransform仿射变换矩阵:
一个二维坐标系上的点p,可以表达为(x, y, 1),乘以变换的矩阵,如下:
把结果相乘,得到下面的关系
此时,我们再来看看打印的结果[2, 0, 0, -2, 0, 200],可以化简为
x' = 2x, y' = 200 - 2y
因为渲染的view高度为100,所以这个坐标转换相当于把原点在左下角(0,100)的坐标系,转换为原点在左上角(0,0)的坐标系!通常我们都会使用UIKit进行渲染,所以iOS系统在drawRect返回CGContext的时候,默认帮我们进行了一次变换,以方便开发者直接用UIKit坐标系进行渲染。
我们尝试对系统添加的坐标变换进行还原:
先进行CGContextTranslateCTM(context, 0, self.bounds.size.height);
对于x' = 2x, y' = 200 - 2y,我们使得x=x,y=y+100;(self.bounds.size.height=100)