本文翻譯自:《iOS 7編程》 Matt Neuburg 著,OREILLY出版。
很多UIView的子類,例如UIButton或者UIlabel,都知道如何繪製自己;不過遲早,你都會想繪製一些自己想要的效果。你可以通過一些已有的類在代碼中繪製一幅圖片,然後在自己的介面上展示出來,例如UIImageVIew和UIButton。單純一個UIView就是只與繪製有關,它給你了很大的空間來繪畫;你的代碼決定了這個視圖怎麼繪製自己,最終怎麼在你介面上展示。
UIImage和UIImageView
iOS系統支援很多標準的圖片格式:TIFF、JPEG、GIF、PNG等。當一張圖片被包含在我們的app 包內,iOS系統特別地,會對PNG檔案提供更加友好的支援,不只是因為系統會對它進行壓縮處理,還有在不同解析度下的對圖片的選取和展示都做了很多工作,所以我們應該優先選擇PNG格式圖片。我們可以通過 imageNamed: 這個UIImage類提供的方法擷取app包內的圖片,這個方法會從兩個地方尋找指定的圖片:
app包頂級目錄
系統會通過提供的圖片名字,名字是大小寫敏感的,以及包括圖片的類型,在app包中尋找。如果沒有提供類型,預設是png格式。
Asset catalog 資來源目錄
它會通過提供的名字,在這個資來源目錄中尋找匹配的圖片集。如果名字帶有檔案尾碼,就不會在這裡尋找,以便舊代 碼中,如果把圖片移動到這個目錄仍然能夠正常工作。這個目錄的尋找優先順序比上面的尋找高,也就意味著,如果在這個 資來源目錄下找到了匹配的圖片,方法就會返回,而不會再去app包頂級目錄中尋找。
可調整大小的Images
可以通過向一個UIImage發送 resizableImageWithCapInsets:resizingMode: 訊息,來把圖片轉換成可調整大小的圖片。capInsets參數是一個UIEdgeInsets類型的結構體,由四個浮點型數字組成:top,left,bottom,right。它們代表著從圖片邊緣向內的距離。在一個比圖片要大的上下文中,可調整大小的Image有兩種工作模式,通過 resizingMode: value: 指定
UIImageResizingModeTile
在上面capInsets 指定的內部矩形地區會平鋪在內部,每一個邊緣由對應邊的矩形地區平鋪而成,而外面的四個角落的矩形不變。
UIImageResizingModeStretch
內部的矩形會被展開一次來填充內部,每個邊緣由對應變的矩形地區展開而成,而外面的四個角落的矩形不變。
例如:假設 self.iv 是一個有固定長寬的UIImageView,contentMode是UIViewContentModeScaleToFill。
(1)設定capInsets 為 UIEdgeInsetsZero
UIImage* mars = [UIImage imageNamed:@"Mars"]; UIImage* marsTiled = [mars resizableImageWithCapInsets: UIEdgeInsetsZero resizingMode: UIImageResizingModeTile]; self.iv.image = marsTiled;
(2)
UIImage* marsTiled = [mars resizableImageWithCapInsets: UIEdgeInsetsMake(mars.size.height/4.0, mars.size.width/4.0, mars.size.height/4.0, mars.size.width/4.0) resizingMode: UIImageResizingModeTile];
(3)常用的展開策略是把幾乎是原始圖片的一半作為capinset,僅僅在中間留出1到2像素來填充整個內部。
UIImage* marsTiled = [mars resizableImageWithCapInsets: UIEdgeInsetsMake(mars.size.height/2.0 - 1, mars.size.width/2.0 - 1, mars.size.height/2.0 - 1, mars.size.width/2.0 - 1) resizingMode: UIImageResizingModeStretch];
在最新的Xcode5 中,我們可以不用代碼來配置一個可調整大小的圖片,僅僅通過Xcode5提供的一個 asset catalogs 功能,而不用多次編寫同樣的代碼,這個功能僅在ios7.0以上版本可用。
圖片的渲染模式
在ios應用介面的很多地方,會自動把圖片當作透明遮罩,也稱為模板。這樣意味著會忽略圖片的顏色,僅僅保留每個像素對應的透明度(alpha)。在螢幕上顯示的圖片就是單一的色調與圖片透明度合成在一起的效果。例如標籤欄按鈕的圖片或者在工具列中類型為UIBarButtonItemSylePlain的按鈕的圖片,都是這種模式。 在最新的ios7系統中,圖片類添加了一個新的屬性:renderingMode,表示圖片渲染模式。這個屬性是
唯讀的。為了改變圖片這個屬性,我們可以通過已有的圖片以不同的渲染模式產生新的圖片,調用這個方法:imageWithRendingMode:。渲染模式有三種,分別為:UIImageRenderingModeAlwaysOriginal,UIImageRenderingModeAutomatic,UIImageRenderingModeAlwaysTemplate 。UIImageRenderingModeAutomatic 預設是UIImageRenderingModeAutomatic模式,也就是除了在上面所說的地方使用透明模板模式外,其他地方都是原樣顯示圖片。有了這個渲染屬性,我們可以強製圖片按照通常的方式繪製,即使在一個通常用透明模板模式渲染圖片的上下文中也可以,反之亦然。蘋果公司希望iOS7應用在整個介面中使用更多的透明模板模式。下面是ios7系統設定應用中的例子:
圖形上下文
UIImageView會為你繪製一張圖片,並處理好所有的細節,很多情況下,這就是你所需要的。即使那樣,你可能也會想直接用代碼來繪製一些自己想要的東西,這時,你需要一個圖形上下文。 一個圖形上下文通常來說就是你能夠繪製的一塊地區。相反地,你只能通過一個圖形上下文來在代碼中進行繪製。有多種方式來獲得一個圖形上下文,這裡將介紹兩種,這兩種目前在我遇到的各種情況下用得最多:
自己建立一個圖片上下文
UIGraphicsBeginImageContextWithOptions 函數產生一個適合用作圖片的圖形上下文。然後你可以在這個圖形上下文中產生圖片。當你完成了繪製,你可以調用UIGraphicsGetImageFromCurrentImageContext 來把當前的圖形上下文轉換成一個UImage,最後調用UIGraphicsEndImageContext來釋放這個上下文。現在,你擁有了一個可以顯示在你的介面中或者在其他上下文中繪製的或者是儲存為一個檔案的UIImage對象了。
Cocoa給你一個圖形上下文
你可以子類化UIView,並實現drawRect:方法。在你實現的這個drawRect:方法被調用時,Cocoa已經為你建立了一個圖形上下文,並叫你立刻使用它來繪製;不管你繪製什麼,都會在UIView中顯示出來。(這種情況的一個輕微的變種就是,你子類化CALayer,並實現drawInContext:方法,或者給layer圖層委託一些對象,並實現drawLayer:inContext:方法,以後會再次討論這個)。 在任何給定的時刻,一個圖形上下文要麼是當前的圖形上下文,要麼不是: * UIGraphicsBeginImageContextWithOptions 不僅建立一個圖片上下文,同時也會把這個上下文設定為當前的圖形上下文。 * 當drawRect:方法被調用時,UIView正在繪製的上下文就已經是當前的圖形上下文了。 * 以一個上下文為參數的回調,不會使任何的上下文為當前的圖形上下文,相反,這個參數僅僅是一個圖形內容相關的引用。 讓初學者困惑的是有兩個單獨的工具集來繪製,它們在繪製時對圖形上下文使用了不同的參數:
UIKit
很多Objective-C類知道如何繪製它們自己,包括UIImage,NSString(繪製文本),UIBezierPath(繪製圖形)和UIColor。這些類中有些提供 了方便的方法和有限的功能;另一些則是功能非常強大。很多情況下,UIKit將是你所需要的全部。 通過UIKit,你只能在當前的圖形上下文中繪製。所以如果你是在使用UIGraphicsBeginImageContextWithOptions 或者drawRect:的情況下,那麼你就可以直接使用UIKit提供的方便的方法;裡面提供了一個當前的上下文,也是你想繪製的那個上下文。如果你已經持有了一個上下文參數,另一方面,你也想使用UIKit的方便方法,你將需要把這個上下文轉變為當前的上下文,通過調用 UIGraphicsPushContext(記得在最後還原上下文,調用UIGraphicsPopContext)。
Core Graphics
這個是完整的繪圖API。Core Graphics 通常稱為Quartz,或者Quartz2D,是構成所有iOS繪畫的繪畫系統 ----UIKit的繪畫就是構建在它之上的-----所以是包含了大量C函數的底層架構。這個小節將讓你熟悉它的原理。為了擷取更全面的資訊,你可以學習蘋果的Quartz 2D編程指南(Apple's Quartz 2D Programming Guide)。 為了使用Core Graphics,你必須指定一個圖形上下文來進行繪製,確切地說,是在每個函數調用中。但是在UIGraphicsBeginContextWithOptions或者drawRect:方法中,你沒有一個內容相關的引用;為了能夠使用Core Graphics,你需要拿到這個引用。由於這個你想用來繪製的上下文就是當前的上下文,你可以調用 UIGraphicsGetCurrentContext來獲得所需的引用。 所以現在我們有兩套工具集,對應的兩種上下文又提供了三種方式,所以我們一共有六種方式繪畫。下面我將一一說明這六種!你不需要擔心實際的這些繪畫命令,僅僅專註於怎麼指定上下文以及我們是在使用UIKit還是Core Graphics。首先我將通過子類化UIView,並實現drawRect:方法來繪製一個藍色圓形;使用UIKit已經為我提供的當前上下文來繪製:
- (void) drawRect: (CGRect) rect { UIBezierPath* p = [UIBezierPath bezierPathWithOvalInRect: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:。這種情況下,我們手中的上下文引用並不是當前上下文,所以我需要用UIKit把它轉換成當前上下文:
- (void)drawLayer:(CALayer*)lay inContext:(CGContextRef)con { UIGraphicsPushContext(con); UIBezierPath* p = [UIBezierPath bezierPathWithOvalInRect:CGRectMake(0,0,100,100)]; [[UIColor blueColor] setFill]; [p fill]; UIGraphicsPopContext();}
為了在drawLayer:inContext:中使用Core Graphics,我僅僅需要簡單地保留一個我持有的上下文即可:
- (void)drawLayer:(CALayer*)lay inContext:(CGContextRef)con { CGContextAddEllipseInRect(con, CGRectMake(0,0,100,100)); CGContextSetFillColorWithColor(con, [UIColor blueColor].CGColor); CGContextFillPath(con);}
最後,為了完整性,讓我們建立一個藍色圓形的UIImage對象。我們可以在任何時間(我們不需要等待某些特定方法被調用)以及在任何類(我們不需要在UIView的子類)中建立。建立的UIImage你可以在任何地方正常使用,例如,你可以把它放到一個可見的UIImageView中當做圖片展示,或者你可以把它儲存在一個檔案中,或者你可以在其他的繪製中使用(下一節介紹)。
首先,我使用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(); // im is the blue circle image, do something with it here ...
下面是使用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();// im is the blue circle image, do something with it here ...
你可能會對UIGraphicsBeginImageContextWithOptions這個方法的參數感到疑惑,其實第一個參數顯然是將要建立的圖片的大小。第二個參數表明這個圖片是否是不透明的,如果我在上面的方法中傳遞YES而是不是NO,我的圖片將會有一個黑色背景,而我不想要這種效果。第三個參數指定圖片的縮放比例,傳遞0是告訴系統根據當前螢幕的尺寸為我自動化佈建壓縮比例,這樣我的圖片就會在單解析度和雙解析度螢幕下都能完美顯示。
你不必完全使用UIKit或者Core Graphics,相反地,你可以混合UIKit 調用和Core Graphics調用來操作同樣的圖形上下文。它們僅僅只是表示兩種不同的方式對同樣的圖形上下文通訊而已。
CGImage繪畫
UIImage在Core Graphics中的版本是CGImage(實際上是CGImageRef)。它們可以很容易地互相轉換:UIImage有一個CGImage的屬性,可以訪問它的Quartz 的圖片資料,你也可以把CGImage 轉換成UIImage,使用imageWithCGImage:或者initWithCGImage:(在實戰中,你會更偏向使用更加可配置性的姐妹方法:imageWithCGImage:scale:orientation: 以及 initWithCGImage:scale:orientation:)。
一個CGImage可以讓你從一個原始圖片的一個矩形地區中建立一個新的圖片,而UIImage是做不到的。(一個CGImage還有其他強大的功能而UIImage沒有的,例如你可以將圖片的遮罩應用到CGImage中)。我將會通過分隔一張火星圖片為兩半,並分開單獨繪製每一邊。
注意,我們現在是在CFTypeRef範圍下操作,必須自動管理好內容:
UIImage* mars = [UIImage imageNamed:@"Mars"]; // extract each half as a CGImage 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)); // draw each CGImage into an image context 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(); CGImageRelease(marsLeft); CGImageRelease(marsRight)
但是這裡的例子有個問題:繪製的東西上下顛倒了! 它不是被旋轉了,而是從上到下映射,或者用專業的術語,翻轉。這種想象會發生在你建立了一個CGImage,然後通過CGContextDrawImage繪製時,是由於源和目標內容相關的本地座標系統不匹配。
有多種的方式補償這種不同座標系統之間的不匹配。其中一種就是把CGImage繪製成一個中間的UIImage,然後從UIImage中擷取CGImage,下面展示一個通用的函數來實現這種轉換:
// Utility for flipping an image drawingCGImageRef 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產生的問題,讓它們正確畫出火星的一半。
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.png),這個繪製就會出錯。原因就是我們使用 imageNamed:來擷取原始的火星圖片,這樣就會返回一個為了適配雙解析度而設定自己的縮放比例來產生雙倍解析度的圖片。但是CGImage沒有scale屬性,同時對這張圖片為原生解析度兩倍一無所知!因此,我們在雙解析度裝置上,我們通過調用 [mars CGImage]獲得到的火星CGImage圖片,是火星圖片大小的兩倍,那麼我們所有的計算都是錯的。
所以,為了在CGImage提取想要的片,我們必須把所有適當的值乘以縮放比例scale,或者以CGImage的尺寸來描述大小。下面是我們在單分屏和雙分屏都正確繪製的一個代碼版本,並且補償了翻轉效果:
UIImage* mars = [UIImage imageNamed:@"Mars"]; CGSize sz = mars.size; // Derive CGImage and use its dimensions to extract its halves 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); // The rest is as before, calling flip() to compensate for flipping 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);
另一種方案就是:在UIImage裡麵包裝一個CGImage,繪製這個UIImage。UIImage可以通過調用 imageWithCGImage:scale:orientation:來實現這種方式,補償縮放帶來的影響。此外,通過繪製一個UIImage,而不是一個的CGImage,我們避免了翻轉問題。下面是一種同時處理翻轉和縮放的方法(沒有調用我們上面的公用類):
UIImage* mars = [UIImage imageNamed:@"Mars"]; CGSize sz = mars.size; // Derive CGImage and use its dimensions to extract its halves 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 會意外發生翻轉的曆史,來源於OS X世界,OS X 裡的座標系統的原點預設是在左下角,正Y方向是向上的,而在iOS中,座標原點預設在左上方,正Y方向是向下的。在大多數的繪畫中沒有問題,因為圖形內容相關的座標系統會自動適應的。另外,在iOS的Core Graphics架構中的上下文繪畫時,內容相關的座標系統原點是左上方,我們都知道,但是,建立和繪製CGImage在兩個座標系統之間,互不匹配。