programming iOS - view drawing (一)

许多UIView的子类,如一个UIButton或一个UILabel,它们知道怎么绘制自己。迟早,你也将想要做一些自己的绘制。你可以事先准备好您的绘图作为一个图像文件。您可以用代码绘制一张图片在应用程序运行中。您可以在`UIView`子类,例如一个UIImageVie或一个UIButton中显示在你的图像。一个纯粹的UIView有关于绘画的一切,绘图很大程度上取决于你;你的代码决定view画什么,你的界面就是什么。

Images and Image Views

基本的UIKit的图像类是UIImageUIImage可以读取磁盘上的文件,因此,如果图像不需要动态创建,一种简单的方式就是在应用程序运行前提供一个图片文件放到程序包资源中。系统知道如何处理标准图像文件类型,如TIFF,JPEG,GIF和PNG;当图像文件将被包含在你的应用程序包,你应该优先提供PNG格式的图片,因为系统对PNG格式有种有特殊的亲和力。还可以以其他方式获取图像数据,例如通过下载,并转变成一个UIImage。相反,你也可以用代码构造一个UIImage并显示在你的界面或保存到磁盘。

Image Files

可以通过UIImage的初始化init(named:)方法来获得app bundle中的图片文件。这个方法在两个地方查找图片:

  • Asset catalog

    我们在asset catalog中查找与name名称相同的图片。该名称是区分大小写的。

  • Top level of app bundle

    我们在app bundle的最上层寻找与name名称相同的图片。该名称是区分大小写的,并应包括文件扩展名;如果它不包括文件扩展名,则假定.PNG。

当调用 init(named:)时,asset catalogapp bundle top level 之前搜索图片文件。如果有多个asset catalog,它们都会进行搜索,但搜索顺序是不确定的并且不能指定搜索顺序,所以应该避免图像具有相同的名称。

关于init(named:)的一个好处是图像数据可能会缓存在内存中,,如果你以后再通过调用init(named:)访问为相同的图像,缓存中的数据可以立即。或者,你可以使用init(contentsOfFile:)直接从 app bundle 里面读取图像数据而不缓存,但是需要提供一个路径字符串作为参数; 你可以通过NSBundle.mainBundle()获取到app's bundle,然后通过NSBundle的实列方法,例如pathForResource:ofType获取文件的路径。

获取app bundle内资源的方法,如 init(named:)pathFor- Resource:ofType:,都会识别实际的资源文件的后缀。在double-resolution 屏幕上,当通过文件名在 app bundle 内获取图片时,具有相同图片名并且含有@2x的图片将自动使用,与由此生成的UIImagescale属性值为2.0。同样,如果文件具有相同的名称并且有 @3x,它扩展将会用于iPhone 6 Plus,并且 scale 属性值为 3.0

通过这种方式,您的应用程序可以包含一个图像文件在在不同分辨率下的多个版本。由于scale属性,图像的高分辨率版本和单分辨率的版本绘制的大小相同。因此,在高分辨率的屏幕上,代码不用修改就能工作,但图片看起来清晰。

同样,具有相同名称的由~ipad扩展的文件当appipad上运行时会被自动使用 。你可以使用这种方式在universal app中根据不同的设备 iPhone 或者 iPad 提供不同的图像(这对任何在bundle中的资源都是有效的而不只是图片)

asset catalog的一个好处是你可以忘记所有关于这些后缀名约定。asset catalog知道在一个image set内何时使用备用图像,不是根据它的名字,而是根据它在catalog中的位置。把single-,double-,triple-resolution分辨率的图片放到标记着1x,2x,3x的位置上。对于不同的iPad版的图像,检查iPhoneiPad的图像集的属性检查器,不同的图片位置将会出现在asset catalog中。

Asset catalog也可以区分图像在不同size class 下的版本。在图像集的属性检查器中,使用宽度和高度的弹出菜单来指定要区分哪个size class。如果我们把运行着app的iPhone旋转到横向,如果有既有的图片集中的Any heightCompact height图像都可以使用的话,会优先使用Compact height版本的图像。这些功能是实时的在app运行期间;如果应用程序从横向旋转为纵向,Any height会自动替换掉Compact height的图片,如果图片集中的2中图像都可以使用的话。

Asset catalog这种神奇的能力是通过trait collectionsUIImageAsset类来实现的。当图像通过初始化init(named:)asset catalog中获取时,它的imageAsset属性是一个UIImageAsset。在图像集所有的图像都可以通过UIImageAsset获取;每个图像都有trait collection,你可以访问图像的imageAsset的属性和 imageWithTraitCollection:从相同的图像集中得到特定trait collection的图片。

一个内置的显示图像的interface object能自动的识别trait collection;它接收traitCollectionDidChange:消息并相应地作出响应。我们可以通过构造一个有image属性的UIView来实现这个功能:

class MyView: UIView {
    var image: UIView!
    override func traitCollectionDidChange(previous: UITraitCollection?) {
    self.setNeedDisplay()
    }
    override func drawRect() {
        if var im = self.image {
            if let asset = self.image.imageAsset {
                let tc = self.traitCollection
                im = asset.imageWithTraitCollection(tc)
            }
            im.drawAtPoint(CGPointZero)
        }
    }
}

此外,你的代码也可以将图像合并到一个UIImageAsset - 代码相当于一个asset catalog中的image set,但是并没有asset catalog 。因此,你可以实时的创建图像,或者在app bundle的外部获取图像,并且自动配置当iPhoneportrait ori‐ entation时使用前一个图像,当iPhonelandscape orientation 时使用另一个图像:

let tcdisp = UITraitCollection(displayScale:UIScreen.mainScreen().scale)
let tcphone = UITraitCollection(userInterfaceIdiom: .Phone)
let tcreg = UITraitCollection(verticalSizeClass: .Regular)
let tc1 = UITraitCollection(traitsFromCollections: [tcdisp, tcphone, tcreg]) 
let tccom = UITraitCollection(verticalSizeClass: .Compact)
let tc2 = UITraitCollection(traitsFromCollections: [tcdisp, tcphone, tccom]) 
let moods = UIImageAsset() 
let frowney = UIImage(named:"frowney")! 
let smiley = UIImage(named:"smiley")!
moods.registerImage(frowney, withTraitCollection: tc1)
moods.registerImage(smiley, withTraitCollection: tc2)

