本文是《Programming iOS5》中Drawing一章的翻譯,考慮到主題完整性,翻譯版本中加入了一些書中未涉及到的內容。希望本文能夠對你有所幫助。
本文由海水的味道翻譯整理,轉載請注明譯者和出處,請勿用於商業用途!
Core Graphics Framework是一套基於C的API框架,使用了Quartz作為繪圖引擎。它提供了低級別、輕量級、高保真度的2D渲染。該框架可以用於基於路徑的繪圖、變換、顏色管理、脫屏渲染,模板、漸變、遮蔽、圖像數據管理、圖像的創建、遮罩以及PDF文檔的創建、顯示和分析。為了從感官上對這些概念做一個入門的認識,你可以運行一下官方的example code。
iOS支持兩套圖形API族:Core Graphics/QuartZ 2D 和OpenGL ES。OpenGL ES是跨平台的圖形API,屬於OpenGL的一個簡化版本。QuartZ 2D是蘋果公司開發的一套API,它是Core Graphics Framework的一部分。需要注意的是:OpenGL ES是應用程序編程接口,該接口描述了方法、結構、函數應具有的行為以及應該如何被使用的語義。也就是說它只定義了一套規范,具體的實現由設備制造商根據規范去做。而往往很多人對接口和實現存在誤解。舉一個不恰當的比喻:上發條的時鍾和裝電池的時鍾都有相同的可視行為,但兩者的內部實現截然不同。因為制造商可以自由的實現Open GL ES,所以不同系統實現的OpenGL ES也存在着巨大的性能差異。
Core Graphics API所有的操作都在上下文中進行。所以在繪圖之前需要獲取該上下文並傳入執行渲染的函數內。如果你正在渲染一副在內存中的圖片,此時就需要傳入圖片所屬的上下文。獲得一個圖形上下文是我們完成繪圖任務的第一步,你可以將圖形上下文理解為一塊畫布。如果你沒有得到這塊畫布,那么你就無法完成任何繪圖操作。有許多方式獲得一個圖形上下文,這里我介紹兩種最為常用的獲取方法。
第一種方法就是創建一個圖片類型的上下文。調用UIGraphicsBeginImageContextWithOptions函數就可獲得用來處理圖片的圖形上下文。利用該上下文,你就可以在其上進行繪圖,並生成圖片。調用UIGraphicsGetImageFromCurrentImageContext函數可從當前上下文中獲取一個UIImage對象。記住在你所有的繪圖操作后別忘了調用UIGraphicsEndImageContext函數關閉圖形上下文。
第二種方法是利用cocoa為你生成的圖形上下文。當你子類化了一個UIView並實現了自己的drawRect:方法后,一旦drawRect:方法被調用,Cocoa就會為你創建一個圖形上下文,此時你對圖形上下文的所有繪圖操作都會顯示在UIView上。
判斷一個上下文是否為當前圖形上下文需要注意的幾點:
- UIGraphicsBeginImageContextWithOptions函數不僅僅是創建了一個適用於圖形操作的上下文,並且該上下文也屬於當前上下文。
- 當drawRect方法被調用時,UIView的繪圖上下文屬於當前圖形上下文。
- 回調方法所持有的context:參數並不會讓任何上下文成為當前圖形上下文。此參數僅僅是對一個圖形上下文的引用罷了。
作為初學者,很容易被UIKit和Core Graphics兩個支持繪圖的框架迷惑。
UIKit
像UIImage、NSString(繪制文本)、UIBezierPath(繪制形狀)、UIColor都知道如何繪制自己。這些類提供了功能有限但使用方便的方法來讓我們完成繪圖任務。一般情況下,UIKit就是我們所需要的。
使用UiKit,你只能在當前上下文中繪圖,所以如果你當前處於UIGraphicsBeginImageContextWithOptions函數或drawRect:方法中,你就可以直接使用UIKit提供的方法進行繪圖。如果你持有一個context:參數,那么使用UIKit提供的方法之前,必須將該上下文參數轉化為當前上下文。幸運的是,調用UIGraphicsPushContext 函數可以方便的將context:參數轉化為當前上下文,記住最后別忘了調用UIGraphicsPopContext函數恢復上下文環境。
Core Graphics
這是一個繪圖專用的API族,它經常被稱為QuartZ或QuartZ 2D。Core Graphics是iOS上所有繪圖功能的基石,包括UIKit。
使用Core Graphics之前需要指定一個用於繪圖的圖形上下文(CGContextRef),這個圖形上下文會在每個繪圖函數中都會被用到。如果你持有一個圖形上下文context:參數,那么你等同於有了一個圖形上下文,這個上下文也許就是你需要用來繪圖的那個。如果你當前處於UIGraphicsBeginImageContextWithOptions函數或drawRect:方法中,並沒有引用一個上下文。為了使用Core Graphics,你可以調用UIGraphicsGetCurrentContext函數獲得當前的圖形上下文。
至此,我們有了兩大繪圖框架的支持以及三種獲得圖形上下文的方法(drawRect:、drawRect: inContext:、UIGraphicsBeginImageContextWithOptions)。那么我們就有6種繪圖的形式。如果你有些困惑了,不用怕,我接下來將說明這6種情況。無需擔心還沒有具體的繪圖命令,你只需關注上下文如何被創建以及我們是在使用UIKit還是Core Graphics。
第一種繪圖形式:在UIView的子類方法drawRect:中繪制一個藍色圓,使用UIKit在Cocoa為我們提供的當前上下文中完成繪圖任務。
- (void) drawRect: (CGRect) rect {
UIBezierPath* p = [UIBezierPathbezierPathWithOvalInRect:CGRectMake(0,0,100,100)];
[[UIColor blueColor] setFill];
[p fill];
}
第二種繪圖形式:使用Core Graphics實現繪制藍色圓。
- (void) drawRect: (CGRect) rect {
CGContextRef con = UIGraphicsGetCurrentContext();
CGContextAddEllipseInRect(con, CGRectMake(0,0,100,100));
CGContextSetFillColorWithColor(con, [UIColor blueColor].CGColor);
CGContextFillPath(con);
}
第三種繪圖形式:我將在UIView子類的drawLayer:inContext:方法中實現繪圖任務。drawLayer:inContext:方法是一個繪制圖層內容的代理方法。為了能夠調用drawLayer:inContext:方法,我們需要設定圖層的代理對象。但要注意,不應該將UIView對象設置為顯示層的委托對象,這是因為UIView對象已經是隱式層的代理對象,再將它設置為另一個層的委托對象就會出問題。輕量級的做法是:編寫負責繪圖形的代理類。在MyView.h文件中聲明如下代碼:
@interface MyLayerDelegate : NSObject
@end
然后MyView.m文件中實現接口代碼:
@implementation MyLayerDelegate
- (void)drawLayer:(CALayer*)layer inContext:(CGContextRef)ctx {
UIGraphicsPushContext(ctx);
UIBezierPath* p = [UIBezierPath bezierPathWithOvalInRect:CGRectMake(0,0,100,100)];
[[UIColor blueColor] setFill];
[p fill];
UIGraphicsPopContext();
}
@end
直接將代理類的實現代碼放在MyView.m文件的#import代碼的下面,這樣感覺好像在使用私有類完成繪圖任務(雖然這不是私有類)。需要注意的是,我們所引用的上下文並不是當前上下文,所以為了能夠使用UIKit,我們需要將引用的上下文轉變成當前上下文。
因為圖層的代理是assign內存管理策略,那么這里就不能以局部變量的形式創建MyLayerDelegate實例對象賦值給圖層代理。這里選擇在MyView.m中增加一個實例變量,因為實例變量默認是strong:
@interface MyView () {
MyLayerDelegate* _layerDeleagete;
}
@end
使用該圖層代理:
MyView *myView = [[MyView alloc] initWithFrame: CGRectMake(0, 0, 320, 480)];
CALayer *myLayer = [CALayer layer];
_layerDelegate = [[MyLayerDelegate alloc] init];
myLayer.delegate = _layerDelegate;
[myView.layer addSublayer:myLayer];
[myView setNeedsDisplay]; // 調用此方法,drawLayer: inContext:方法才會被調用。
第四種繪圖形式: 使用Core Graphics在drawLayer:inContext:方法中實現同樣操作,代碼如下:
- (void)drawLayer:(CALayer*)lay inContext:(CGContextRef)con {
CGContextAddEllipseInRect(con, CGRectMake(0,0,100,100));
CGContextSetFillColorWithColor(con, [UIColor blueColor].CGColor);
CGContextFillPath(con);
}
最后,演示UIGraphicsBeginImageContextWithOptions的用法,並從上下文中生成一個UIImage對象。生成UIImage對象的代碼可以在任何地方被使用,它沒有上述繪圖方法那樣的限制。
第五種繪圖形式: 使用UIKit實現:
UIGraphicsBeginImageContextWithOptions(CGSizeMake(100,100), NO, 0);
UIBezierPath* p = [UIBezierPath bezierPathWithOvalInRect:CGRectMake(0,0,100,100)];
[[UIColor blueColor] setFill];
[p fill];
UIImage* im = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
解釋一下UIGraphicsBeginImageContextWithOptions函數參數的含義:第一個參數表示所要創建的圖片的尺寸;第二個參數用來指定所生成圖片的背景是否為不透明,如上我們使用YES而不是NO,則我們得到的圖片背景將會是黑色,顯然這不是我想要的;第三個參數指定生成圖片的縮放因子,這個縮放因子與UIImage的scale屬性所指的含義是一致的。傳入0則表示讓圖片的縮放因子根據屏幕的分辨率而變化,所以我們得到的圖片不管是在單分辨率還是視網膜屏上看起來都會很好。
第六種繪圖形式: 使用Core Graphics實現:
UIGraphicsBeginImageContextWithOptions(CGSizeMake(100,100), NO, 0);
CGContextRef con = UIGraphicsGetCurrentContext();
CGContextAddEllipseInRect(con, CGRectMake(0,0,100,100));
CGContextSetFillColorWithColor(con, [UIColor blueColor].CGColor);
CGContextFillPath(con);
UIImage* im = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
UIKit和Core Graphics可以在相同的圖形上下文中混合使用。在iOS 4.0之前,使用UIKit和UIGraphicsGetCurrentContext被認為是線程不安全的。而在iOS4.0以后蘋果讓繪圖操作在第二個線程中執行解決了此問題。
UIImage常用的繪圖操作
一個UIImage對象提供了向當前上下文繪制自身的方法。我們現在已經知道如何獲取一個圖片類型的上下文並將它轉變成當前上下文。
平移操作:下面的代碼展示了如何將UIImage繪制在當前的上下文中。
UIImage* mars = [UIImage imageNamed:@"Mars.png"];
CGSize sz = [mars size];
UIGraphicsBeginImageContextWithOptions(CGSizeMake(sz.width*2, sz.height), NO, 0);
[mars drawAtPoint:CGPointMake(0,0)];
[mars drawAtPoint:CGPointMake(sz.width,0)];
UIImage* im = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
UIImageView* iv = [[UIImageView alloc] initWithImage:im];
[self.window.rootViewController.view addSubview: iv];
iv.center = self.window.center;圖1 UIImage平移處理
縮放操作:下面代碼展示了如何對UIImage進行縮放操作:
UIImage* mars = [UIImage imageNamed:@"Mars.png"];
CGSize sz = [mars size];
UIGraphicsBeginImageContextWithOptions(CGSizeMake(sz.width*2, sz.height*2), NO, 0);
[mars drawInRect:CGRectMake(0,0,sz.width*2,sz.height*2)];
[mars drawInRect:CGRectMake(sz.width/2.0, sz.height/2.0, sz.width, sz.height) blendMode:kCGBlendModeMultiply alpha:1.0];
UIImage* im = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
圖2 UIImage縮放處理
UIImage沒有提供截取圖片指定區域的功能。但通過創建一個較小的圖形上下文並移動圖片到一個適當的圖形上下文坐標系內,指定區域內的圖片就會被獲取。
裁剪操作:下面代碼展示了如何獲取圖片的右半邊:
UIImage* mars = [UIImage imageNamed:@"Mars.png"];
CGSize sz = [mars size];
UIGraphicsBeginImageContextWithOptions(CGSizeMake(sz.width/2.0, sz.height), NO, 0);
[mars drawAtPoint:CGPointMake(-sz.width/2.0, 0)];
UIImage* im = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
以上的代碼首先創建一個一半圖片寬度的圖形上下文,然后將圖片左上角原點移動到與圖形上下文負X坐標對齊,從而讓圖片只有右半部分與圖形上下文相交。
圖3 UIImage裁剪原理
CGImage常用的繪圖操作
UIImage的Core Graphics版本是CGImage(具體類型是CGImageRef)。兩者可以直接相互轉化: 使用UIImage的CGImage屬性可以訪問Quartz圖片數據;將CGImage作為UIImage方法imageWithCGImage:或initWithCGImage:的參數創建UIImage對象。
一個CGImage對象可以讓你獲取原始圖片中指定區域的圖片(也可以獲取指定區域外的圖片,UIImage卻辦不到)。
下面的代碼展示了將圖片拆分成兩半,並分別繪制在上下文的左右兩邊:
UIImage* mars = [UIImage imageNamed:@"Mars.png"];
// 抽取圖片的左右半邊
CGSize sz = [mars size];
CGImageRef marsLeft = CGImageCreateWithImageInRect([mars CGImage],CGRectMake(0,0,sz.width/2.0,sz.height));
CGImageRef marsRight = CGImageCreateWithImageInRect([mars CGImage],CGRectMake(sz.width/2.0,0,sz.width/2.0,sz.height));
// 將每一個CGImage繪制到圖形上下文中
UIGraphicsBeginImageContextWithOptions(CGSizeMake(sz.width*1.5, sz.height), NO, 0);
CGContextRef con = UIGraphicsGetCurrentContext();
CGContextDrawImage(con, CGRectMake(0,0,sz.width/2.0,sz.height), marsLeft);
CGContextDrawImage(con, CGRectMake(sz.width,0,sz.width/2.0,sz.height), marsRight);
UIImage* im = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
// 記得釋放內存,ARC在這里無效
CGImageRelease(marsLeft);
CGImageRelease(marsRight);
你也許發現繪出的圖是上下顛倒的!圖片的顛倒並不是因為被旋轉了。當你創建了一個CGImage並使用CGContextDrawImage方法繪圖就會引起這種問題。這主要是因為原始的本地坐標系統(坐標原點在左上角)與目標上下文(坐標原點在左下角)不匹配。有很多方法可以修復這個問題,其中一種方法就是使用CGContextDrawImage方法先將CGImage繪制到UIImage上,然后獲取UIImage對應的CGImage,此時就得到了一個倒轉的CGImage。當再調用CGContextDrawImage方法,我們就將倒轉的圖片還原回來了。實現代碼如下:
CGImageRef flip (CGImageRef im) {
CGSize sz = CGSizeMake(CGImageGetWidth(im), CGImageGetHeight(im));
UIGraphicsBeginImageContextWithOptions(sz, NO, 0);
CGContextDrawImage(UIGraphicsGetCurrentContext(), CGRectMake(0, 0, sz.width, sz.height), im);
CGImageRef result = [UIGraphicsGetImageFromCurrentImageContext() CGImage];
UIGraphicsEndImageContext();
return result;
}
現在將之前的代碼修改如下:
CGContextDrawImage(con, CGRectMake(0,0,sz.width/2.0,sz.height), flip(marsLeft));
CGContextDrawImage(con, CGRectMake(sz.width,0,sz.width/2.0,sz.height), flip(marsRight));
然而,這里又出現了另外一個問題:在雙分辨率的設備上,如果我們的圖片文件是高分辨率(@2x)版本,上面的繪圖就是錯誤的。原因在於對於UIImage來說,在加載原始圖片時使用imageNamed:方法,它會自動根據所在設備的分辨率類型選擇圖片,並且UIImage通過設置用來適配的scale屬性補償圖片的兩倍尺寸。但是一個CGImage對象並沒有scale屬性,它不知道圖片文件的尺寸是否為兩倍!所以當調用UIImage的CGImage方法,你不能假定所獲得的CGImage尺寸與原始UIImage是一樣的。在單分辨率和雙分辨率下,一個UIImage對象的size屬性值都是一樣的,但是雙分辨率UIImage對應的CGImage是單分辨率UIImage對應的CGImage的兩倍大。所以我們需要修改上面的代碼,讓其在單雙分辨率下都可以工作。代碼如下:
UIImage* mars = [UIImage imageNamed:@"Mars.png"];
CGSize sz = [mars size];
// 轉換CGImage並使用對應的CGImage尺寸截取圖片的左右部分
CGImageRef marsCG = [mars CGImage];
CGSize szCG = CGSizeMake(CGImageGetWidth(marsCG), CGImageGetHeight(marsCG));
CGImageRef marsLeft = CGImageCreateWithImageInRect(marsCG,CGRectMake(0,0,szCG.width/2.0,szCG.height));
CGImageRef marsRight = CGImageCreateWithImageInRect(marsCG, CGRectMake(szCG.width/2.0,0,szCG.width/2.0,szCG.height));
UIGraphicsBeginImageContextWithOptions(CGSizeMake(sz.width*1.5, sz.height), NO, 0);
//剩下的和之前的代碼一樣,修復倒置問題
CGContextRef con = UIGraphicsGetCurrentContext();
CGContextDrawImage(con, CGRectMake(0,0,sz.width/2.0,sz.height),flip(marsLeft));
CGContextDrawImage(con, CGRectMake(sz.width,0,sz.width/2.0,sz.height),flip(marsRight));
UIImage* im = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
CGImageRelease(marsLeft);
CGImageRelease(marsRight);
上面的代碼初看上去很繁雜,不過不用擔心,這里還有另一種修復倒置問題的方案。相對於使用flip函數,你可以在繪圖之前將CGImage包裝進UIImage中,這樣做有兩大優點:
- 當UIImage繪圖時它會自動修復倒置問題
- 當你從CGImage轉化為Uimage時,可調用imageWithCGImage:scale:orientation:方法生成CGImage作為對縮放性的補償。
所以這是一個解決倒置和縮放問題的自包含方法。
代碼如下:
UIImage* mars = [UIImage imageNamed:@"Mars.png"];
CGSize sz = [mars size];
CGImageRef marsCG = [mars CGImage];
CGSize szCG = CGSizeMake(CGImageGetWidth(marsCG), CGImageGetHeight(marsCG));
CGImageRef marsLeft = CGImageCreateWithImageInRect(marsCG, CGRectMake(0,0,szCG.width/2.0,szCG.height));
CGImageRef marsRight = CGImageCreateWithImageInRect(marsCG, CGRectMake(szCG.width/2.0,0,szCG.width/2.0,szCG.height));
UIGraphicsBeginImageContextWithOptions(CGSizeMake(sz.width*1.5, sz.height), NO, 0);
[[UIImage imageWithCGImage:marsLeft scale:[mars scale] orientation:UIImageOrientationUp] drawAtPoint:CGPointMake(0,0)];
[[UIImage imageWithCGImage:marsRight scale:[mars scale] orientation:UIImageOrientationUp] drawAtPoint:CGPointMake(sz.width,0)];
UIImage* im = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
CGImageRelease(marsLeft); CGImageRelease(marsRight);
還有另一種解決倒置問題的方案是在繪制CGImage之前,對上下文應用變換操作,有效地倒置上下文的內部坐標系統。這里先不做討論。
為什么會發生倒置問題
究其原因是因為Core Graphics源於Mac OS X系統,在Mac OS X中,坐標原點在左下方並且正y坐標是朝上的,而在iOS中,原點坐標是在左上方並且正y坐標是朝下的。在大多數情況下,這不會出現任何問題,因為圖形上下文的坐標系統是會自動調節補償的。但是創建和繪制一個CGImage對象時就會暴露出倒置問題。
CIFilter與CIImage
CIFilter與CIImage是iOS 5新引入的,雖然它們已在MAX OS X系統中存在多年。前綴“CI”表示Core Image,這是一種使用數學濾鏡變換圖片的技術。但是你不要去幻想iOS提供了像Photoshop軟件那樣強大的濾鏡功能。使用Core Image之前你需要將CoreImage.framework框架導入到你的target之中。
所謂濾鏡指的是CIFilter類,濾鏡可被分為以下幾類:
模板與漸變類
這兩類濾鏡創建的CIImage可以和其他的CIImage進行合並,比如一種單色,一個棋盤,條紋,亦或是漸變。
合成類
此類濾鏡可以將一張圖片與另外的圖片合並,合成濾鏡模式常見於圖形處理軟件Photoshop中。
色彩類
此濾鏡調整、修改圖片的色彩。因此你可以改變一張圖片的飽和度、色度、亮度、對比度、伽馬、白點、曝光度、陰影、高亮等屬性。
幾何變換類
此類濾鏡可對圖片執行基本的幾何變換,比如縮放、旋轉、裁剪。
CIFilter使用起來非常的簡單。CIFilter看上去就像一個由鍵值組成的字典。它生成一個CIImage對象作為其輸出。一般地,一個濾鏡有一個或多個輸入,而對於部分濾鏡,生成的圖片是基於其他類型的參數值。CIFilter對象是一個集合,可使用鍵值對進行檢索。通過提供濾鏡的字符串名稱創建一個濾鏡,如果想知道有哪些濾鏡,可以查詢蘋果的Core Image Filter Reference文檔,或是調用CIFilter的類方法filterNamesInCategories:,參數值為nil。每一個濾鏡擁有一小部分用來確定其行為的鍵值。如果你想修改某一個鍵(比如亮度鍵)對應的值,你可以調用setValue:forKey:方法或當你指定一個濾鏡名時提供所有鍵值對。
需要處理的圖片必須是CIImage類型,調用initWithCGImage:方法可獲得CIImage。因為CGImage又是作為濾鏡的輸出,因此濾鏡之間可被連接在一起(將濾鏡的輸出作為initWithCGImage:方法的輸入參數)
當你構建一個濾鏡鏈時,並沒有做復雜的運算。只有當整個濾鏡鏈需要輸出一個CGImage時,密集型計算才會發生。調用contextWithOptions:和createCGImage: fromRect:方法創建CIContext。與以往不同的地方是CIImage沒有frame與bounds屬性;只有extent屬性。你將非常頻繁的使用這個屬性作為createCGImage: fromRect:方法的第二個參數。
接下來我將演示Core Image的使用。首先創建一個徑向漸變的濾鏡,該濾鏡是從白到黑的漸變方式,白色區域的半徑默認是100。接着將其與一張使用CIDarkenBlendMode濾鏡的圖片合成。CIDarkenBlendMode的作用是背景圖片樣本將被源圖片的黑色部分替換掉。
代碼如下:
UIImage* moi = [UIImage imageNamed:@"Mars.jpeg"];
CIImage* moi2 = [[CIImage alloc] initWithCGImage:moi.CGImage];
CIFilter* grad = [CIFilter filterWithName:@"CIRadialGradient"];
CIVector* center = [CIVector vectorWithX:moi.size.width / 2.0 Y:moi.size.height / 2.0];
// 使用setValue:forKey:方法設置濾鏡屬性
[grad setValue:center forKey:@"inputCenter"];
// 在指定濾鏡名時提供所有濾鏡鍵值對
CIFilter* dark = [CIFilter filterWithName:@"CIDarkenBlendMode" keysAndValues:@"inputImage", grad.outputImage, @"inputBackgroundImage", moi2, nil];
CIContext* c = [CIContext contextWithOptions:nil];
CGImageRef moi3 = [c createCGImage:dark.outputImage fromRect:moi2.extent];
UIImage* moi4 = [UIImage imageWithCGImage:moi3 scale:moi.scale orientation:moi.imageOrientation];
CGImageRelease(moi3);
圖4 圖片合成快照
這個例子可能沒有什么吸引人的地方,因為所有一切都可以使用Core Graphics完成。除了Core Image是使用GPU處理,可能有點吸引人。Core Graphics也可以做到徑向漸變並使用混合模式合成圖片。但Core Image要簡單得多,特別是當你有多個圖片輸入想重用一個濾鏡鏈時。並且Core Image的顏色調整功能比Core Graphics更加強大。對了,Core Image還能實現自動人臉識別哦!
繪制一個UIView
繪制一個UIVIew最靈活的方式就是由它自己完成繪制。實際上你不是繪制一個UIView,你只是子類化了UIView並賦予子類繪制自己的能力。當一個UIVIew需要執行繪圖操作的時, drawRect:方法就會被調用。覆蓋此方法讓你獲得繪圖操作的機會。當drawRect:方法被調用,當前圖形上下文也被設置為屬於視圖的圖形上下文。你可以使用Core Graphics或UIKit提供的方法將圖形畫到該上下文中。
你不應該手動調用drawRect:方法!如果你想調用drawRect:方法更新視圖,只需發送setNeedsDisplay方法。這將使得drawRect:方法會在下一個適當的時間調用。當然,不要覆蓋drawRect:方法除非你知道這樣做絕對合法。比方說,在UIImageView子類中覆蓋drawRect:方法是不合法的,你將得不到你繪制的圖形。
在UIView子類的drawRect:方法中無需調用super,因為本身UIView的drawRect:方法是空的。為了提高一些繪圖性能,你可以調用setNeedsDisplayInRect方法重新繪制視圖的子區域,而視圖的其他部分依然保持不變。
一般情況下,你不應該過早的進行優化。繪圖代碼可能看上去非常的繁瑣,但它們是非常快的。並且iOS繪圖系統自身也是非常高效,它不會頻繁調用drawRect:方法,除非迫不得已(或調用了setNeedsDisplay方法)。一旦一個視圖已由自己繪制完成,那么繪制的結果會被緩存下來留待重用,而不是每次重頭再來。(蘋果公司將緩存繪圖稱為視圖的位圖存儲回填(bitmap backing store))。你可能會發現drawRect:方法中的代碼在整個應用程序生命周期內只被調用了一次!事實上,將代碼移到drawRect:方法中是提高性能的普遍做法。這是因為繪圖引擎直接對屏幕進行渲染相對於先是脫屏渲染然后再將像素拷貝到屏幕要來的高效。
當視圖的backgroundColor為nil並且opaque屬性為YES,視圖的背景顏色就會變成黑色。
Core Graphics上下文屬性設置
當你在圖形上下文中繪圖時,當前圖形上下文的相關屬性設置將決定繪圖的行為與外觀。因此,繪圖的一般過程是先設定好圖形上下文參數,然后繪圖。比方說,要畫一根紅線,接着畫一根藍線。那么首先需要將上下文的線條顏色屬性設定為為紅色,然后畫紅線;接着設置上下文的線條顏色屬性為藍色,再畫出藍線。表面上看,紅線和藍線是分開的,但事實上,在你畫每一條線時,線條顏色卻是整個上下文的屬性。無論你用的是UIKit方法還是Core Graphics函數。
因為圖形上下文在每一時刻都有一個確定的狀態,該狀態概括了圖形上下文所有屬性的設置。為了便於操作這些狀態,圖形上下文提供了一個用來持有狀態的棧。調用CGContextSaveGState函數,上下文會將完整的當前狀態壓入棧頂;調用CGContextRestoreGState函數,上下文查找處在棧頂的狀態,並設置當前上下文狀態為棧頂狀態。
因此一般繪圖模式是:在繪圖之前調用CGContextSaveGState函數保存當前狀態,接着根據需要設置某些上下文狀態,然后繪圖,最后調用CGContextRestoreGState函數將當前狀態恢復到繪圖之前的狀態。要注意的是,CGContextSaveGState函數和CGContextRestoreGState函數必須成對出現,否則繪圖很可能出現意想不到的錯誤,這里有一個簡單的做法避免這種情況。代碼如下:
- (void)drawRect:(CGRect)rect {
CGContextRef ctx = UIGraphicsGetCurrentContext();
CGContextSaveGState(ctx);
{
// 繪圖代碼
}
CGContextRestoreGState(ctx);
}
但你不需要在每次修改上下文狀態之前都這樣做,因為你對某一上下文屬性的設置並不一定會和之前的屬性設置或其他的屬性設置產生沖突。你完全可以在不調用保存和恢復函數的情況下先設置線條顏色為紅色,然后再設置為藍色。但在一定情況下,你希望你對狀態的設置是可撤銷的,我將在接下來討論這樣的情況。
許多的屬性組成了一個圖形上下文狀態,這些屬性設置決定了在你繪圖時圖形的外觀和行為。下面我列出了一些屬性和對應修改屬性的函數;雖然這些函數是關於Core Graphics的,但記住,實際上UIKit同樣是調用這些函數操縱上下文狀態。
線條的寬度和線條的虛線樣式
CGContextSetLineWidth、CGContextSetLineDash
線帽和線條聯接點樣式
CGContextSetLineCap、CGContextSetLineJoin、CGContextSetMiterLimit
線條顏色和線條模式
CGContextSetRGBStrokeColor、CGContextSetGrayStrokeColor、CGContextSetStrokeColorWithColor、CGContextSetStrokePattern
填充顏色和模式
CGContextSetRGBFillColor,CGContextSetGrayFillColor,CGContextSetFillColorWithColor, CGContextSetFillPattern
陰影
CGContextSetShadow、CGContextSetShadowWithColor
混合模式
CGContextSetBlendMode(決定你當前繪制的圖形與已經存在的圖形如何被合成)
整體透明度
CGContextSetAlpha(個別顏色也具有alpha成分)
文本屬性
CGContextSelectFont、CGContextSetFont、CGContextSetFontSize、CGContextSetTextDrawingMode、CGContextSetCharacterSpacing
是否開啟反鋸齒和字體平滑
CGContextSetShouldAntialias、CGContextSetShouldSmoothFonts
另外一些屬性設置:
裁剪區域:在裁剪區域外繪圖不會被實際的畫出來。
變換(或稱為“CTM“,意為當前變換矩陣): 改變你隨后指定的繪圖命令中的點如何被映射到畫布的物理空間。
許多這些屬性設置接下來我都會舉例說明。
路徑與繪圖
通過編寫移動虛擬畫筆的代碼描畫一段路徑,這樣的路徑並不構成一個圖形。繪制路徑意味着對路徑描邊或填充該路徑,也或者兩者都做。同樣,你應該從某些繪圖程序中得到過相似的體會。
一段路徑是由點到點的描畫構成。想象一下繪圖系統是你手里的一只畫筆,你首先必須要設置畫筆當前所處的位置,然后給出一系列命令告訴畫筆如何描畫隨后的每段路徑。每一段新增的路徑開始於當前點,當完成一條路徑的描畫,路徑的終點就變成了當前點。
下面列出了一些路徑描畫的命令:
定位當前點
CGContextMoveToPoint
描畫一條線
CGContextAddLineToPoint、CGContextAddLines
描畫一個矩形
CGContextAddRect、CGContextAddRects
描畫一個橢圓或圓形
CGContextAddEllipseInRect
描畫一段圓弧
CGContextAddArcToPoint、CGContextAddArc
通過一到兩個控制點描畫一段貝賽爾曲線
CGContextAddQuadCurveToPoint、CGContextAddCurveToPoint
關閉當前路徑
CGContextClosePath 這將從路徑的終點到起點追加一條線。如果你打算填充一段路徑,那么就不需要使用該命令,因為該命令會被自動調用。
描邊或填充當前路徑
CGContextStrokePath、CGContextFillPath、CGContextEOFillPath、CGContextDrawPath。對當前路徑描邊或填充會清除掉路徑。如果你只想使用一條命令完成描邊和填充任務,可以使用CGContextDrawPath命令,因為如果你只是使用CGContextStrokePath對路徑描邊,路徑就會被清除掉,你就不能再對它進行填充了。
創建路徑並描邊路徑或填充路徑只需一條命令就可完成的函數:CGContextStrokeLineSegments、CGContextStrokeRect、CGContextStrokeRectWithWidth、CGContextFillRect、CGContextFillRects、CGContextStrokeEllipseInRect、CGContextFillEllipseInRect。
一段路徑是被合成的,意思是它是由多條獨立的路徑組成。舉個例子,一條單獨的路徑可能由兩個獨立的閉合形狀組成:一個矩形和一個圓形。當你在構造一條路徑的中間過程(意思是在描畫了一條路徑后沒有調用描邊或填充命令,或調用CGContextBeginPath函數來清除路徑)調用CGContextMoveToPoint函數,就像是你拾起畫筆,並將畫筆移動到一個新的位置,如此來准備開始一段獨立的相同路徑。如果你擔心當你開始描畫一條路徑的時候,已經存在的路徑和新的路徑會被認為是已存在路徑的一個合成部分,你可以調用CGContextBeginPath函數指定你繪制的路徑是一條獨立的路徑;蘋果的許多例子都是這樣做的,但在實際開發中我發現這是非必要的。
CGContextClearRect函數的功能是擦除一個區域。這個函數會擦除一個矩形內的所有已存在的繪圖;並對該區域執行裁剪。結果像是打了一個貫穿所有已存在繪圖的孔。
CGContextClearRect函數的行為依賴於上下文是透明還是不透明。當在圖形上下文中繪圖時,這會尤為明顯和直觀。如果圖片上下文是透明的(UIGraphicsBeginImageContextWithOptions第二個參數為NO),那么CGContextClearRect函數執行擦除后的顏色為透明,反之則為黑色。
當在一個視圖中直接繪圖(使用drawRect:或drawLayer:inContext:方法),如果視圖的背景顏色為nil或顏色哪怕有一點點透明度,那么CGContextClearRect的矩形區域將會顯示為透明的,打出的孔將穿過視圖包括它的背景顏色。如果背景顏色完全不透明,那么CGContextClearRect函數的結果將會是黑色。這是因為視圖的背景顏色決定了是否視圖的圖形上下文是透明的還是不透明的。
圖5 CGContextClearRect函數的應用
如圖5,在左邊的藍色正方形被挖去部分留為黑色,然而在右邊的藍色正方形也被挖去部分留為透明。但這兩個正方形都是UIView子類的實例,采用相同的繪圖代碼!不同之處在於視圖的背景顏色,左邊的正方形的背景顏色在nib文件中
但是這卻完全改變了CGContextClearRect函數的效果。UIView子類的drawRect:方法看起來像這樣:
CGContextRef con = UIGraphicsGetCurrentContext();
CGContextSetFillColorWithColor(con, [UIColor blueColor].CGColor);
CGContextFillRect(con, rect);
CGContextClearRect(con, CGRectMake(0,0,30,30));
為了說明典型路徑的描畫命令,我將生成一個向上的箭頭圖案,我謹慎避免使用便利函數操作,也許這不是創建箭頭最好的方式,但依然清楚的展示了各種典型命令的用法。
圖6 一個簡單的路徑繪圖
CGContextRef con = UIGraphicsGetCurrentContext();
// 繪制一個黑色的垂直黑色線,作為箭頭的桿子
CGContextMoveToPoint(con, 100, 100);
CGContextAddLineToPoint(con, 100, 19);
CGContextSetLineWidth(con, 20);
CGContextStrokePath(con);
// 繪制一個紅色三角形箭頭
CGContextSetFillColorWithColor(con, [[UIColor redColor] CGColor]);
CGContextMoveToPoint(con, 80, 25);
CGContextAddLineToPoint(con, 100, 0);
CGContextAddLineToPoint(con, 120, 25);
CGContextFillPath(con);
// 從箭頭桿子上裁掉一個三角形,使用清除混合模式
CGContextMoveToPoint(con, 90, 101);
CGContextAddLineToPoint(con, 100, 90);
CGContextAddLineToPoint(con, 110, 101);
CGContextSetBlendMode(con, kCGBlendModeClear);
CGContextFillPath(con);
確切的說,為了以防萬一,我們應該在繪圖代碼周圍使用CGContextSaveGState和CGContextRestoreGState函數。可對於這個例子來說,添加與否不會有任何的區別。因為上下文在調用drawRect:方法中不會被持久,所以不會被破壞。
如果一段路徑需要重用或共享,你可以將路徑封裝為CGPath(具體類型是CGPathRef)。你可以創建一個新的CGMutablePathRef對象並使用多個類似於圖形的路徑函數的CGPath函數構造路徑,或者使用CGContextCopyPath函數復制圖形上下文的當前路徑。有許多CGPath函數可用於創建基於簡單幾何形狀的路徑(CGPathCreateWithRect、CGPathCreateWithEllipseInRect)或基於已存在路徑(CGPathCreateCopyByStrokingPath、CGPathCreateCopyDashingPath、CGPathCreateCopyByTransformingPath)。
UIKit的UIBezierPath類包裝了CGPath。它提供了用於繪制某種形狀路徑的方法,以及用於描邊、填充、存取某些當前上下文狀態的設置方法。類似地,UIColor提供了用於設置當前上下文描邊與填充的顏色。因此我們可以重寫我們之前繪制箭頭的代碼:
UIBezierPath* p = [UIBezierPath bezierPath];
[p moveToPoint:CGPointMake(100,100)];
[p addLineToPoint:CGPointMake(100, 19)];
[p setLineWidth:20];
[p stroke];
[[UIColor redColor] set];
[p removeAllPoints];
[p moveToPoint:CGPointMake(80,25)];
[p addLineToPoint:CGPointMake(100, 0)];
[p addLineToPoint:CGPointMake(120, 25)];
[p fill];
[p removeAllPoints];
[p moveToPoint:CGPointMake(90,101)];
[p addLineToPoint:CGPointMake(100, 90)];
[p addLineToPoint:CGPointMake(110, 101)];
[p fillWithBlendMode:kCGBlendModeClear alpha:1.0];
在這種特殊情況下,完成同樣的工作並沒有節省多少代碼,但是UIBezierPath仍然還是有用的。如果你需要對象特性,UIBezierPath提供了一個便利方法:bezierPathWithRoundedRect:cornerRadius:,它可用於繪制帶有圓角的矩形,如果是使用Core Graphics就相當冗長乏味了。還可以只讓圓角出現在左上角和右上角。
- (void)drawRect:(CGRect)rect {
CGContextRef ctx = UIGraphicsGetCurrentContext();
CGContextSetStrokeColorWithColor(ctx, [UIColor blackColor].CGColor);
CGContextSetLineWidth(ctx, 3);
UIBezierPath *path;
path = [UIBezierPath bezierPathWithRoundedRect:CGRectMake(100, 100, 100, 100) byRoundingCorners:(UIRectCornerTopLeft |UIRectCornerTopRight) cornerRadii:CGSizeMake(10, 10)];
[path stroke];
}
圖7 左右圓角矩形
裁剪
路徑的另一用處是遮蔽區域,以防對遮蔽區域進一步繪圖。這種用法被稱為裁剪。裁剪區域外的圖形不會被繪制到。默認情況下,一個圖形上下文的裁剪區域是整個圖形上下文。你可在上下文中的任何地方繪圖。
總的來說,裁剪區域是上下文的一個特性。與已存在的裁剪區域相交會出現新的裁剪區域。所以如果你應用了你自己的裁剪區域,稍后將它從圖形上下文中移除的做法是使用CGContextSaveGState和CGContextRestoreGState函數將代碼包裝起來。
為了便於說明這一點,我使用裁剪而不是使用混合模式在箭頭桿子上打孔的方法重寫了生成箭頭的代碼。這樣做有點小復雜,因為我們想要裁剪區域不在三角形內而在三角形外部。為了表明這一點,我們使用了一個三角形和一個矩形組成了一個組合路徑。
當填充一個組合路徑並使用它表示一個裁剪區域時,系統遵循以下兩規則之一:
環繞規則(Winding rule)
如果邊界是順時針繪制,那么在其內部逆時針繪制的邊界所包含的內容為空。如果邊界是逆時針繪制,那么在其內部順時針繪制的邊界所包含的內容為空。
奇偶規則
最外層的邊界代表內部都有效,都要填充;之后向內第二個邊界代表它的內部無效,不需填充;如此規則繼續向內尋找邊界線。我們的情況非常簡單,所以使用奇偶規則就很容易了。這里我們使用CGContextEOCllip設置裁剪區域然后進行繪圖。(如果不是很明白,可以參見這篇文章:五種方法繪制有孔的2d形狀)
CGContextRef con = UIGraphicsGetCurrentContext();
// 在上下文裁剪區域中挖一個三角形狀的孔
CGContextMoveToPoint(con, 90, 100);
CGContextAddLineToPoint(con, 100, 90);
CGContextAddLineToPoint(con, 110, 100);
CGContextClosePath(con);
CGContextAddRect(con, CGContextGetClipBoundingBox(con));
// 使用奇偶規則,裁剪區域為矩形減去三角形區域
CGContextEOClip(con);
// 繪制垂線
CGContextMoveToPoint(con, 100, 100);
CGContextAddLineToPoint(con, 100, 19);
CGContextSetLineWidth(con, 20);
CGContextStrokePath(con);
// 畫紅色箭頭
CGContextSetFillColorWithColor(con, [[UIColor redColor] CGColor]);
CGContextMoveToPoint(con, 80, 25);
CGContextAddLineToPoint(con, 100, 0);
CGContextAddLineToPoint(con, 120, 25);
CGContextFillPath(con);
漸變
漸變可以很簡單也可以很復雜。一個簡單的漸變(接下來要討論的)由一端點的顏色與另一端點的顏色決定,如果在中間點加入顏色(可選),那么漸變會在上下文的兩個點之間線性的繪制或在上下文的兩個圓之間放射狀的繪制。不能使用漸變作為路徑的填充色,但可使用裁剪限制對路徑形狀的漸變。
我重寫了繪制箭頭的代碼,箭桿使用了線性漸變。效果如圖7所示。
圖8 箭頭桿子漸變
CGContextRef con = UIGraphicsGetCurrentContext();
CGContextSaveGState(con);
// 在上下文裁剪區域挖一個三角形孔
CGContextMoveToPoint(con, 90, 100);
CGContextAddLineToPoint(con, 100, 90);
CGContextAddLineToPoint(con, 110, 100);
CGContextClosePath(con);
CGContextAddRect(con, CGContextGetClipBoundingBox(con));
CGContextEOClip(con);
//繪制一個垂線,讓它的輪廓形狀成為裁剪區域
CGContextMoveToPoint(con, 100, 100);
CGContextAddLineToPoint(con, 100, 19);
CGContextSetLineWidth(con, 20);
// 使用路徑的描邊版本替換圖形上下文的路徑
CGContextReplacePathWithStrokedPath(con);
// 對路徑的描邊版本實施裁剪
CGContextClip(con);
// 繪制漸變
CGFloat locs[3] = { 0.0, 0.5, 1.0 };
CGFloat colors[12] = {
0.3,0.3,0.3,0.8, // 開始顏色,透明灰
0.0,0.0,0.0,1.0, // 中間顏色,黑色
0.3,0.3,0.3,0.8 // 末尾顏色,透明灰
};
CGColorSpaceRef sp = CGColorSpaceCreateDeviceGray();
CGGradientRef grad = CGGradientCreateWithColorComponents (sp, colors, locs, 3);
CGContextDrawLinearGradient(con, grad, CGPointMake(89,0), CGPointMake(111,0), 0);
CGColorSpaceRelease(sp);
CGGradientRelease(grad);
CGContextRestoreGState(con); // 完成裁剪
// 繪制紅色箭頭
CGContextSetFillColorWithColor(con, [[UIColor redColor] CGColor]);
CGContextMoveToPoint(con, 80, 25);
CGContextAddLineToPoint(con, 100, 0);
CGContextAddLineToPoint(con, 120, 25);
CGContextFillPath(con);
調用CGContextReplacePathWithStrokedPath函數假裝對當前路徑描邊,並使用當前線段寬度和與線段相關的上下文狀態設置。但接着創建的是描邊路徑外部的一個新的路徑。因此,相對於使用粗的線條,我們使用了一個矩形區域作為裁剪區域。
雖然過程比較冗長但是非常的簡單;我們將漸變描述為一組在一端點(0.0)和另一端點(1.0)之間連續區上的位置,以及設置與每個位置相對應的顏色。為了提亮邊緣的漸變,加深中間的漸變,我使用了三個位置,黑色點的位置是0.5。為了創建漸變,還需要提供一個顏色空間。最后,我創建出了該漸變,並對裁剪區域繪制線性漸變,最后釋放了顏色空間和漸變。
顏色與模板
在iOS中,CGColor表示顏色(具體類型為CGColorRef)。使用UIColor的colorWithCGColor:和CGColor方法可bridged cast到UIColor。
在iOS中,模板表示為CGPattern(具體類型為CGPatternRef)。你可以創建一個模板並使用它進行描邊或填充。其過程是相當復雜的。作為一個非常簡單的例子,我將使用紅藍相間的三角形替換箭頭的三角形部分。現在移除下面行:
CGContextSetFillColorWithColor(con, [UIColor redColor].CGColor));
在被移除的地方填入下面代碼:
CGColorSpaceRef sp2 = CGColorSpaceCreatePattern(NULL);
CGContextSetFillColorSpace (con, sp2);
CGColorSpaceRelease (sp2);
CGPatternCallbacks callback = {0, &drawStripes, NULL };
CGAffineTransform tr = CGAffineTransformIdentity;
CGPatternRef patt = CGPatternCreate(NULL,CGRectMake(0,0,4,4), tr, 4, 4, kCGPatternTilingConstantSpacingMinimalDistortion, true, &callback);
CGFloat alph = 1.0;
CGContextSetFillPattern(con, patt, &alph);
CGPatternRelease(patt);
代碼非常冗長,但它卻是一個完整的樣板。現在我們從后往前分析代碼: 我們調用CGContextSetFillPattern不是設置填充顏色,我們設置的是填充的模板。函數的第三個參數是一個指向CGFloat的指針,所以我們事先設置CGFloat自身。第二個參數是一個CGPatternRef對象,所以我們需要事先創建CGPatternRef,並在最后釋放它。
現在開始討論CGPatternCreate。一個模板是在一個矩形元中的繪圖。我們需要矩形元的尺寸(第二個參數)以及矩形元原始點之間的間隙(第四和第五個參數)。這這種情況下,矩形元是4*4的,每一個矩形元與它的周圍矩形元是緊密貼合的。我們需要提供一個應用到矩形元的變換參數(第三個參數);在這種情況下,我們不需要變換做什么工作,所以我們應用了一個恆等變換。我們應用了一個瓷磚規則(第六個參數)。我們需要聲明的是顏色模板不是漏印(stencil)模板,所以參數值為true。並且我們需要提供一個指向回調函數的指針,回調函數的工作是向矩形元繪制模板。第八個參數是一個指向CGPatternCallbacks結構體的指針。這個結構體由數字0和兩個指向函數的指針構成。第一個函數指針指向的函數當模板被繪制到矩形元中被調用,第二個函數指針指向的函數當模板被釋放后調用。第二個函數指針我們沒有指定,它的存在主要是為了內存管理的需要。但在這個簡單的例子中,我們並不需要。
在你使用顏色模板調用CGContextSetFillPattern函數之前,你需要設置將應用到模板顏色空間的上下文填充顏色空間。如果你忽略這項工作,那么當你調用CGContextSetFillPattern函數時會發生錯誤。所以我們創建了顏色空間,設置它作為上下文的填充顏色空間,並在后面做了釋放。
到這里我們仍然沒有完成繪圖。因為我還沒有編寫向矩形元中繪圖的函數!繪圖函數地址被表示為&drawStripes。繪圖代碼如下所示:
void drawStripes (void *info, CGContextRef con) {
// assume 4 x 4 cell
CGContextSetFillColorWithColor(con, [[UIColor redColor] CGColor]);
CGContextFillRect(con, CGRectMake(0,0,4,4));
CGContextSetFillColorWithColor(con, [[UIColor blueColor] CGColor]);
CGContextFillRect(con, CGRectMake(0,0,4,2));
}
圖9 模板填充
如你所見,實際的模板繪圖代碼是非常簡單的。唯一的復雜點在於CGPatternCreate函數必須與模板繪圖函數的矩形元尺寸相同。我們知道矩形元的尺寸為4*4,所以我們用紅色填充它,並接着填充它的下半部分為綠色。當這些矩形元被水平垂直平鋪時,我們得到了如圖8所示的條紋圖案。
注意,最后圖形上下文遺留下了一個不可取的狀態,即填充顏色空間被設置為了一個模板顏色空間。如果稍后嘗試設置填充顏色為常規顏色,就會引起錯誤。通常的解決方案是,使用CGContextSaveGState和CGContextRestoreGState函數將代碼包起來。
你可能觀察到圖8的平鋪效果並不與箭頭的三角形內部相符合:最底部的似乎只平鋪了一半藍色。這是因為一個模板的定位並不關心你填充(描邊)的形狀,總的來說它只關心圖形上下文。我們可以調用CGContextSetPatternPhase函數改變模板的定位。
圖形上下文變換
就像UIView可以實現變換,同樣圖形上下文也具備這項功能。然而對圖形上下文應用一個變換操作不會對已在圖形上下文上的繪圖產生什么影響,它只會影響到在上下文變換之后被繪制的圖形,並改變被映射到圖形上下文區域的坐標方式。一個圖形上下文變換被稱為CTM,意為“當前變換矩陣“(current transformation matrix)。
完全利用圖形上下文的CTM來免於即使是簡單的計算操作是很常見的。你可以使用CGContextConcatCTM函數將當前變換乘上任何CGAffineTransform,還有一些便利函數可對當前變換應用平移、縮放,旋轉變換。
當你獲得上下文的時候,對圖形上下文的基本變換已經設置好了;這就是系統能映射上下文繪圖坐標到屏幕坐標的原因。無論你對當前變換應用了什么變換,基本變換變換依然有效並且繪圖繼續工作。通過將你的變換代碼封裝到CGContextSaveGState和CGContextRestoreGState函數調用中,對基本變換應用的變換操作可以被還原。
舉個例子,對於我們迄今為止使用代碼繪制的向上箭頭來說,已知的放置箭頭的方式僅僅只有一個位置:箭頭矩形框的左上角被硬編碼在坐標{80,0}。這樣代碼很難理解、靈活性差、且很難被重用。最明智的做法是通過將所有代碼中的x坐標值減去80,讓箭頭矩形框左上角在坐標{0,0}。事先應用一個簡單的平移變換,很容易將箭頭畫在任何位置。為了映射坐標到箭頭的左上角,我們使用下面代碼:
CGContextTranslateCTM(con, 80, 0); //在坐標{0,0}處繪制箭頭
旋轉變換特別的有用,它可以讓你在一個被旋轉的方向上進行繪制而無需使用任何復雜的三角函數。然而這略有點復雜,因為旋轉變換圍繞的點是原點坐標。這幾乎不是你所想要的,所以你先是應用了一個平移變換,為的是映射原點到你真正想繞其旋轉的點。但是接着,在旋轉之后,為了算出你在哪里繪圖,你可能需要做一次逆向平移變換。
為了說明這個做法,我將繞箭頭桿子尾部旋轉多個角度重復繪制箭頭,並把對箭頭的繪圖封裝為UIImage對象。接着我們簡單重復繪制UIImage對象。
具體代碼如下:
- (void)drawRect:(CGRect)rect {
UIGraphicsBeginImageContextWithOptions(CGSizeMake(40,100), NO, 0.0);
CGContextRef con = UIGraphicsGetCurrentContext();
CGContextSaveGState(con);
CGContextMoveToPoint(con, 90 - 80, 100);
CGContextAddLineToPoint(con, 100 - 80, 90);
CGContextAddLineToPoint(con, 110 - 80, 100);
CGContextMoveToPoint(con, 110 - 80, 100);
CGContextAddLineToPoint(con, 100 - 80, 90);
CGContextAddLineToPoint(con, 90 - 80, 100);
CGContextClosePath(con);
CGContextAddRect(con, CGContextGetClipBoundingBox(con));
CGContextEOClip(con);
CGContextMoveToPoint(con, 100 - 80, 100);
CGContextAddLineToPoint(con, 100 - 80, 19);
CGContextSetLineWidth(con, 20);
CGContextReplacePathWithStrokedPath(con);
CGContextClip(con);
CGFloat locs[3] = { 0.0, 0.5, 1.0 };
CGFloat colors[12] = {
0.3,0.3,0.3,0.8,
0.0,0.0,0.0,1.0,
0.3,0.3,0.3,0.8
};
CGColorSpaceRef sp = CGColorSpaceCreateDeviceGray();
CGGradientRef grad = CGGradientCreateWithColorComponents (sp, colors, locs, 3);
CGContextDrawLinearGradient (con, grad, CGPointMake(89 - 80,0), CGPointMake(111 - 80,0), 0);
CGColorSpaceRelease(sp);
CGGradientRelease(grad);
CGContextRestoreGState(con);
CGColorSpaceRef sp2 = CGColorSpaceCreatePattern(NULL);
CGContextSetFillColorSpace (con, sp2);
CGColorSpaceRelease (sp2);
CGPatternCallbacks callback = {0, &drawStripes, NULL };
CGAffineTransform tr = CGAffineTransformIdentity;
CGPatternRef patt = CGPatternCreate(NULL,CGRectMake(0,0,4,4),tr,4,4,kCGPatternTilingConstantSpacingMinimalDistortion,true, &callback);
CGFloat alph = 1.0;
CGContextSetFillPattern(con, patt, &alph);
CGPatternRelease(patt);
CGContextMoveToPoint(con, 80 - 80, 25);
CGContextAddLineToPoint(con, 100 - 80, 0);
CGContextAddLineToPoint(con, 120 - 80, 25);
CGContextFillPath(con);
UIImage* im = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
con = UIGraphicsGetCurrentContext();
[im drawAtPoint:CGPointMake(0,0)];
for (int i=0; i<3; i++) {
CGContextTranslateCTM(con, 20, 100);
CGContextRotateCTM(con, 30 * M_PI/180.0);
CGContextTranslateCTM(con, -20, -100);
[im drawAtPoint:CGPointMake(0,0)];
}
}
圖10 使用CTM旋轉變換
變換有多個方法解決我們早期使用CGContextDrawImage函數遇到的倒置問題。相對於逆向繪圖,我們選擇逆向我們繪圖的上下文。實質上,我們對上下文坐標系統應用了一個“倒置”變換。你自上而下移動上下文,接着你通過應用一個讓y坐標乘以-1的縮放變換逆向y坐標的方向。
CGContextTranslateCTM(con, 0, theHeight);
CGContextScaleCTM(con, 1.0, -1.0);
上下文的頂部應該被你往下移動多遠依賴於你繪制的圖片。比如說我們可以繪制沒有倒置問題的兩個半邊的火星圖形(前面討論的一個例子)。
CGContextTranslateCTM(con, 0, sz.height); // sz為[mars size]
CGContextScaleCTM(con, 1.0, -1.0);
CGContextDrawImage(con, CGRectMake(0, 0, sz.width/2.0, sz.height), marsLeft);
CGContextDrawImage(con, CGRectMake(b.size.width-sz.width/2.0, 0, sz.width/2.0, sz.height),marsRight);
陰影
為了在繪圖上加入陰影,可在繪圖之前設置上下文的陰影值。陰影的位置表示為CGSize,如果CGSize的兩個值都是正數,則表示陰影是朝下和朝右的。模糊度被表示為任何一個正數。蘋果沒有解釋縮放的工作方式,但實驗表明12是最佳的模糊度,99及以上的模糊度會讓陰影變得不成形。
我在圖9的基礎上給上下文加了一個陰影:
~~~~~~~~~~~~
con = UIGraphicsGetCurrentContext();
CGContextSetShadow(con, CGSizeMake(7, 7), 12);
[im drawAtPoint:CGPointMake(0,0)];
~~~~~~~~~~~~~~~
然而,使用這種方法有一個不太明顯的問題。我們是在每繪制一個箭頭的時候加上的陰影。因此,箭頭的陰影會投射在另一個箭頭上面。我們想要的是讓所有的箭頭集體地投射出一個陰影。解決方法是使用一個透明的圖層;該圖層類似一個先是疊加所有繪圖然后加上陰影的一個子上下文。代碼如下:
con = UIGraphicsGetCurrentContext();
CGContextSetShadow(con, CGSizeMake(7, 7), 12);
CGContextBeginTransparencyLayer(con, NULL);
[im drawAtPoint:CGPointMake(0,0)];
for (int i=0; i<3; i++) {
CGContextTranslateCTM(con, 20, 100);
CGContextRotateCTM(con, 30 * M_PI/180.0);
CGContextTranslateCTM(con, -20, -100);
[im drawAtPoint:CGPointMake(0,0)];
}
// 在調用了CGContextEndTransparencyLayer函數之后,
// 圖層內容會在應用全局alpha和上下文陰影狀態之后被合成到上下文中
CGContextEndTransparencyLayer(con);
圖11 陰影效果
點與像素
一個點是由xy坐標描述的一個無窮小量的位置。通過指定點實現在圖形上下文中的繪圖。我們並沒有關心設備的分辨率,因為Core Graphics已經精細地將繪圖映射到物理輸出設備(基於CTM、反鋸齒和平滑技術)。因此,文章之前的討論只關心圖形上下文的點,不關注點與屏幕像素的關系。
然而像素是真實存在的。一個像素是真實世界中一個具有完整物理尺寸的顯示單元。整數的點實際上介於像素之間。在單分辨率設備上,這可能會讓人感到迷惑。比方說,如果使用線寬為1的線條對一個整數坐標的垂直路徑描邊,那么線條將會被分為兩半,分別落在路徑的兩側。所以在單分辨率設備上線寬會變成2px(因為設備無法表示半個像素)。
圖12 整數的點坐標與偏移0.5點的坐標對應的描邊處理
當你遇到顯示效果不佳的時,可能會被建議通過對坐標增減0.5讓它在像素中居中。這個建議可能有效,如圖11。但它只是做了一些頭腦簡單的假設。一個復雜的做法是獲得UIView的contentScaleFactor屬性。這個值為1.0或2.0,所以你可以除以這個屬性值得到從像素到點的轉換。還可以想想用最精確的方式繪制一條水平或垂直的線條的方式不是描邊路徑,而是填充路徑。使用這種方法UIView的子類代碼將可以在任何設備上繪制一條完美的1px寬的垂線,代碼如下:
CGContextFillRect(con, CGRectMake(100,0,1.0/self.contentScaleFactor,100));
內容模式
一個視圖向它自身繪圖,相對於只有背景顏色和子視圖,它還有內容。這意味着每當視圖被調整大小它的contentMode屬性就變得非常重要。正如我之前提到的,繪圖系統會盡可能避免重頭開始繪制視圖。相反,繪圖系統將使用之前繪圖操作的緩存結果(位圖回填)。所以,如果視圖被重新調整大小,系統可能簡單的伸縮或重定位緩存繪圖,前提是你的contentMode設置指令是是這樣設置的。
說明這一點略有點復雜。因為我需要安排調整視圖大小而不引起重繪操作(調用drawRect:方法)。當程序啟動時,我將創建一個MyView實例,並將它放在window上。接着將執行調整MyView尺寸的操作延遲到window出現和界面初次顯示之后:
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
self.window = [[UIWindow alloc] initWithFrame:[[UIScreen mainScreen] bounds]];
self.window.rootViewController = [UIViewController new];
MyView* mv =[[MyView alloc] initWithFrame:CGRectMake(0, 0, self.window.bounds.size.width - 50, 150)];
mv.center = self.window.center;
[self.window.rootViewController.view addSubview: mv];
mv.opaque = NO;
mv.tag = 111; // so I can get a reference to this view later
[self performSelector:@selector(resize:) withObject:nil afterDelay:0.1];
self.window.backgroundColor = [UIColor whiteColor];
[self.window makeKeyAndVisible];
return YES;
}
我們將視圖的高度調成之前的2倍。沒有觸發drawRect:方法的調用。如果我們視圖的drawRect:方法代碼和生成圖9的代碼相同,則我們得到如圖12的結果,視圖被顯示在正確高度上。
圖13 內容自動伸展
可是早晚drawRect:方法會被調用,繪圖將按照drawRect:方法中的代碼被刷新。代碼不會將箭頭繪制在相對於視圖邊界的高度。它是在一個固定的高度。因此箭頭會伸展,而且會在以后某個時間返回到原始的尺寸。
通常我們的視圖的contentMode屬性需要與視圖繪制自己的方式一致。假設我們的drawRect:方法中的代碼讓箭頭的尺寸和位置相對於視圖的邊界原點,即它的左上方。所以我們可以設置它的contentMode為UIViewContentModeTopLeft。又或者,我們可以將contentMode設置為UIVIewContentModeRedraw,這將引起緩存內容的自動縮放和重定位被關閉,最終結果是視圖的setNeedsDisplay方法將被調用,觸發drawRect:方法重繪視圖內容。
在另一方面,如果一個視圖只是暫時被調整大小。假設是作為動畫的一部分,那么伸縮行為正是你所想要的。假設我們的動畫是想要讓視圖變大然后還原回原始大小以達到作為吸引用戶的一種手段。這就需要視圖伸縮的時候視圖的內容也跟着伸縮,正確的contentMode的值是UIViewContentModeScaleToFill,被伸縮的內容僅僅是視圖內容的一副緩存圖片,所以它運行起來十分的高效。
完。
本文只是一個指引,如果讀者想系統學習iOS Drawing的相關內容,可參閱《iOS Drawing Practical UIKit Solutions》。以及閱讀Stack OverFlow兩位大牛的經典問答:Bard Larson和Rob Mayoff.
本文由海水的味道翻譯,轉載請注明譯者和出處,請勿用於商業用途!
譯者說明:譯文中的錯誤或不當之處望不吝指出。
Drop me a line: xdreamarshal@gmail.com, http://weibo.com/xdream86