iOS 绘画学习(1)

  本文翻译自:《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系统设置应用中的例子:
 
 
  
  为了方便实现这种效果,iOS7给UIView添加了一个tintColor的属性,用来给图片包含的任意模板着色。而且,这个属性默认是从视图层次结构中继承下来的,贯穿整个应用,从UIWindow开始。此外,给你的应用主窗口分配一个tint color可能是你对主窗口为数不多的改变之一,否则你的应用将会采用系统的蓝色色调颜色(如果你使用storyboard故事板,可以在File inspector 文件检查器中修改这个tint color)。也可以为独立的视图设置它们自己的tint color,它们的子视图会继承该tint color。下面就是在一个窗口的tint color 为 红色的 应用中,两种同样的图片不同的展示,一个是通常的渲染模式,另外一个是模板模式:
 
 

  图形上下文
   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 drawing
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产生的问题,让它们正确画出火星的一半。

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在两个坐标系统之间,互不匹配。

posted @ 2014-05-16 15:38  剑尖  阅读(1450)  评论(2编辑  收藏  举报