美文网首页iOSiOS核心动画
iOS核心动画高级技巧一(图层树与寄宿图)

iOS核心动画高级技巧一(图层树与寄宿图)

作者: 路飞_Luck | 来源:发表于2019-08-24 11:28 被阅读0次
    目录
    • 图层树
      • 图层与视图
      • 图层的能力
      • 视图图层
    • 寄宿图
      • Contents属性
      • Custom Drawing
    一 图层树

    Core Animation是一个复合引擎,它的职责就是尽可能快地组合屏幕上不同的可视内容,这个内容是被分解成独立的图层,存储在一个叫做图层树的体系之中。于是这个树形成了UIKit以及在iOS应用程序当中你所能在屏幕上看见的一切的基础

    1.1 图层与视图

    在iOS当中,所有的视图都从一个叫做UIVIew的基类派生而来,UIView可以处理触摸事件,可以支持基于Core Graphics绘图,可以做仿射变换(例如旋转或者缩放),或者简单的类似于滑动或者渐变的动画。

    CALayer

    CALayer类在概念上和UIView类似,同样也是一些被层级关系树管理的矩形块,同样也可以包含一些内容(像图片,文本或者背景色),管理子图层的位置。它们有一些方法和属性用来做动画和变换。和UIView最大的不同是CALayer不处理用户的交互

    CALayer并不清楚具体的响应链(iOS通过视图层级关系用来传送触摸事件的机制),于是它并不能够响应事件,即使它提供了一些方法来判断是否一个触点在图层的范围之内

    平行的层级关系

    每一个UIview都有一个CALayer实例的图层属性,也就是所谓的backing layer,视图的职责就是创建并管理这个图层,以确保当子视图在层级关系中添加或者被移除的时候,他们关联的图层也同样对应在层级关系树当中有相同的操作

    实际上这些背后关联的图层才是真正用来在屏幕上显示和做动画,UIView仅仅是对它的一个封装,提供了一些iOS类似于处理触摸的具体功能,以及Core Animation底层方法的高级接口。

    但是为什么iOS要基于UIView和CALayer提供两个平行的层级关系呢?为什么不用一个简单的层级来处理所有事情呢?原因在于要做职责分离,这样也能避免很多重复代码。在iOS和Mac OS两个平台上,事件和用户交互有很多地方的不同,基于多点触控的用户界面和基于鼠标键盘有着本质的区别,这就是为什么iOS有UIKit和UIView,但是Mac OS有AppKit和NSView的原因。他们功能上很相似,但是在实现上有着显著的区别。

    实际上,这里并不是两个层级关系,而是四个,每一个都扮演不同的角色,除了视图层级和图层树之外,还存在呈现树和渲染树。

    1.2 图层的能力

    如果说CALayer是UIView内部实现细节,那我们为什么要全面地了解它呢?苹果当然为我们提供了优美简洁的UIView接口,那么我们是否就没必要直接去处理Core Animation的细节了呢?

    某种意义上说的确是这样,对一些简单的需求来说,我们确实没必要处理CALayer,因为苹果已经通过UIView的高级API间接地使得动画变得很简单。

    但是这种简单会不可避免地带来一些灵活上的缺陷。如果你略微想在底层做一些改变,或者使用一些苹果没有在UIView上实现的接口功能,这时除了介入Core Animation底层之外别无选择。

    我们已经证实了图层不能像视图那样处理触摸事件,那么他能做哪些视图不能做的呢?这里有一些UIView没有暴露出来的CALayer的功能:

    • 阴影,圆角,带颜色的边框
    • 3D变换
    • 非矩形范围
    • 透明遮罩
    • 多级非线性动画
    1.3 使用图层
    • 简单添加一个图层
    - (void)viewDidLoad {
        [super viewDidLoad];
        
        //create sublayer
        CALayer *blueLayer = [CALayer layer];
        blueLayer.frame = CGRectMake(50.0f, 50.0f, 100.0f, 100.0f);
        blueLayer.backgroundColor = [UIColor blueColor].CGColor;
        //add it to our view
        [self.layerView.layer addSublayer:blueLayer];
    }
    
    • 运行结果
    image.png

    使用图层关联的视图而不是CALayer的好处在于,你能在使用所有CALayer底层特性的同时,也可以使用UIView的高级API(比如自动排版,布局和事件处理)。

    然而,当满足以下条件的时候,你可能更需要使用CALayer而不是UIView:

    • 开发同时可以在Mac OS上运行的跨平台应用
    • 使用多种CALayer的子类(见第六章,“特殊的图层“),并且不想创建额外的UIView去包封装它们所有
    • 做一些对性能特别挑剔的工作,比如对UIView一些可忽略不计的操作都会引起显著的不同(尽管如此,你可能会直接想使用OpenGL绘图)

    总的来说,处理视图会比单独处理图层更加方便

    二 寄宿图
    2.1 contents属性

    CALayer 有一个属性叫做contents,这个属性的类型被定义为id,意味着它可以是任何类型的对象。在这种情况下,你可以给contents属性赋任何值,你的app仍然能够编译通过。但是,在实践中,如果你给contents赋的不是CGImage,那么你得到的图层将是空白的。

    contents这个奇怪的表现是由Mac OS的历史原因造成的。它之所以被定义为id类型,是因为在Mac OS系统上,这个属性对CGImage和NSImage类型的值都起作用。如果你试图在iOS平台上将UIImage的值赋给它,只能得到一个空白的图层。一些初识Core Animation的iOS开发者可能会对这个感到困惑。

    事实上,你真正要赋值的类型应该是CGImageRef,它是一个指向CGImage结构的指针。UIImage有一个CGImage属性,它返回一个CGImageRef,如果你想把这个值直接赋值给CALayer的contents,那你将会得到一个编译错误。因为CGImageRef并不是一个真正的Cocoa对象,而是一个Core Foundation类型。

    尽管Core Foundation类型跟Cocoa对象在运行时貌似很像(被称作toll-free bridging),他们并不是类型兼容的,不过你可以通过bridged关键字转换。如果要给图层的寄宿图赋值,你可以按照以下这个方法:

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

    我们建立一个视图,以便能够展示一张图片而不仅仅是一个背景色。我们已经用代码的方式建立一个图层,那我们就不需要额外的图层了。那么我们就直接把layerView的宿主图层的contents属性设置成图片。

    - (void)addImgContents {
        UIImage *img = [UIImage imageNamed:@"cat"];
        
        UIView *layerView = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 200, 200)];
        layerView.center = self.view.center;
        [self.view addSubview:layerView];
        
        layerView.layer.contents = (__bridge id)img.CGImage;
    }
    
    • 运行结果如下
    cat.png

    我们用这些简单的代码做了一件很有趣的事情:我们利用CALayer在一个普通的UIView中显示了一张图片。这不是一个UIImageView,它不是我们通常用来展示图片的方法。通过直接操作图层,我们使用了一些新的函数,使得UIView更加有趣了。

    2.1.2 contentGravity

    你可能已经注意到了我们的图片看起来有点变形了,我们加载的图片并不刚好是一个方的,为了适应这个视图,它有一点点被拉伸了。在使用UIImageView的时候遇到过同样的问题,解决方法就是把contentMode属性设置成更合适的值,像这样:

    view.contentMode = UIViewContentModeScaleAspectFit;
    

    这个方法基本和我们遇到的情况的解决方法已经接近了(你可以试一下 :) ),不过UIView大多数视觉相关的属性比如contentMode,对这些属性的操作其实是对对应图层的操作。

    CALayercontentMode对应的属性叫做contentsGravity,但是它是一个NSString类型,而不是像对应的UIKit部分,那里面的值是枚举。contentsGravity可选的常量值有以下一些:

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

    和cotentMode一样,contentsGravity的目的是为了决定内容在图层的边界中怎么对齐,我们将使用kCAGravityResizeAspect,它的效果等同于UIViewContentModeScaleAspectFit, 同时它还能在图层中等比例拉伸以适应图层的边界。

    layerView.layer.contentsGravity = kCAGravityResizeAspect;
    
    image.png
    2.1.3 contentsScale

    contentsScale属性定义了寄宿图的像素尺寸和视图大小的比例,默认情况下它是一个值为1.0的浮点数。

    contentsScale的目的并不是那么明显。它并不是总会对屏幕上的寄宿图有影响。如果你尝试对我们的例子设置不同的值,你就会发现根本没任何影响。因为contents由于设置了contentsGravity属性,所以它已经被拉伸以适应图层的边界。

    如果你只是单纯地想放大图层的contents图片,你可以通过使用图层的transformaffineTransform属性来达到这个目的,也不是contengsScale的目的所在。

    contentsScale属性其实属于支持高分辨率(又称Hi-DPI或Retina)屏幕机制的一部分。它用来判断在绘制图层的时候应该为寄宿图创建的空间大小,和需要显示的图片的拉伸度(假设并没有设置contentsGravity属性)。UIView有一个类似功能但是非常少用到的contentScaleFactor属性。

    如果contentsScale设置为1.0,将会以每个点1个像素绘制图片,如果设置为2.0,则会以每个点2个像素绘制图片,这就是我们熟知的Retina屏幕。

    这并不会对我们在使用kCAGravityResizeAspect时产生任何影响,因为它就是拉伸图片以适应图层而已,根本不会考虑到分辨率问题。但是如果我们把contentsGravity设置为kCAGravityCenter(这个值并不会拉伸图片),那将会有很明显的变化。

    如运行结果所见,我们的图片不仅有点大还有点像素的颗粒感。那是因为和UIImage不同,CGImage没有拉伸的概念。当我们使用UIImage类去读取我们的雪人图片的时候,他读取了高质量的Retina版本的图片。但是当我们用CGImage来设置我们的图层的内容时,拉伸这个因素在转换的时候就丢失了。不过我们可以通过手动设置contentsScale来修复这个问题。

    - (void)addImgContents {
        UIImage *img = [UIImage imageNamed:@"cat"];
        
        UIView *layerView = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 200, 200)];
        layerView.center = self.view.center;
        [self.view addSubview:layerView];
        
        // 1.图片处于中间
        layerView.layer.contentsGravity = kCAGravityCenter;
        
        // 2.图片缩放比例
        layerView.layer.contentsScale = img.scale;
        
        layerView.layer.contents = (__bridge id)img.CGImage;
    }
    
    • 运行结果如下
    image.png

    当用代码的方式来处理寄宿图的时候,一定要记住要手动的设置图层的contentsScale属性,否则,你的图片在Retina设备上就显示得不正确

    layerView.layer.contentsScale = [UIScreen mainScreen].scale;
    
    2.1.4 maskToBounds

    现在我们的图片总算是显示了正确的大小,不过你也许已经发现了另外一些事情:他超出了视图的边界。默认情况下,UIView仍然会绘制超过边界的内容或是子视图,在CALayer下也是这样的。

    UIView有一个叫做clipsToBounds的属性可以用来决定是否显示超出边界的内容,CALayer对应的属性叫做masksToBounds,把它设置为YES,图片就在边界里。

    layerView.layer.masksToBounds = YES;
    
    2.1.5 contentsRect

    CALayer的contentsRect属性允许我们在图层边框里显示寄宿图的一个子域。这涉及到图片是如何显示和拉伸的,所以要比contentsGravity灵活多了。

    boundsframe不同,contentsRect不是按点来计算的,它使用了单位坐标,单位坐标指定在0到1之间,是一个相对值(像素和点就是绝对值)。所以他们是相对与寄宿图的尺寸的。iOS使用了以下的坐标系统:

    —— 在iOS和Mac OS中最常见的坐标体系。点就像是虚拟的像素,也被称作逻辑像素。在标准设备上,一个点就是一个像素,但是在Retina设备上,一个点等于2*2个像素。iOS用点作为屏幕的坐标测算体系就是为了在Retina设备和普通设备上能有一致的视觉效果。

    像素 —— 物理像素坐标并不会用来屏幕布局,但是仍然与图片有相对关系。UIImage是一个屏幕分辨率解决方案,所以指定点来度量大小。但是一些底层的图片表示如CGImage就会使用像素,所以你要清楚在Retina设备和普通设备上,他们表现出来了不同的大小。

    单位 —— 对于与图片大小或是图层边界相关的显示,单位坐标是一个方便的度量方式, 当大小改变的时候,也不需要再次调整。单位坐标在OpenGL这种纹理坐标系统中用得很多,Core Animation中也用到了单位坐标。

    默认的contentsRect是{0, 0, 1, 1},这意味着整个寄宿图默认都是可见的,如果我们指定一个小一点的矩形,图片就会被裁剪。

    layerView.layer.contentsRect = CGRectMake(0, 0, 0.5, 0.5);
    
    • 运行结果如下
    contentsRect.png

    事实上给contentsRect设置一个负数的原点或是大于{1, 1}的尺寸也是可以的。这种情况下,最外面的像素会被拉伸以填充剩下的区域。

    contentsRect在app中最有趣的地方在于一个叫做image sprites(图片拼合)的用法。如果你有游戏编程的经验,那么你一定对图片拼合的概念很熟悉,图片能够在屏幕上独立地变更位置。抛开游戏编程不谈,这个技术常用来指代载入拼合的图片,跟移动图片一点关系也没有。

    典型地,图片拼合后可以打包整合到一张大图上一次性载入。相比多次载入不同的图片,这样做能够带来很多方面的好处:内存使用,载入时间,渲染性能等等

    2D游戏引擎入Cocos2D使用了拼合技术,它使用OpenGL来显示图片。不过我们可以使用拼合在一个普通的UIKit应用中,对!就是使用contentsRect

    首先,我们需要一个拼合后的图表 —— 一个包含小一些的拼合图的大图片。

    接下来,我们要在app中载入并显示这些拼合图。规则很简单:像平常一样载入我们的大图,然后把它赋值给四个独立的图层的contents,然后设置每个图层的contentsRect来去掉我们不想显示的部分。

    - (void)contentsRect {
        UIImage *img = [UIImage imageNamed:@"1.jpg"];
        
        // add four img
        for (int i = 0; i < 4; i++) {
            UIView *view = [[UIView alloc] initWithFrame:CGRectMake(100, 100 + 120 * i, 100, 100)];
            [self.view addSubview:view];
            
            CGFloat x = i % 2 == 0 ? 0 : 0.5;
            CGFloat y = i < 2 ? 0 : 0.5;
            [self addSpriteImage:img withContentRect:CGRectMake(x, y, 0.5, 0.5) toLayer:view.layer];
        }
    }
    
    - (void)addSpriteImage:(UIImage *)image withContentRect:(CGRect)rect toLayer:(CALayer *)layer {//set image
        layer.contents = (__bridge id)image.CGImage;
        
        //scale contents to fit
        layer.contentsGravity = kCAGravityResizeAspect;
        
        //set contentsRect
        layer.contentsRect = rect;
    }
    

    拼合不仅给app提供了一个整洁的载入方式,还有效地提高了载入性能(单张大图比多张小图载入地更快)。

    • 运行结果如下
    contentRect.png
    2.1.6 contentsCenter

    本节我们介绍的最后一个和内容有关的属性是contentsCenter,看名字你可能会以为它可能跟图片的位置有关,不过这名字着实误导了你。contentsCenter其实是一个CGRect,它定义了一个固定的边框和一个在图层上可拉伸的区域。 改变contentsCenter的值并不会影响到寄宿图的显示,除非这个图层的大小改变了,你才看得到效果。

    默认情况下,contentsCenter是{0, 0, 1, 1},这意味着如果大小(由conttensGravity决定)改变了,那么寄宿图将会均匀地拉伸开。但是如果我们增加原点的值并减小尺寸。我们会在图片的周围创造一个边框。图2.9展示了contentsCenter设置为{0.25, 0.25, 0.5, 0.5}的效果。

    - (void)contentsCenter {
        UIImage *img = [UIImage imageNamed:@"cat"];
        
        UIView *layerView = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 200, 200)];
        layerView.center = self.view.center;
        [self.view addSubview:layerView];
        
        layerView.layer.contents = (__bridge id)img.CGImage;
        layerView.layer.contentsGravity = kCAGravityResizeAspect;
        
        // contentsCenter
        layerView.layer.contentsCenter = CGRectMake(0.25, 0.25, 0.5, 0.5);
    }
    
    • 运行结果如下
    contentsCenter.png

    contentsCenter的例子
    这意味着我们可以随意重设尺寸,边框仍然会是连续的。他工作起来的效果和UIImage里的-resizableImageWithCapInsets: 方法效果非常类似,只是它可以运用到任何寄宿图,甚至包括在Core Graphics运行时绘制的图形

    ontentsCenter的另一个很酷的特性就是,它可以在Interface Builder里面配置,根本不用写代码.

    - (void)contentsCenter1 {
        UIImage *img = [UIImage imageNamed:@"cat"];
        
        // add four img
        for (int i = 0; i < 4; i++) {
            UIView *view = [[UIView alloc] initWithFrame:CGRectMake(100, 100 + 120 * i, 100, 100)];
            [self.view addSubview:view];
            
            [self addStretchableImage:img withContentCenter:CGRectMake(0.25, 0.25, 0.5, 0.5) toLayer:view.layer];
        }
    }
    
    - (void)addStretchableImage:(UIImage *)image withContentCenter:(CGRect)rect toLayer:(CALayer *)layer {
        //set image
        layer.contents = (__bridge id)image.CGImage;
        
        //set contentsCenter
        layer.contentsCenter = rect;
    }
    
    • 运行结果如下
    image.png
    2.2 Custom Drawing

    给contents赋CGImage的值不是唯一的设置寄宿图的方法。我们也可以直接用Core Graphics直接绘制寄宿图。能够通过继承UIView并实现-drawRect:方法来自定义绘制。

    -drawRect: 方法没有默认的实现,因为对UIView来说,寄宿图并不是必须的,它不在意那到底是单调的颜色还是有一个图片的实例。如果UIView检测到-drawRect: 方法被调用了,它就会为视图分配一个寄宿图,这个寄宿图的像素尺寸等于视图大小乘以 contentsScale的值。

    如果你不需要寄宿图,那就不要创建这个方法了,这会造成CPU资源和内存的浪费,这也是为什么苹果建议:如果没有自定义绘制的任务就不要在子类中写一个空的-drawRect:方法。

    当视图在屏幕上出现的时候 -drawRect:方法就会被自动调用。-drawRect:方法里面的代码利用Core Graphics去绘制一个寄宿图,然后内容就会被缓存起来直到它需要被更新(通常是因为开发者调用了-setNeedsDisplay方法,尽管影响到表现效果的属性值被更改时,一些视图类型会被自动重绘,如bounds属性)。虽然-drawRect:方法是一个UIView方法,事实上都是底层的CALayer安排了重绘工作和保存了因此产生的图片

    CALayer有一个可选delegate属性,实现了CALayerDelegate协议,当CALayer需要一个内容特定的信息时,就会从协议中请求。CALayerDelegate是一个非正式协议,其实就是说没有CALayerDelegate @protocol可以让你在类里面引用啦。你只需要调用你想调用的方法,CALayer会帮你做剩下的。(delegate属性被声明为id类型,所有的代理方法都是可选的)。

    当需要被重绘时,CALayer会请求它的代理给他一个寄宿图来显示。它通过调用下面这个方法做到的:

    @protocol CALayerDelegate <NSObject>
    @optional
    
    /* If defined, called by the default implementation of the -display
     * method, in which case it should implement the entire display
     * process (typically by setting the `contents' property). */
    
    - (void)displayLayer:(CALayer *)layer;
    
    /* If defined, called by the default implementation of -drawInContext: */
    
    - (void)drawLayer:(CALayer *)layer inContext:(CGContextRef)ctx;
    
    /* If defined, called by the default implementation of the -display method.
     * Allows the delegate to configure any layer state affecting contents prior
     * to -drawLayer:InContext: such as `contentsFormat' and `opaque'. It will not
     * be called if the delegate implements -displayLayer. */
    
    - (void)layerWillDraw:(CALayer *)layer
      API_AVAILABLE(macos(10.12), ios(10.0), watchos(3.0), tvos(10.0));
    
    /* Called by the default -layoutSublayers implementation before the layout
     * manager is checked. Note that if the delegate method is invoked, the
     * layout manager will be ignored. */
    
    - (void)layoutSublayersOfLayer:(CALayer *)layer;
    
    /* If defined, called by the default implementation of the
     * -actionForKey: method. Should return an object implementing the
     * CAAction protocol. May return 'nil' if the delegate doesn't specify
     * a behavior for the current event. Returning the null object (i.e.
     * '[NSNull null]') explicitly forces no further search. (I.e. the
     * +defaultActionForKey: method will not be called.) */
    
    - (nullable id<CAAction>)actionForLayer:(CALayer *)layer forKey:(NSString *)event;
    
    @end
    

    趁着这个机会,如果代理想直接设置contents属性的话,它就可以这么做,不然没有别的方法可以调用了。如果代理不实现-displayLayer:方法,CALayer就会转而尝试调用下面这个方法:

    - (void)drawLayer:(CALayer *)layer inContext:(CGContextRef)ctx;
    

    在调用这个方法之前,CALayer创建了一个合适尺寸的空寄宿图(尺寸由bounds和contentsScale决定)和一个Core Graphics的绘制上下文环境,为绘制寄宿图做准备,他作为ctx参数传入。

    实现CALayerDelegate并做一些绘图工作

    - (void)caLayerDelegate {
        // create layerView
        UIView *layerView = [[UIView alloc] initWithFrame:CGRectMake(50.0f, 50.0f, 100.0f, 100.0f)];
        layerView.center = self.view.center;
        [self.view addSubview:layerView];
        
        //create sublayer
        CALayer *blueLayer = [CALayer layer];
        blueLayer.frame = CGRectMake(50.0f, 50.0f, 100.0f, 100.0f);
        blueLayer.backgroundColor = [UIColor blueColor].CGColor;
    
        //set controller as layer delegate
        blueLayer.delegate = self;
        
        // ensure that layer backing image uses correct scale
        blueLayer.contentsScale = [UIScreen mainScreen].scale;
        
        [layerView.layer addSublayer:blueLayer];
        
        [blueLayer display];
    }
    
    #pragma mark - CALayerDelegate
    
    - (void)drawLayer:(CALayer *)layer inContext:(CGContextRef)ctx {
        //draw a thick red circle
        CGContextSetLineWidth(ctx, 10.0f);
        CGContextSetStrokeColorWithColor(ctx, [UIColor redColor].CGColor);
        CGContextStrokeEllipseInRect(ctx, layer.bounds);
    }
    
    • 运行结果如下
    image.png

    上面就是实现CALayerDelegate来绘制图层

    注意一下一些有趣的事情:

    • 我们在blueLayer上显式地调用了-display。不同于UIView,当图层显示在屏幕上时,CALayer不会自动重绘它的内容。它把重绘的决定权交给了开发者。

    • 尽管我们没有用masksToBounds属性,绘制的那个圆仍然沿边界被裁剪了。这是因为当你使用CALayerDelegate绘制寄宿图的时候,并没有对超出边界外的内容提供绘制支持。

    现在你理解了CALayerDelegate,并知道怎么使用它。但是除非你创建了一个单独的图层,你几乎没有机会用到CALayerDelegate协议。因为当UIView创建了它的宿主图层时,它就会自动地把图层的delegate设置为它自己,并提供了一个-displayLayer:的实现,那所有的问题就都没了。

    当使用寄宿了视图的图层的时候,你也不必实现-displayLayer:-drawLayer:inContext:方法来绘制你的寄宿图。通常做法是实现UIView的-drawRect:方法,UIView就会帮你做完剩下的工作,包括在需要重绘的时候调用-display方法。

    2.3 总结

    本章介绍了寄宿图和一些相关的属性。你学到了如何显示和放置图片, 使用拼合技术来显示, 以及用CALayerDelegate和Core Graphics来绘制图层内容。


    本文摘自 iOS核心动画高级技巧


    项目链接地址 - AnimationHightSkill_1


    相关文章

      网友评论

        本文标题:iOS核心动画高级技巧一(图层树与寄宿图)

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