之后,如果把frowney放到用户界面 - 例如,一个UIImageViewimage属性 - ,当app改变方向时,它将和smiley交替显示。可喜的是,即使是没有永久引用frowneysmiley,或UIImageAssetmoods),这都会自动发生。原因是,frowneysmiley由系统缓存(因为调用init(named:)),他们各自保持一个它们自己关联的UIImageAsset的强引用。

通过init(named:inBundle:compatibleWith- TraitCollection:)app bundle或者asset catalog获取图像时可以指定一个目标trait collectionbundle参数经常为nil,这表示app‘s main bundle

Image Views

许多内置的CocoaCocoa interface objects接受一个UIImage做为自己的一部分去绘制;例如,一个UIButton能够显示图像,UINavigationBar或者UITabBar可以有一个背景图像。当你只是想一个图像出现在你的界面上,你可能把它交给一个图像视图 - UIImageView - 它的作用就是显示图像。

nib编辑器在这方面提供了一些快捷方式:一个interface object的属性检查器中,会有一个弹出菜单,其中包含项目中的所有图像,这些图像也会在Media library中显示(Command-Option- Control-4)。Media library的图像往往可以拖动到画布上的interface object上显示,如果只是拖动Media library里的图像到空白的view,它被转换成显示该图像的一个UIImageView

一个UIImageView实际上可以有两幅图片,其中一个分配给自己的image 属性和另一个分配给其highlightedImage属性;UIImageViewhighlighted属性值决定在任何给定的时间显示哪幅图。和button一样,UIImageView只会在用户点击它的时候高亮显示。不过,在某些特定的情况下UIImageView会对它周围的高亮作出回应;例如在table view cell中,当cell高亮的时候,ImageView会显示它的highlighted image

UIImageView也是UIView,因此它可以有图像属性外也可以有背景颜色,它可以有一个alpha(透明度)值,等等。图像可能有透明的区域,也可以是任何形状,UIImageView都会显示它;没有背景颜色的UIImageView不会显示在界面中,除非它的image属性不为空,图像仅仅只是显示在界面中,用户不会意识到它是在一个矩形框中。即没有背景颜色,image属性也没有值的UIImageView是不可见的,所以你可以以一个空的UIImageView开始,并随后在代码中指定image属性。你可以指定一个新的图像来代替旧的,或者设置image属性的值为nil来移除UIImageView的图像。

UIImageView如何绘制自己的图像取决于其contentMode属性(UIViewContentMode)的设置。 (该contentMode属性是从UIView继承的)例如,.ScaleToFill意味着图像的宽度和高度都设置为视图的宽度和高度,从而完全填充视图即使这会改变图像的长宽比;.Center居中绘制图像而且不改变t图像的大小。理解contentMode最好的方式是在nib中为UIImageView分配一个小图像,然后在属性检查器中,切换不同的mode,观察图像如何绘制自身。

你还应该注意UIImageViewclipsToBounds属性;如果是false,即使它的图像比image view更大,或者图像没有被contentMode按比例缩小,那么图像可以延伸超出image view本身全部显示。

当在代码中创建UIImageView,你可以充分利用便利构造器的优势,init(image:) (或者 init(image:highlightedImage:)).默认的contentMode.ScaleToFill,但图像初始时是不缩放的,而是调整view自身的大小去匹配图像。你仍然可能需要把UIImageView放到它的父视图上正确位置。在下面这个例子中,我把火星图片在应用程序界面中心:

let iv = UIImageView(image: UIImage(named: "Mars"))  //asset catalog
mainView.addSubview(iv)
iv.center = iv.superview!.bounds.center
iv.frame.makeIntegralInPlace()

为一个已经存在的UIImageView指定图片时,UIImageView的大小如何改变取决于它是否使用autolayout。如果没有使用autolayout,或者它的大小被约束完全限定,那么UIImageView的大小不发生变化。但在autolayout下,除非其他约束阻止,新的图像的尺寸会变为image viewintrinsicContentSize,因此imageview将变为图像的大小。

image view会自动从图像的alignmentRectInsets获得其alignmentRectInsets。因此,如果你打算使用自动布局来调整image view对齐到其他对象,你可以将相应的alignmentRectInsets设置到将要显示的图像上,那么image view会正确的显示。要实现这个功能,通过在原始图像上调用imageWithAlignmentRectInsets派生出新的图像。

从理论上讲,你应该能够在Asset catalog中设置图像的alignmentRectInsets。但是当写这篇文章时,此功能无法正常工作。

Resizable Images

在界面中某些地方可能需要可以调整大小的图像;例如,在作为一个滑块或进度条视图的轨道的自定义图像必须能够调整大小,以便它可以填充任何长度的空间。还有经常需要通过平铺或拉伸现有图像填充背景的其他情形。

通过在正常的图像上调用resizableImageWithCapInsets:resizingMode:方法来创建动态伸缩的图像。capInsets:参数是一个UIEdgeInsets,其分量代表向内到图像的边缘的距离(可以理解为内边距)。在一个比图像大的context中,可调整大小的图像有2种表现方式,这取决于resizingMode:的值(UIImageResizingMode):

  • .Tile


    变化的区域的内部图片是平铺(重复);每一条边是由非变化区域的相应边缘矩形组成的。相对于变化区域的四个角落的绘制不变。

  • .Stretch


    变化区域的内部被拉伸一次以填充;每一条边是由非变化区域的相应边缘矩形组成的。相对于变化区域的四个角落的绘制不变。

