I.2 主图像

作者: liril | 来源:发表于2015-08-13 15:54 被阅读253次

    一图胜千言,一接口胜千图。——Ben Shneiderman

    第1章“图层树”介绍了CALayer类并创建了一个简单有蓝色背景的图层。背景色非常棒,但如果图层只能显示一个颜色将会显得平庸无常。CALayer可以容纳任何你喜欢的图片。这一章将讲述CALayer的主图像。

    内容图像

    CALayer有一个叫contents的属性,这个属性被定义为id类型[1],意味它可以作为任意类的对象。这是正确的,这意味着你可以给contents属性定义为任何你喜欢的对象,而你的应用程序仍可以编译成功——然而,实际上如果你提供了一个不是CGImage的对象,你的图层将变为一片空白。

    contents属性的这一奇怪特性是由于Core Animation是继承于Mac OS的。在Mac OS上contents被定义为id的原因是你可以给这一属性赋值为CGImage或者NSImage而它会自动生效。如果你尝试在iOS上赋值一个UIImage,你就只会得到一个空白的图层。这对于新接触Core Animation的iOS开发者来说是一个普遍的困惑之处。

    头疼之处不仅如此。事实上,你真正所需要提供的类型是CGImageRef,这是一个指向CGImage结构体的指针。UIImage有一个CGImage属性会返回CGImageRef。如果你尝试直接赋值CALayer content属性,会无法通过编译。这是因为CGImageRef并不是一个真正的Cocoa对象,它是一个Core Foundation类型

    尽管Core Foundation类型运行时表现得如同是Cocoa对象(被称为toll-free bridging),除非你使用一个bridged转换,否则它们并不等同于id[2]赋值一个图层的图像只需要这样做:

    layer.contents = (__bridge id)image.CGImage;
    

    如果你不使用ARC(自动引用计数),你不需要加上bridge,但是你为什么不用ARC?!

    让我们修改在第1章中创建的项目来显示一张图像而非一个背景颜色。既然我们可以程序化地创建图层而我们不再需要额外的图层,所以我们直接将layerView中的contents属性直接设置为图像[3]

    表2.1显示了升级后的代码,图2.1显示了结果。

    表2.1 设置CGImage作为图层contents
    override func viewDidLoad() {
        super.viewDidLoad()
    
        // 加载图像
        let image = UIImage(named: "Snowman.png")!
    
        // 直接添加到我们的视图图层中
        self.layerView.layer.contents = image.CGImage
    }
    
    图2.1 一张显示在UIView主图层中的图像

    这是非常简单的代码,但我们已经在这做了一些相当有趣的事情:使用CALayer的在一个普通的UIView中显示一个图像。这不是一个UIImageView,并不是正常设计来显示图像的。但通过直接操作图层,我们发现了新的方法并使得我们平庸的UIView有趣了一点。

    contentsGravity

    你可能注意到我们的雪人看起来有一点...。我们加载的图片并不是一个准确的正方形,但它被拉伸以适应这个视图。你可能在使用UIImageView中碰见过相似的情形,解决方法是给这个视图设置更为合适contentMode属性。就像这样:

    view.contentMode = UIViewContentMode.ScaleAspectFit
    

    这个方法效果不错(自己试一下),但大多UIView的视觉属性,例如contentMode就是直接操作底层图层的相应属性。

    CALayer中的相应属性叫contentsGravity,而且它是一个NSString而不是像UIKit中是一个enum。这个contentsGravity字符串应该被设为如下几个常量:

    • kCAGravityCenter
    • kCAGravityTop
    • kCAGravityBottom
    • kCAGravityLeft
    • kCAGravityRight
    • kCAGravityTopLeft
    • kCAGravityTopRight
    • kCAGravityBottomLeft
    • kCAGravityBottomRight
    • kCAGravityResize
    • kCAGravityResizeAspect
    • kCAGravityResizeAspectFill

    正如contentMode一样,contentsGravity的目的在于确定内容应该怎么和图层边界对齐。我们将使用kCAGravityResizeAspect这等同于UIViewContentMode.ScaleAspectFit,它可以缩放图像使其适合图层的边界而不会改变它的比例:

    self.layerView.layer.contentsGravity = kCAGravityResizeAspect
    

    图2.2显示这个结果。


    图2.2 用正确的contentsGravity显示的雪人图像

    contentsScale

    contentsScale属性定义了图层主图像的像素尺寸和视图大小的比例。它是一个默认为1.0的浮点数。

    contentsScale属性的目的并不是很直接,它并不是总能够影响到屏幕上的主图像的缩放;如果你尝试在我们的雪人例子中将它设置为不同的值,你会发现它并没有任何效果,因为contents图像早已经通过contentsGravity属性缩放成适合图层边界。

    如果你想简单的缩放图层的contents图像,你可以使用图层的transform或是affineTransform属性(看第5章“变形”,用于于解释变形),但这并不是`contentsScale的目的。

    contentsScale属性实际上是用来支持高分辨率(也被称为Hi-DPI或者Retina)屏幕实现机制的一部分。它被用于确认主图像的大小,当绘制时这个图层应当自动创建,以及contents图像应该被显示的缩放尺寸(假设这还没有被contentsGravity设置缩放)。UIView有一个相同但很少使用的属性叫作contentScaleFactor

    如果contentsScale被设为1.0,将以每点1像素的分辨率绘制。如果被设为2.0,将以没电2像素的分辨率绘制,也被称作Retina分辨率。(如果你不是很清楚像素和点之间的差异,稍后将解释。)

    这与使用kCAGravityResizeAspect并不会有任何不同,因为无论什么分辨率,它将缩放图像以适应图层。但如果我们将我们的contentsGravity设为kCAGravityCenter(这个并不会缩放图像),这之间的差异会更加明显(见图2.3)。

    图2.3 一个被错误的contentsScale默认显示的Retina图像

    正如你所见,我们的雪人非常大而且像素化。这是因为CGImage(不像UIImage)一样并没有缩放的内部概念。当我们用UIImage类来加载我们的雪人图像,它会正确地加载为高质量的Retina版本。但当我们用CGImage作为我们的图层contents的图像,缩放因子在变形中丢失。我们可以通过手动的设置contentsScale来修复它,这样可以匹配_UIImage scale属性(见表2.2)。图2.4显示了结果。

    override func viewDidLoad() {
        super.viewDidLoad()
    
        // 加载图像
        let image = UIImage(named: "Snowman.png")!
    
        // 直接添加到我们的视图图层中
        self.layerView.layer.contents = image.CGImage
    
        // 将图片放在正中间
        self.layerView.layer.contentsGravity = kCAGravityCenter
    
        // 改变contentsScale来适应图像的缩放
        self.layerView.layer.contentsScale = image.scale
    }
    
    图2.4 显示同一张设置了正确contentsScale的Retina图像

    当程序化生成主图像时,你应当时常记得手动设置图层的contentsScale来匹配屏幕缩放;否则,你的图像将会在Retina设备上显示成像素化。你可以像这样做:

    layer.contentsScale = UIScreen.mainScreen().scale
    

    masksToBounds

    既然我们的雪人已经按正常的大小显示,你可能注意到了其它东西——他沿伸到视图的边界之外。默认情况下,UIView将会很开心地绘制它指定边界之外的内容和子视图,而这同样适用于CALayer

    UIView中有一个属性叫做clipsToBounds用来开关裁剪(这是用来控制是否一个视图的内容可以蔓延到它们的帧之外)。CALayer有一个相同的属性叫做masksToBounds。当设为时,我们可以限定我们的雪人在视图之内(见图2.5)。

    图2.5 使用masksToBounds来裁剪图层内容

    contentsRect

    CALayer中的contentsRect属性允许我们指定一个子矩形来在图层帧中显示主图像。这比contentsGravity提供更多的自由性来决定图像是如何被裁剪或拉伸。

    而与bounds以及frame不同的是contentsRect并不是以点度量的;它使用单元坐标。单元坐标被限定为0到1之间,是相对值(不同于点和像素等绝对值)。在这个侄子中,它们相对于主图像的尺寸。下面这些坐标类型被用于iOS:

    • ——在iOS和Mac OS最广泛使用的坐标类型。点是虚拟的像素,也被称作逻辑像素。在标准定义的设备上,一点相当于一个像素,但在Retina设备上,一点相当于2*2个物理像素。iOS在所有的屏幕坐标度量中使用点来使得布局可以无缝地使用于Retina和非Retina屏的设备上。
    • 像素——物理像素坐标并不用于屏幕布局,但它们通常与图像处理相关。UIImage是屏幕分辨相关的,并且以点来限定大小,但有些底层图像显示例如CGImage使用像素尺寸,因此你应当记住它们的状态尺寸并不会匹配它们在一个Retina设备上的显示尺寸。
    • 单元——单元坐标是一个用来确切度量相对的图像大小或图层边界的简便方法,因此当大小改变时不需要调整。单元坐标在OpenGL广泛用于纹理坐标等,它们也常常用于Core Animation中。

    默认的contentsRect{0, 0, 1, 1},这意味着整个主图像是默认可见的,如果我们指定一个小一点的矩形,这个图像会被裁剪(如图2.6)。

    图2.6 一个自定义contentsRect(左)和被显示的图像(右)

    contentsRect指定为一个负数或者大于{1,1}的尺寸是被允许的。在这种情况下,图像最外的像素会被拉伸来填充指定区域。

    contentsRect的一个有趣之处在于它可以被用于叫做图像精灵的东西。如果你曾做过任何游戏编程,你会对精灵的概念感到熟悉。这其实是一种可以独立在屏幕到处移动的图像。但在游戏世界之外,这个术语通常用于指代一种用于加载精灵图像的通用技术,而不是使一切事物动起来。

    通常,许多精灵会被打包成一张大图片来一次性加载。这比使用多张独立的照片在内存使用、加载时间和渲染表现上等上更多的优点。

    精灵被用于2D游戏引擎如Cocos2d,在其中用OpenGL来显示图像。但我们可以通过借助contentsRect的力量在一个普通的UIKit应用程序中使用精灵。

    在开始前,我们需要一个精灵图集[4]——一张包含我们小一点的精灵图像的大图像。图2.7展示了一个精灵图集的案例。

    图2.7 一个精灵图集

    接下来,我们要在我们的应用中加载并显示这些精灵。原则十分简单:我们正常加载我们的大图像,将它赋值给四个分离图层的contents(每一个对应一个精灵),然后设置它们每个的contentsRect来遮盖我们不需要的部分。

    我们需要在我们的项目中为我们的精灵图层增加一些额外的视图。(这些视图是用Interface Builder定位的,这样可以防止产生杂乱的代码,但如果你喜欢也可以用程序化的方式创建它们。)表2.3展示了代码,图2.8展示了最终的结果。

    表2.3 使用contentsRect切割精灵图集
    import UIKit
    
    class ViewController: UIViewController {
    
        @IBOutlet weak var coneView: UIView!
        @IBOutlet weak var shipView: UIView!
        @IBOutlet weak var iglooView: UIView!
        @IBOutlet weak var anchorView: UIView!
    
        func addSpriteImage(image: UIImage, withContentRect rect: CGRect, toLayer layer: CALayer) {
            // 设置图像
            layer.contents = image.CGImage
    
            // 缩放内容以适应
            layer.contentsGravity = kCAGravityResizeAspectFill
    
            // 设置内容矩形
            layer.contentsRect = rect
        }
    
        override func viewDidLoad() {
            super.viewDidLoad()
    
            // 加载精灵图集
            let image = UIImage(named: "Sprites.png")!
    
            // 设置冰屋
            self.addSpriteImage(image, withContentRect: CGRectMake(0, 0, 0.5, 0.5), toLayer: self.iglooView.layer)
    
            // 设置圆锥
            self.addSpriteImage(image, withContentRect: CGRectMake(0.5, 0, 0.5, 0.5), toLayer: self.coneView.layer)
    
            // 设置锚
            self.addSpriteImage(image, withContentRect: CGRectMake(0, 0.5, 0.5, 0.5), toLayer: self.anchorView.layer)
    
            // 设置飞船
            self.addSpriteImage(image, withContentRect: CGRectMake(0.5, 0.5, 0.5, 0.5), toLayer: self.shipView.layer)
        }
    }
    
    图2.8 四个精灵,随机分布在屏幕上

    精灵图集是一个减少应用大小和优化加载表现(一张大图比多张小图压缩更好且加载更快)的简洁的方式,但人工设置它们会比较笨拙,当精灵图集创建完成之后,增加新精灵或修改某个已有精灵的尺寸一直是一个大问题。

    一些商业应用可以在你的Mac上自动创建精灵图集。这些工具通过生成XML或Plist文件来存储精灵坐标来简化精灵的使用。这些文件可以随图像一起加载并用于给每个精灵设置contentsRect,而非让开发者们不得不去手工用代码在应用中布置它们。

    这些文件通常被设计为应用于OpenGL的游戏中,但如果你对在一个常规的应用中使用精灵图集,这个开源图层精灵库可以用流行的Cocos2d格式读入精灵图集并用正常的Core Animation图层显示它们。

    contentsCenter

    这一章我们讲解的最后一个内容相关的属性是contentsCenter。你可能根据名字推测contentsCenter这一属性将会与contents图像的位置有关,但事实上这个名字是有误导性的。contentsCenter实际上是一个定义图层可拉伸区域和一个边缘固定边框的CGRect。改变contentsCenter对于主图像如何显示毫无影响,除非图层被重新定义大小,那么它的目的就变得清晰了。默认情况下contentsCenter被设为{0, 0, 1, 1},这意味着当图层被重新定义大小时(由contentsGravity决定)主图像将会均匀拉伸。但如果我们增加开始的值并减少大小,我们可以在图像四周创建边框。图2.9展示了{0.25, 0.25, 0.5, 0.5}这组数的缩放效果的。

    图2.9 一个contentsCenter矩形的例子以及它对图像的影响

    这意味着当我们直接重新调整视图大小的时候,边框将保持一致(如图2.10)。这与使用UIImage-resizableImageWithCapInsets:方法相似,但可以应用于任一图层的主图像,甚至包换在运行时用Core Graphics绘制的图层(正如这章后面将讲述的一样)。[5]

    图2.10 一对使用同一张可拉伸图像的视图

    表2.4展示了用于程序化设置这些可拉伸图像的代码。然而,contentsCenter另一个很棒的额外特性是,它可以不用写任何代码,而在Interface Builder的检查器窗口中使用拉伸控制来设置,正如图2.11所示。

    表2.4 使用contentsCenter设置可拉伸视图
    import UIKit
    
    class ViewController: UIViewController {
    
        @IBOutlet weak var button1: UIView!
        @IBOutlet weak var button2: UIView!
    
        func addStretchableImage(image: UIImage, withContentCenter rect: CGRect, toLayer layer: CALayer) {
            // 设置图像
            layer.contents = image.CGImage
    
            // 设置内容中心
            layer.contentsCenter = rect
        }
    
        override func viewDidLoad() {
            super.viewDidLoad()
    
            // 加载按钮图像
            let image = UIImage(named: "Button.png")!
    
            // 设置button1
            self.addStretchableImage(image, withContentCenter: CGRectMake(0.25, 0.25, 0.5, 0.5), toLayer: self.button1.layer)
    
            // 设置button2
            self.addStretchableImage(image, withContentCenter: CGRectMake(0.25, 0.25, 0.5, 0.5), toLayer: self.button2.layer)
        }
    }
    
    图2.11 Interface Builder检查器中contentsCenter的控制(最下方的Stretching)

    自定义绘图

    用一张CGImage设置图层的contents并不是唯一设置主图像的方法。你也可以通过使用Core Graphics来直接绘制主图像。-drawRect:方法可以在一个UIView的子类中实现自定义绘图。

    -drawRect:方法没有默认的实现,这是因为如果仅是用一个纯色填充或着底图层的contents属性包含一个已存在的图像实例,UIView并不需要一个自定义的主图像。如果UIView检测到-drawRect:方法出现,它将为这个视图分配一个新的主图像,其像素大小等同于视图大小乘以contentsScale

    如果你不需要这个主图像,创建它将浪费内存和CPU。这就是为什么Apple建议你,如果不是想自定义一些绘制的时候,不要在你的图层子类中存在一个空的-drawRect:方法。

    -drawRect:方法会在视图第一次出现在屏幕上时自动执行。-drawRect:方法中的代码会使用Core Graphics来绘制主图像,其结果在被缓存直至视图需要更新(通常因为开发者调用了-setNeedsDisplay方法,然而某些视图类型在当一个会影响它们显示的属性被改变时会自动重绘[如bounds])。尽管-drawRect:是一个UIView的方法,它实际上是在CALayer下安排绘制并存储结果图像的。

    CALayer有一个可选的遵守CALayerDelegate协议的delegate属性。当CALayer需要特定内容的信息时,它将向这个delegate中请求相关信息。CALayerDelegate是一个非正式的协议,这是说明事实上并没有CALayerDelegate @protocol可以让你在类接口的引用。你只需要加上你需要的方法,而CALayer会在出现时调用它们。(这个delegate属性被声明为id并且所有的委托方法被当作可选的对待。)

    当它需要被重绘时,CALayer会让它的委托提供一个主图像用于显示。为这实现这一功能它会尝试调用下面这个方法:

    objc:
    - (void)displayLayer: (CALayer nonnull *)layer;
    
    swift:
    func displayLayer(_ layer: CALayer)
    

    这个时候委托可以直接设置图层的contents,之后不会再有其它方法被调用。如果委托没有实现-displayLayer:方法,CALayer会尝试调用如上方法来代替:

    objc:
    - (void)drawLayer: (CALayer * nonnull)layer inContext: (CGContextRef nonnull)ctx
    
    swift:
    func drawLayer(_ layer: CALayer, inContext ctx: CGContext)
    

    在调用这一方法前,CALayer会创建一个合适大小(基于图层的bounds以及contentsScale)的空主图像和一个合适的Core Graphics绘制上下文来绘制这个图像,这就是它传递的ctx参数。

    让我们修改第1章的测试程序来实现CALayerDelegate协议,然后绘制一些图像(如表2.5)。图2.12展示了结果。

    表2.5 实现CALayerDelegate
    import UIKit
    
    class ViewController: UIViewController {
    
        @IBOutlet weak var layerView: UIView!
    
        override func viewDidLoad() {
            super.viewDidLoad()
    
            // 创建子图层
            let blueLayer = CALayer()
            blueLayer.frame = CGRectMake(50.0, 50.0, 100.0, 100.0)
            blueLayer.backgroundColor = UIColor.blueColor().CGColor
    
            // 设置当前控制器作为图层代理
            blueLayer.delegate = self
    
            // 确保图层的主图像使用正确的缩放
            blueLayer.contentsScale = UIScreen.mainScreen().scale
    
            // 将图层加入视图
            self.layerView.layer.addSublayer(blueLayer)
    
            // 强制图层重新绘制
            blueLayer.display()
        }
    
        override func drawLayer(layer: CALayer!, inContext ctx: CGContext!) {
            // 绘制一个粗的红色圆圈
            CGContextSetLineWidth(ctx, 10.0)
            CGContextSetStrokeColorWithColor(ctx, UIColor.redColor().CGColor)
            CGContextStrokeEllipseInRect(ctx, layer.bounds)
        }
    }
    
    图2.12 使用CALayerDelegate绘制主图像的图层

    一些有趣的注意事项:

    • 我们不得不手动调用blueLayer-display方法来强制其重新绘制。不同于UIViewCALayer在显示在屏幕上时并不会自动重新绘制里面的内容;它留给开发者去谨慎决定是否图层需要重新绘制。
    • 即使我们没有允许masksToBounds属性,我们绘制的圆也会被裁剪成图层的bounds。这是因为当你用CALayerDelegate绘制主图像时,CALayer会创建一个与图层尺寸一样大小的上下文。并不存在可以画出边界的选择。

    到现在你应该理解了CALayerDelegate并知道如何去使用它。但除非你在创建独立的图层,你几乎不会需要去实现CALayerDelegate协议。其原因在于当UIView创建主图像时,它会自动将自己设为图层的delegate并提供-displayLayer:抽象问题的一种实现。

    当使用基于视图的图层时,你并不需要去实现-displayLayer:或者-drawLayer:inContext:来绘制你图层的主图像;你可以直接用正常方式实现UIView-drawRect:方法,而UIView将会管理一切,这包括当图层需要被重新绘制时自动调用其中的-display方法。

    总结

    这一章节讲述了图层的主图像以及它的相关属性。你学习了如何移动和裁剪图像、从一个精灵图集中裁剪出独立的图像以及在使用CALayerDelegateCore Graphics时绘制图层内容。

    在第3章“图层几何”中,我们将查看一个图层的几何并测试相关图层之间如何重新设置位置和大小。


    1. 可以类比为swift中的AnyObject

    2. 这是objective-c的技术,swift并不需要

    3. 文章中使用的雪人图片


      Snowman.png
    4. 文章中使用的精灵图集


      Sprites.png
    5. 文章中使用的按钮


      Button.png

    相关文章

      网友评论

      • Jisen:你这是用swift翻译了一遍呀
        liril:@木棠 哈哈哈,以后可以交流交流
        Jisen:@liril 嗯嗯,因为我最近就在看这个,也是用swift写了一遍😄😄😄
        liril:@木棠 是的,不过,还会再修订一遍

      本文标题:I.2 主图像

      本文链接:https://www.haomeiwen.com/subject/wktwqttx.html