iOS開發必會的座標系探究

騰訊雲加社群發表於2018-11-13

歡迎大家前往騰訊雲+社群,獲取更多騰訊海量技術實踐乾貨哦~

本文由落影發表於雲+社群專欄

前言

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),在左上角進行了繪製。

img
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,最終渲染到螢幕上。 但是結果與上文不一致:文字是上下顛倒。

img
CoreText的文字繪製

從這個不同的現象開始,我們來理解iOS的座標系。

座標系概念

在iOS中繪製圖形必須在一個二維的座標系中進行,但在iOS系統中存在多個座標系,常需要處理一些座標系的轉換。 先介紹一個圖形上下文(graphics context)的概念,比如說我們常用的CGContext就是Quartz 2D的上下文。圖形上下文包含繪製所需的資訊,比如顏色、線寬、字型等。用我們在Windows常用的畫圖來參考,當我們使用畫筆?在白板中寫字時,圖形上下文就是畫筆的屬性設定、白板大小、畫筆位置等等。

iOS中,每個圖形上下文都會有三種座標: 1、繪製座標系(也叫使用者座標系),我們平時繪製所用的座標系; 2、檢視(view)座標系,固定左上角為原點(0,0)的view座標系; 3、物理座標系,物理螢幕中的座標系,同樣是固定左上角為原點;

img

根據我們繪製的目標不同(螢幕、點陣圖、PDF等),會有多個context;

img
Quartz常見的繪製目標

不同context的繪製座標系各不相同,比如說UIKit的座標系為左上角原點的座標系,CoreGraphics的座標系為左下角為原點的座標系;

img

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仿射變換矩陣:

img

一個二維座標系上的點p,可以表達為(x, y, 1),乘以變換的矩陣,如下:

img

把結果相乘,得到下面的關係

img

此時,我們再來看看列印的結果[2, 0, 0, -2, 0, 200],可以化簡為 x' = 2x, y' = 200 - 2y 因為渲染的view高度為100,所以這個座標轉換相當於把原點在左下角(0,100)的座標系,轉換為原點在左上角(0,0)的座標系!通常我們都會使用UIKit進行渲染,所以iOS系統在drawRect返回CGContext的時候,預設幫我們進行了一次變換,以方便開發者直接用UIKit座標系進行渲染。

img

我們嘗試對系統新增的座標變換進行還原: 先進行CGContextTranslateCTM(context, 0, self.bounds.size.height); 對於x' = 2x, y' = 200 - 2y,我們使得x=x,y=y+100;(self.bounds.size.height=100) 於是有x' = 2x, y' = 200-2(y+100) = -2y; 再進行CGContextScaleCTM(context, 1.0, -1.0); 對於x' = 2x, y' = -2y,我們使得x=x, y=-y; 於是有 x'=2x, y' = -2(-y) = 2y;

- (void)drawRect:(CGRect)rect {
    [super drawRect:rect];
    CGContextRef context = UIGraphicsGetCurrentContext();
    CGContextTranslateCTM(context, 0, self.bounds.size.height);
    CGContextScaleCTM(context, 1.0, -1.0);
    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);
}
複製程式碼

通過log也可以看出來CGContext default matrix [2, 0, -0, 2, 0, 0]; 最終結果如下,文字從左下角開始渲染,並且沒有出現上下顛倒的情況。

img

這時我們產生新的困擾: 用CoreText渲染文字的上下顛倒現象解決,但是修改後的座標系UIKit無法正常使用,如何相容兩種座標系? iOS可以使用CGContextSaveGState()方法暫存context狀態,然後在CoreText繪製完後通過CGContextRestoreGState ()可以恢復context的變換。

- (void)drawRect:(CGRect)rect {
    [super drawRect:rect];

    CGContextRef context = UIGraphicsGetCurrentContext();
    NSLog(@"CGContext default matrix %@", NSStringFromCGAffineTransform(CGContextGetCTM(context)));
    CGContextSaveGState(context);
    CGContextTranslateCTM(context, 0, self.bounds.size.height);
    CGContextScaleCTM(context, 1.0, -1.0);
    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);
    CGContextRestoreGState(context);
    
    
    NSLog(@"CGContext default CTM matrix %@", NSStringFromCGAffineTransform(CGContextGetCTM(context)));
    UILabel *testLabel = [[UILabel alloc] initWithFrame:CGRectMake(0, 0, 100, 20)];
    testLabel.text = @"測試文字";
    testLabel.font = [UIFont systemFontOfSize:14];
    testLabel.textColor = [UIColor whiteColor];
    [testLabel.layer renderInContext:context];
}
複製程式碼

渲染結果如下,控制檯輸出的兩個matrix都是[2, 0, 0, -2, 0, 200]

img

遇到的問題

1、UILabel.layer在drawContext的時候frame失效

初始化UILabel時設定了frame,但是沒有生效。 UILabel *testLabel = [[UILabel alloc] initWithFrame:CGRectMake(20, 20, 100, 28)]; 這是因為frame是在上一層view中座標的偏移,在renderInContext中座標起點與frame無關,所以需要修改的是bounds屬性: testLabel.layer.bounds = CGRectMake(50, 50, 100, 28);

2、renderInContext和drawInContext的選擇

在把UILabel.layer渲染到context的時候,應該採用drawInContext還是renderInContext?

img

雖然這兩個方法都可以生效,但是根據畫線部分的內容來判斷,還是採用了renderInContext,並且問題1就是由這裡的一句Renders in the coordinate space of the layer,定位到問題所在。

3、如何理解CoreGraphics座標系不一致後,會出現繪製結果異常?

我的理解方法是,我們可以先不考慮座標系變換的情況。 如下圖,上半部分是普通的渲染結果,可以很容易的想象; 接下來是增加座標變換後,座標系變成原點在左上角的頂點,相當於按照下圖的虛線進行了一次垂直的翻轉。

img

也可以按照座標系變換的方式去理解,將左下角原點的座標系相對y軸做一次垂直翻轉,然後向上平移height的高度,這樣得到左上角原點的座標系。

附錄

Drawing and Printing Guide for iOS Quartz 2D Programming Guide

相關閱讀 【每日課程推薦】機器學習實戰!快速入門線上廣告業務及CTR相應知識

此文已由作者授權騰訊雲+社群釋出,更多原文請點選

搜尋關注公眾號「雲加社群」,第一時間獲取技術乾貨,關注後回覆1024 送你一份技術課程大禮包!

海量技術實踐經驗,盡在雲加社群

相關文章