在下面的例子中,假设self.iv是一个绝对高度和宽度(因此它不会用它的图像的大小来设置自己的大小)而且contentMode属性为.ScaleToFill(图像会有伸缩的行为)的UIImageView。首先,我会说明怎么平铺整个图像;注意,capInsets:的值是UIEdgeInsetsZero

let mars = UIImage(named: "Mars")!
let marsTiled = mars.resizableImageWithCapInsets(UIEdgeInsetsZero, resizingMode: .Tile)
self.iv.image = marsTiled

然后,改变上面代码种capInsets参数的值来重新平铺上面的图片:

let 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: .Tile)

然后来说明拉伸,从改变上面代码的resizingMode开始:

let 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: .Stretch)

效果如下:


一个常见的延伸策略是让几乎一半的原始图像作为cap inset,只留下中心的一两个像素来填充空白区域:

let 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: .Stretch)

效果如下


你也应该尝试不同的contentMode设置。在上的例子中,如果该图像视图的contentMode.ScaleAspectFill,并且如果图像视图的clipsToBoundstrue,我们会得到一种渐变的效果,因为拉神完成的图象的顶部和底部已经超出image view的边界而不会被绘制出来。
效果如下:

你可以通过项目的asset catalog而不是代码来配置一个可调整大小的图像。经常出现的情况是:一个特定的图像将在您的应用中主要被用来作为一个可调整大小的图像,并且总是具有同样的capInsetsresizingMode,所以很有必要只配置此图像一次,而不是重复写相同的代码。即使图像在asset catalog中配置为可调整大小,它也可以做为一个正常的图片出现在你的界面中, 例如,你可以把它分配给根据图像大小调整自身大小的image view,或者是不会压缩或者拉伸自己图像的image view

要在asset catalog中配置一个可调整大小的图像,首先选择图像,然后在Slicing section的属性检查器中,更改Slices弹出菜单为Horizontal,Vertical,或者是Horizontal and Vertical。当你执行此操作时,会有更多的界面出现供你配置参数。您可以在另外的弹出菜单中配置resizingMode。也可以用数字,或单击画布右下角的Show Slicing,这都会在画布上显示配置好的图像。图形编辑器是可缩放的,所以你可以放大到你觉得舒服的大小。

这个功能实际上比resizableImageWithCapInsetsresizingMode:更加强大.它可以让你从平铺或拉伸区域分别指定结束的caps,而剩下的部分将会被切掉。如下图所示:


在上图中左上角,右上角,左下角和右下角的暗色区域将原样绘制。窄带将被拉长,顶部中心的小矩形将被拉伸以填充大部分的区域。但是图像的其余部分,被纱布幕覆盖的中央大片区域,将被完全省略。结果如下图:

Image Rendering Mode

iOS应用的用户界面在某些地方会自动将图像作为一个透明遮罩(transparency mask),也被称为template。这意味着图像的每个像素的透明度(alpha)会起作用,而颜色值将被忽略。在屏幕上显示的图像是由图像的透明度值和一个纯色(tint color)混合而成。tab bar item的图片显示就是这种方式。

图像被怎样显示是通过图像的只读renderingMode的属性控制的;只能通过在图像上调用imageWithRenderingMode:来生成一个新的图像来更改rendering mode值.渲染模式的值(UIImageRenderingMode)为:

  • .Automatic
  • .AlwaysOriginal
  • .AlwaysTemplate

默认值是.Automatic。这意味在某些被限制的上下文中它被用作透明遮罩(transparency mask)外,大部分情况都会被原样绘制出来。

通过设置renderingMod属性,你可以强制正常绘制图像,即使在一个把它当作一个透明遮罩(transparency mask)的上下文中。相反的:你可以强制绘制图像为透明度遮罩(transparency mask),即使在正常的上下文中。

为了实现这个功能,iOS给每个UIView一个tintColor属性,这将对它包含的任何模板图像进行着色。此外,tintColor默认是从应用程序的最上面window一直贯穿整个视图层级而继承的。因此,更改应用程序的主windowtintColor可能是你仅有的能对window所做的改变之一;否则,你的应用程序将采用系统的蓝色的tint color。 (如果你使用的是main storyboard,可以在文件检查器中设置Global Tint color的颜色。)可以设置个别viewtint color, 这会被他的子视图继承。下图显示了一个应用中主windowtintcolor是红色的,显示相同的背景图片的两个按钮,一个正常渲染,另一个以模板方式渲染:

asset catalog中可以设置图像的渲染模式。在asset catalog中选择图片,在属性检查器中使用弹出的Render菜单设置渲染模式为默认值(.Automatic),原始图像(.AlwaysOriginal),或模版图像(.AlwaysTemplate) 。这是一个很好的办法,这使你每次使用相同的渲染模式设置图片时少写很多代码。而且,当调用init(named:),会返回已经设置好渲染模式的图片。

Reversible Images

新的iOS9系统中,当你的app是在一个本地语言是从右到左的系统上运行的时候,整个用户界面会自动改变翻转(改变方向)。一般情况下,这不会影响到你的图片。runtime会假定你不想反转图片当用户界面反转的时候,因此其默认行为是让他们保持原样。

不过,你可能希望图片随着系统一起反转。例如,可能你已经绘制好一个当用户点击按钮后指向新界面出现的方向的箭头。如果按钮作用是在导航界面进入一个新的视图控制器,在从左往右的系统图片箭头方向指向右边,但是在从右往左的系统上图片箭头方向应该指向左边。此图像在应用程序的界面上具有方向的信息;因此它需要水平翻转当界面反转时。

通过调用图像的imageFlippedForRightToLeftLayoutDirection,并在界面使用新生成的图像来达到上面的效果。在左到右系统中,正常的图像将被使用;而在右到左的系统上时,将会自动创建并使用对应的相反的图片。您可以重写此行为,即使图像是可反转的。对于UIView,例如UIImageView,通过设置viewsemanticContentAttribute防止图片镜像。

不幸的是,在asset catalog中还没有办法指定图片是可反转的。因此,在界面中出现的图片 - 例如,在nib中配置的UIImageView - 你必须使用代码去控制。在下面这个例子中,我在视图控制器的viewDidLoad方法中取出图像视图(self.iv)的图片,并用它自身的可反转版本替换它自己:

override func viewDidLoad() {
    super.viewDidLoad()
    self.iv.image = self.iv.image?.imageFlippedForRightToLeftLayoutDirection()
}

Graphics Contexts

你可能想使用代码绘制一些图像,而不是仅仅使用已经存在的图片文件。要实现这一点,你需要一个图形上下文(graphics context)。

图形上下文就是你可以绘制图像的地方。反之,你不能用代码绘制,除非你有一个图形上下文。有几种方法可以得到一个图形上下文;我将主要介绍两种已经被我的经验证明是最常见的方式:

  • You create an image context
    UIGraphicsBeginImageContextWithOptions函数会创建一个适合用于绘制图像的上下文。你可以绘制到此上下文并生成图像。当这样做之后,调用UIGraphicsGetImageFromCurrentImageContext把上下文变成一个UIImage,然后调用UIGraphicsEndImageContext清除上下文。现在你有了一个图片,你可以让它显示到界面上或者绘制到一些其他的图形上下文或者保存为一个文件。
  • Cocoa hands you a graphics context
    你子类化UIView然后实现drawRect:方法.当你的drawRect:方法被调用的时候,Cocoa同时会为你创建一个图形上下文,并要求你在里面绘制;不管你画什么UIView都会显示出来。
    这种情况有一种轻微的变体是你继承CALayer并在layerdelegate里面实现drawInContext:,或实现drawLayer:inContext

此外,在任何时刻图形上下文要么是或不是当前的图形上下文:

  • UIGraphicsBeginImageContextWithOptions不仅创造了一个图形上下文,这也使得上下文成为当前的图形上下文。
  • drawRect:被调用时,UIView的图形上下文已经是当前的图形上下文。
  • 有一个context:参数的回调函数并不会使任何上下文变成当前的图形上下文;相反,该参数是一个想要你在里面绘制的图形上下文的引用,但是只有它称为当前的图形上下文你才能在它里面绘制,是否这么做取决于你。

初学者对于绘制感到困惑的原因是有两套关于绘制图像的工具,但是它们对于绘制图像时的context的处理方式一点都不相同。一组需要一个当前上下文;另一组只是需要一个上下文:

  • UIKit
    很多Cocoa类知道如何绘制它们自己;其中包括UIImageNSString(绘制文本),UIBezierPath(用于绘制形状),和UIColor。有些类提供能力有限但是方便的方法;有些类则非常强大。在许多情况下,UIKit会满足你所以的需求。
    你只能绘制到当前图形上下文中当你使用UIKit时。所以,如果你在一个UIGraphicsBeginImageContextWithOptionsdrawRect:情况下,你可以直接使用UIKit便利的方法;而且会有一个当前的图形上下文,它就是你想在里面绘制的上下文。另外,如果你有一个context:的参数,而且你还想使用UIKit便利的方法,你只能把上下文变成当前的图形上下文;通过调用UIGraphicsPushContext做到这一点(并确保用UIGraphicsPopContext来还原)。
  • Core Graphics
    这是一个完整的绘图API。Core Graphics,通常被称为Quartz ,或Quartz 2D,是iOS绘图系统的基础 - UIKit的绘制是建立在它之上 - 所以它是低层次的,包含很多C函数。
    使用Core Graphics绘制时,你必须在每个函数调用中明确的指定将要绘制的图形上下文。如果你你有一个context:参数,这可能是你想要绘制的图形上下文。在UIKit中你不需要一个context的引用,但是在Core Graphic中你需要一个context的引用。既然你在当前的图形上下文里面绘制,通过调用UIGraphicsGetCurrentContext来得到当前图形上下文的引用。

你不必分开使用UIKitCore graphics。相反,你可以在同一个代码块中使用UIKit和Core graphics操作相同的图形上下文。他们只是控制图形上下文绘制的两种不同方式。

因此,在一个图形上下文中我们有两套工具和三种方法,总共六种方式来绘图。在下面的例子中,我将说明这六种方法。首先,我将通过继承UIView然后实现drawRect:方法,在UIKit为我们准备好的当前图形上下文中画一个蓝色圆圈:

override func drawRect(rect: GGRect) {
    let p = UIBezierPath(ovalInRect: CGRectMake(0, 0, 100, 100))
    UIColor.blueColor().setFill()
    p.fill()
}

下面我将会用Core graphics实现相同的功能,但是得先获得当前图形上下文得引用:

override func drawRect(rect: CGRect) {
    let context = UIGraphicsGetCurrentContext()
    CGContextAddEllipseInRect(context, CGRectMake(0, 0, 100, 100))
    CGContextSetFillColorWithColor(context, UIColor.blueColor().CGColor)
    CGContextFillPath(context)
}

接下来我会实现一个UIView子类的drawLayer:inContext:方法。在这个例子中,会有一个context的引用,但是它并不是当前的图形上下文,所以为了使用UIKit,我得先让它成为当前得图形上下文:

override func drawLayer(layer: CALayer, inContext context: CGContext) {
    UIGraphicsPushContext(context)
    let p = UIBezierPath(ovalInRect: CGRectMake(0, 0, 100, 100))
    UIColor.blueColor().setFill()
    p.fill()
    UIGraphicsPopContext()
}

然后我引用以下传进来得context就可以在drawLayer:inContext中使用Core graphics:

override func drawLayer(layer, inContext context: CGContext) {
    CGContextAddEllipseInRect(context, CGRectMake(0, 0, 100, 100))
    CGContextSetFillColorWithColor(context, UIColor.blueColor().CGColor)
    CGContextFillPath(context)
}

最后,我将做一个蓝色圆圈得图片。我们可以在任何时候(我们不需要等待某些特定方法被调用),并在任何类(我们不需要继承UIView)中实现此功能。由此产生的UIImage适用于可以使用UIImage的任何地方。例如,你可以把它赋值给UIImageViewimage属性,从而导致图像出现在屏幕上。或者你可以将其保存为一个文件。
首先我会使用UIKit来绘制我的图片:

UIGraphicsBeginImageContextWithOptions(CGSizeMake(100, 100), false, 0)
let p = UIBezierPath(ovalInRect: CGRectMake(0, 0, 100, 100))
UIColor.blueColor().setFill()
p.fill()
let im = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()

下面是使用Core Graphics实现相同的功能:

UIGraphicsBeginImageContextWithOptions(CGSizeMake(100, 100), false, 0)
let context = UIGraphicsGetCurrentContext()!
CGContextAddEllipseInRect(context, CGRectMake(0, 0, 100, 100))
CGContextSetFillColorWithColor(context, UIColor.blueColor().CGColor)
CGContextFillPath(context)
let img = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()

你可能想知道UIGraphicsBeginImageContextWithOptions各项参数的意义。第一个参数很明显是要创建图片的尺寸。第二个参数声明图像是不是应该透明;如果这个参数为true而不是false,绘制的图片将有一个黑色的背景,这并不是我想要的效果。第三个参数指定图像的缩放系数;通过传递0,告诉系统根据主屏幕来设置图片的缩放系数,所以我的图片在单分辨率和高分辨率的设备上都会显示很好。

UIImage Drawing

UIImage提供绘制自身到当前的图形上下文的方法。我们知道如何获得UIImage,我们知道如何获得图形上下文并使其成为当前的图形上下文,所以我们可以尝试使用这些方法来绘制图片。

下面,我会做出一张由两张火星图片并排组成的新图片:

let mars = UIImage(named: "Mars")!
let size = mars.size
UIGraphicsBeginImageContextWithOptions(CGSizeMake(size.width * 2, size.height * 2), false, 0.0)
mars.drawAtPoint(CGPointMake(0, 0))
mars.drawAtPoint(CGPointMake(size.width, 0))
let im = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()

效果如下:


在上面的例子中图片的缩放控制的很好。如果有原始的火星图像的多个分辨率版本,系统会根据当前设备选择正确的图片,并指定正确的scale值。调用UIGraphicsBeginImageContextWithOptions函数的第三个参数为0,因此在图像上下文中绘制的图片也会有正确的scale值。而且从UIGraphicsGetImageFromCurrentImageContext得到的图像也会有正确的scale值。因此,上面的代码生成的图片在当前设备上看起来显示效果很好,无论它的屏幕分辨率是多少。

UIImage的其他一些方法,可以让你在需要绘制的矩形中指定缩放系数,并指定和矩形中已经存在的东西的混合模式。为了说明这一点,我将创建一个两倍大的火星图片显示在另一个火星图片的中心,使用.Multiply混合模式:

let mars = UIImage(named: "Mars")!
let size = mras.size
UIGraphicsBeginImageContextWithOptions(CGSizeMake(size.width * 2, size.height * 2), false, 0)
mars.drawInRect(CGRectMake(0, 0, size.width * 2, size.height * 2))
mars.drawInRect(CGRectMake(sizw.width / 2, size.height / 2, size.width, size.height),
    blendMode: .Multiply, 
    alpha: 1.0)
let im = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()

效果如下图:


UIImage的绘制方法没有办法指定原始矩形-你要提取原始图像的某已小块区域。您可以通过创建一个较小的图形上下文然后把图片的指定区域绘制到里面。例如,为了获得火星的右半部分的图像,你可以创建一个只有火星图像宽度一半的图形上下文,然后左移绘制火星,这样火星图片只有右半部分在图形上下文中。这样做没有什么坏处,这个一种非常标准的策略来获取原始图片的某部分区域。代码如下:

let mars = UIImage(named: "Mars")!
let size = mars.size
UIGraphicsBeginImageContextWithOptions(CGSizeMake(size.width / 2.0, size.height), false, 0.0)
mars.drawAtPoint(CGPointMake(-size.width / 2.0, 0))
let im = UIGraphicsGetImageFromCurrenImageContext()
UIGraphicsEndImageContext()

效果如下图:


CGImage Drawing

UIImageCore graphics的版本是CGImage。他们很容易相互转换:UIImage有一个访问其Quartz图像数据的CGImage属性,你可以在CGImage上调用init(CGImage:)或更容易配置的init(CGImage:scale:orientation:)方法来得到一个UIImage。

CGImage允许你直接从原始的UIImage的某个矩形区域创建一个新的图片,而UIImage不能这么做。(CGImage还有其他UIImage不具备的能力;例如,您可以为CGImage应用一个图片遮罩(image mask)。)我会将火星图片分为两半然后分开绘制来说明:

let mars = UIImage(named: "Mars")!
let marsCG = mars.CGImage
let size = mars.size
let marsLeft = CGImageCreateWithImageInRect(marsCG, CGRectMake(0, 0, size.width / 2.0, size.height))
let marsRight = CGImageCreateWithImageInRect(marsCG, CGRectMake(size.width / 2.0, 0, size.width / 2.0, size.height))
UIGraphicsBeginImageContextWithOptions(CGSizeMake(size.width * 1.5, size.height * 1.5), false, 0)
let context = UIGraphicsGetCurrentContext()!
CGContextDrawImage(context, CGRectMake(0, 0, size.width / 2, size.height), marsLeft)
CGContextDrawImage(context, CGRectMake(sizw.width, 0, size.width / 2.0, size.height), marsRight)
let im = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()

效果如下:


但是,上面例子有个问题:绘图是上下颠倒的!代码没有对它执行翻转;但是它是上下镜像的,或使用专业的技术术语:flipped。当你创建一个CGImage,然后用CGContextDrawImage绘制它的时候就会出现这种情况,这是由于原始的上下文和目标上下文的坐标系统不匹配造成的。

有很多种方式来修复不同坐标系统之间的不匹配。其中一种是绘制CGImage到一个中间的UIImage然后从中间UIImage中提取出另一个CGImage。下面的例子通过一个函数来实现这个功能:

func flip(im: CGImage) -> CGImage {
    let size = CGSizeMake(
        CGFloat(CGImageGetWidth(im),
        CGFloat(CGImageGetHeight(im))))
    UIGraphicsBeginImageContextWithOptions(size, false, 0)
    let context = UIGraphicsGetCurrentContext()!
    CGContextDrawImage(context, 
        CGRectMake(0, 0, size.width, size.height), 
        im)
    let result = UIGraphicsGetImageFromCurrentImageContext().CGImage
    UIGraphicsEndImageContext()
    return result!
}

使用上面那个便利的函数,我们可以修复上面例子中使用CGContextDrawImage绘制两半火星的正确方法了:

CGContextDrawImage(context, 
    CGRectMake(0, 0, size.width / 2.0, size.height),
    flip(marsLeft!))
CGContextDrawImage(context, 
    CGRectMake(size.width, 0, size.width / 2.0, sizw.height),
    flip(marsRight!))

但是,我们还有一个问题:一个高分辨率的设备上,如果我们的图片有一个高分辨率的版本,那么绘制就会出错。原因是我们使用UIImageinit(named:)方法获取的火星图片,在高分辨率的设备上它返回一个更大尺寸的火星图像,通过设置图片的scale属性来匹配原始火星图片的大小。但CGImage并没有scale属性,而且也不知道图片的尺寸增加了!因此,在一个高分辨率的设备上,我们从火星图片中提取的mars.CGImagemars.size的尺寸要大,并且之后所有的计算都是错误的。

处理CGImage最好的解决办法是,把它封装为一个UIImage并绘制这个UIImage而不是直接绘制CGImage。从CGImage生成UIImage时可以调用init(CGImage:scale:orientation:)来避免图片的缩放。而且直接绘制UIImage而不是CGImage也避免翻转问题!下面是不使用flip函数来处理翻转和缩放的方法:

let mars = UIImage(named: "Mars")!
let size = mars.size
let marsCG = mars.CGImage
let sizeCG = CGSizeMake(
    CGFloat(CGImageGetWidth(marsCG),
    CGFloat(CGImageGetHeight(marsCG))))
let marsLeft = CGImageCreateWithImageInRect(
    marsCG,
    CGRectMake(0, 0, sizeCG.width / 2, sizeCG.height))
let marsRight = CGImageCreateWithImageInRect(
    marsCG,
    CGRectMake(sizeCG.width / 2.0, 0, sizeCG.width / 2.0, sizeCG.height))
UIGraphicsBeginImageContextWithOptions(
    CGSizeMake(size.width * 1.5, size.height), 
    false,
    0.0)
//instead of calling flip, pass through UIImage
UIImage(CGImage: marsLeft!, 
    scale: mars.scale,
    orientation: mars.imageOrientation)
    .drawAtPoint(CGPointMake(0, 0))
UIImage(CGImage: marsRight!,
    scale: mars.scale,
    orientation: mars.imageOrientation)
    .drawAtPoint(CGPointMake(sizw.width, 0))
let im = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
翻转发生的原因
意外翻转发生的原因是Core graphics移植于OS XOS X的坐标系的原点默认情况下位于左下角和y轴增长方向是向上的,而iOS上的原点默认情况下在位于左上角并且y轴正方向是向下的。在大多数绘图的情况下,因为图形上下文的坐标系统被自动调整,所以这不会有什么问题。因此在iOS中使用Core Graphics绘制时上下文中的坐标系统的原点在左上角,这和你期望的是一样的。但是,创建和绘制CGImage暴露了两个坐标世界之间的不兼容

另一个解决方案是,绘制CGImage之前对图形上下文做一个变换(transform),直接翻转上下文的内部坐标系统。这是非常有效的,但是如果已经存在其他变换,这会非常混乱。

Snapshots

整个视图 -- 从任何一个单一的按钮到你整个界面包含视图整个层次结构--可通过调用UIView的实例方法drawViewHierarchyInRect:afterScreenUpdates:可以被绘制到当前的图形上下文中. (此方法比CALayerrenderInContext:速度快很多;不过,renderInContext:确实很方便)。绘制的结果是原始视图的快照(snapshot):它看起来像原来的视图,但它本质上只是原来视图的一个位图图像。

获得视图的快照更快的方法是使用的UIView(或UIScreen)的实例方法snapshotViewAfterScreenUpdates:.结果是一个UIView,而不是一个UIImage;这很像一个只知道如何绘制一个图像--即快照--的UIImageView。这样的快照视图通常会被用作扩大其边界和拉伸其图像。如果你想拉伸快照让它表现得像一个可调整大小的图像,调用resizableSnapshotViewFromRect:afterScreenUpdates:withCapInsets:

Snapshots非常有用因为IOS界面的动态性质。例如,你可以在真正的视图上面放置一个快照从而隐藏真正的视图上正在发生的事情,或者在动画中移动快照而不是真正的视图。

下面是我的应用程序中的一个例子。这是一个纸牌游戏,它的视图都是纸牌。我想用动画把所有的纸牌从屏幕里面移动到屏幕外面。但我不想移动那些视图本身!他们需要留在原地,绘制以后出现的纸牌。所以我为每个纸牌视图做了一个快照视图;然后,我隐藏真正的纸牌视图,而把快照视图放在它们的位置上,然后用动画移除这些快照。代码如下:

for v in views {
    let snapshot = v.snapshotViewAfterScreenUpdates(false)
    let snap = MySnapBehavior(item: snapshot, 
        snapToPoint: CGPointMake(
            self.anim.referenceView!.bounds.midX,
            -self.anim.referenceView!.bounds.height))
    self.snaps.append(snapshot)
    snapshot.frame = v.frame
    v.hidden = ture
    self.anim.referenceView!.addSubview(snapshot)
    self.anim.addBehavior(snap)
}

CIFilter and CIImage

CIFilterCIImage中的CI代表Core Image,通过数学变换转化图像的技术。Core Image首先在桌面(OS X)环境中使用,并且当它被迁移到iOS系统时,一些在桌面上可用的滤镜无法在IOS上使用(大概是因为对于移动设备来说这么数学运算太过于复杂)。多年来越来越多的OS X滤镜被添加到iOS上,而现在在新的iOS9系统中,两者没有差别:所有OS X滤镜在iOS中都是可用的,并且两个平台都具有几乎相同的API 。

一个CIFilter就是一个滤镜。可用的滤镜可以分为几大类:

  • Patterns and gradients
    这些滤镜创建的CIImage可以与其他的CIImage,诸如单一的颜色,颜色盘,条纹,或梯度组合。
  • Compositing
    这些滤镜使用图像处理软件如Photoshop的混合模式来组合图片。
  • Color
    这些滤镜调整或改变图像的颜色。因此,你可以改变图像的饱和度,色调,亮度,对比度,伽玛值和白平衡,曝光,阴影和高光,等等。
  • Geometric
    这些滤镜对图片执行基本的几何变换,如缩放,旋转和裁剪。
  • Transformation
    这些滤镜对图片进行变形,模糊,或风格化。
  • Transition
    这些滤镜提供了一个图像和另一个之间转换的帧;通过顺序请求帧,可以设置过渡动画效果。
  • Special purpose
    这些滤镜执行高度专业化的操作,如人脸检测和生成的QR码。

CIFilter的基本用法相当简单︰

  • 您可以通过提供滤镜的字符串名称来指定想使用的滤镜;想知道都有哪些名字,查阅Core Image Filter Reference,或调用CIFilter的类方法filterNamesInCategories:并提供一个nil参数。
  • 每个滤镜具有少量的键值对确定其行为。您可以通过代码了解全部的键,但通常你会参考文档。对于你感兴趣的每个键,你提供的一个键 - 值对。在提供值中,数字必须被包装成NSNumber,并且其他有用的类,如CIVectorCIColor,其使用很容易理解。

CIFilter键中输入的图像或被滤镜操作的图片必须是CIImage。可以从CGImage中获取CIImage通过调用init(CGImage:)或者从UIImage中获取CIImage通过调用init(image:)

不要尝试直接从UIImageCIImage属性获取CIImage。此属性不会把UIImage转换成CIImage!它仅仅只是指向支持(back)UIImageCIImage,如果这个UIImage真的是被CIImage支持(back)的;但是有可能你的图片不是由CIImage支持的,而是由一个CGImage

然而你可以从滤镜的输出中获取CIImage--这意味着滤镜可以链式调用。
有三种方式来描述和使用滤镜:

  • 通过CIFilterinit(name:)创建滤镜。通过重复调用setValue:forKey:方法或者通过调用setValuesForKeysWith- Dictionary: 来为滤镜添加键值对。通过滤镜的outputImage获取CIImage
  • 通过调用CIFilterinit(name:withInputParameters:)并提供键值对创建滤镜。通过滤镜的outputImage获取CIImage
  • 如果CIFilter需要输入图像而且你已经有了一个CIImage,可以通过调用CIImage实例方法imageByApplyingFilter:withInputParameters:并提供滤镜和键值对,然后获取输出的CIImage

当你构建一个滤镜链时,实际上什么都没有发生。唯一的密集计算会发生在最后,当你变换滤镜链输出的CIImage为位图图形时。这就是所谓的图像渲染。有两种主要方式的方式实现这一点:

  • With a CIContext
    通过调用init(options:)创建一个CIContext。然后把最后的CIImage作为第一个参数传递给createCGImage:fromRect:去处理。这会渲染图像。这种方式有点轻微棘手的事情是,CIImage不具有framebounds;它只有一个extent。你会经常使用它作为createCGImage:fromRect:的第二个参数.最终输出的CGImage可以随便使用,例如在应用程序的显示,或者转换为一个UIImage,或者进行进一步的绘制。
    这种方法具有一个优点是当渲染发生时你有全部的控制权。但是要注意:创建一个CIContext是非常昂贵的!最好的方式是,事先只创建CIContext一次--整个app中--, 并在每次渲染时重用它。
  • With a UIImage
    在最后的CIImage上调用的init(CIImage:)init(CIImage:scale:orientation:)直接创建一个UIImage。然后,可以把UIImage绘制到某些图形上下文中。在绘制发生的时候,图像就被渲染了。

苹果声称,你可以简单地调用init(CIImage:)创建一个UIImage然后把它赋值给UIImageView的image属性,那么UIImageView会渲染这个图像。根据我的经验,这是不正确的。为了渲染它你必须显式的绘制它。

为了说明这一点,我会用我自己的一张普通的照片创建一个圆形的带阴影效果的图片。我们会先从图片中导出CIImage。我们对图片运用一个白色到黑色的径向渐变的滤镜。然后,我们使用第二个滤镜,这个滤镜把上面的迳向渐变当做一个遮罩来把我的照片和一个透明背景色混合:其中径向渐变为白色(渐变的内半径)的部分,只会看到我的照片,径向渐变是黑色(渐变的外半径)的部分,我们只会看到透明的颜色,在它们中间的环形渐变区域内图片渐渐消失。下面的代码用两种方式来设置滤镜:

let moi = UIImage(named: "Moi")!
let moiCI = CIImage(image: moi)!
let moiextent = moiCI.extent
let center = CIVector(x: moiextent.width / 2.0, y: moiextent.height / 2.0)
let smallerDimension = min(moiextent.width, moiextent.height)
let largerDimension = max(moiextent.width, moiextent.height)
//first filter
let grad = CIFilter(name: "CIRadialGradient")!
grad.setValue(center, forKey: "inputCenter")
grad.setValue(smallerDimension / 2.0 * 0.85, forKey: "inputRadius0")
grad.setValue(largerDimension / 2.0, forKey: "inputRadius1")
let gradimage = grad.outputImage!
//second filter
let blendImage = moiCI.imageByApplyingFilter("CIBlendWithMask", withInputParameters: ["inputMaskImage": gradImage])

现在我们在滤镜链的最后得到了最终的CIImage(blendimage);注意:此时处理器还没有进行任何渲染。现在我们想要生成最终的位图并显示它。例如,我们可以通过UIImageView来显示它。有两种不同的方法可以做到这一点。我们可以通过调用CIContext(options:nil)CIImage传给我们已经事先准备好的CIContext(self.context)来创建一个CGImage

let moiCG = self.context.createCGImage(blendImage, fromRect: moiextent)
self.iv.image = UIImage(CGImage: moiCG)

另外,我们可以把滤镜链最后输出的CIImage作为一个UIImage然后把它绘制成一个位图:

UIGraphicsBeginImageContextWithOptions(moiextent.size, false, 0)
UIImage(CIImage: blendImage).drawInRect(moiextent)
let im = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
self.iv.image = im

效果如下:


通过继承CIFilter可以把滤镜链封装成一个自定义滤镜。子类只需要重写outputImage属性(以及可能的其他方法,如setdefaults),并使用额外的属性使之成为键值编码兼容的对于任何输入的键.。下面是我们的阴影滤镜一个简单的CIFilter子类,其中一个输入的键是指定输入的图像,另一个输入键是调整渐变的小半径的百分比:

class MyVignetterFilter: CIFilter {
    var inputImage: CIImage?
    var inputPercentage: NSNumber? = 1.0
    override var outputImage: CIImage? {
        return self.makeOutputImage()
    }
    private func makeOutputImage() -> CIImage? {
        guard let inputImage = self.inputImage 
        else {return nil}
        guard let inputPercentage = self.inputPercentage 
        else {return nil}
        let extent = inputImage.extent
        let grad = CIFilter(name: "CIRadialGradient")!
        let center = CIVector(x: extent.width / 2.0, y: extent.height / 2.0)
        let smallerDimension = min(extent.width, extent.height)
        let largerDimension = max(extent.width, extent.height)
        grad.setValue(center, forKey: "inputCenter")
        grad.setValue(smallerDimension / 2.0 * CGFloat(inputPercentage), forKey: "inputRadius0")
        grad.setValue(largerDimension / 2.0, forKey: "inputRadius1")
        let blend = CIFilter(name: "CIBlendWithMask")!
        blend.setValue(inputImage, forKey: "inputImage")
        blend.setValue(grad.outputImage, forKey: "inputMaskImage")
        return blend.outputImage
    }
}

下面是如何使用我们的CIFilter子类并显示其输出的一个例子:

let vig = MyVignetterFilter()
let moiCI = CIImage(image: UIImage(named: "Moi")!)!
vig.setValuesForKeysWithDictionary([
    "inputImage": moiCI,
    "inputPercentage": 0.7
])
let outim = vig.outputImage!
let outimCG = self.context.createCGImage(outim, formRect: outim.extent)
self.iv.image = UIImage(CGImage: outimCG)

Blur and Vibrancy Views

iOS上的某些视图,例如导航栏和控制中心会显示一种半透明的模糊过渡效果。iOS提供了UIVisualEffectView类来帮助你实现这种效果。你可以把其他视图放到UIVisualEffectView前面,但是任何子视图应该放在contentView里面。通过设置contentViewbackgroundColor来实现模糊的效果。

使用init(effect:)来创建UIVisualEffectView并使用它;effect:参数是一个UIVisualEffect子类的实例:

  • UIBlurEffect
    调用init(style:)来创建一个UIBlurEffect;style参数(UIBlurEffectStyle)是.Dark.Light.ExtraLight。(.ExtraLight特别适合于一小块界面,例如一个导航栏或工具栏。):
    let fuzzy = UIVisualEffectView(effect: (UIBlurEffect(style: .Light)))
  • UIVibrancyEffect
    调用init(forBlurEffect:)来初始化UIVibrancyEffectVibrancy让视图和它地下的模糊效果相协调。这样做的目的是,高亮效果视图应该在一个模糊效果视图的前面,一般在单一的UIViewcontentView里面添加一个高亮效果;告诉高亮效果的下面是什么模糊效果,它们会自己协调。您可以获取的视图的模糊效果作为其effect属性,但是这是一个UIVisualEffect类,所以你必须要转换为UIBlurEffect才能把它传递给init(forBlurEffect:)

下面是一个模糊效果并覆盖整个视图的视图,而且包含一个含有UILabel的高亮视图:

let blur = UIVisualEffectView(effect: UIBlurEffect(style: .ExtraLight))
blur.frame = mainview.bounds
blur.autoresizingMasks = [.Flexiblewidth, .FlexibleHeight]
let vib = UIVisualEffectView(effect: UIVibrancyEffect(forBlurEffect: blur.effect as! UIBlurEffect))
let lab = UILabel()
lab.text = "Hello, world!"
lab.sizeToFit()
vib.frame = lab.frame
vib.frame = lab.frame
vib.contentView.addSubview(lab)
vib.center = CGPointMake(blur.bounds.midX, blur.bounds.midY)
vib.autoresizingMask = [.FlexibleToMargin, .FlexibleBottomMargin, .FlexibleLeftMargin, .FlexibleRightMargin]
blur.contextView.addSubview(vib)
mainview.addSubview(blur)

效果如下图:


苹果似乎认为高亮使视图与模糊底层结合时更加清晰,但我不这么认为。高亮的视图的颜色和会模糊的颜色综合但这会是高亮的视图更加不清晰。上图中使用.Dark.ExtraLight模糊效果时label看起来还可以,但是使用.Light就很难看清了。
在UIVisualEffectView.h头文件中有很多值得研究的额外信息。例如,为了高亮图片视图必须使用模版(template)图片。
在nib编辑器中都可以直接使用模糊和高亮视图。

==未完待续

推荐阅读更多精彩内容