美文网首页核心动画
[iOS] 核心高级动画技巧 — Part1

[iOS] 核心高级动画技巧 — Part1

作者: 木小易Ying | 来源:发表于2019-10-25 23:19 被阅读0次

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

为什么iOS要基于UIView和CALayer提供两个平行的层级关系呢?为什么不用一个简单的层级来处理所有事情呢?

原因在于要做职责分离,这样也能避免很多重复代码。例如在iOS和Mac OS两个平台上,事件和用户交互有很多地方的不同,基于多点触控的用户界面和基于鼠标键盘有着本质的区别,但是绘制的部分是一致的可以共享。

CALayer的寄宿图

在设置图层图片时,需要给contents赋值。其定义如下:

@property(nullable, strong) id contents; 

可以看到,它是一个id类型的对象,但是这是为了兼容OSX开发中CoreAnimation、AppKit中的CGImage和NSImage两种类型而设置,对于UIKit的UIImage并不支持。

UIImage: 存在CGImage属性(Cocoa下对象),返回“CGImageRef”,返回的是CoreFundation下的类型,因此需要类型转化一下:

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

由于转换的是CGImageRef,我们知道C类型是不受ARC管理的,所以需要进行类型桥接。

如果你设置给content的类型不是image,那么这个图层就啥也不显示,其实就是没有成功被转换。


UIView.contentMode = CALayer.contentGravity

设置view的contetMode其实就是对layer设置:

@property(copy) NSString *contentsGravity; 

它是NSString类型,可选的常量值为kCAGravity**。
对view设置contetMode为AspectFit等同self.layerView.layer.contentsGravity = kCAGravityResizeAspect;


contentScale

属性定义了寄宿图的像素尺寸和视图大小的比例,默认情况下它是一个值为1.0的浮点数。它用来判断在绘制图层的时候应该为寄宿图创建的空间大小,和需要显示的图片的拉伸度。

如果设置为1.0,将会以每个点1个像素绘制图片,如果设置为 2.0,则会以每个点2个像素绘制图片,其实也就是我们熟悉的2x 3x的意思。

当设置layer的contentGravity为kCAGravityCenter(这个值不会拉伸图片),并且通过设置图片的方式设置layer的content,那么这时你会发现图片大了一倍(如果是3x就是三倍)。因为会自动读取高分辨率的图片,却不会自动设置contentScale为正确的数值,仍旧默认设置为1,就会造成过大的情况。(类型转换过程中丢失了缩放因子)

举个栗子,如果图片大小本来是3030个点,在3x的设备上会读取9090像素的图片,然后设置显示的时候却默认告诉设备按照一个点一个像素的方式显示,于是这个图片的大小就变为了90*90个点,也就大了3倍。

所以如果是这种情形需要手动设置:

layer.contentsScale = [UIScreen mainScreen].scale;

maskToBounds

对应UIView的clipsToBounds属性,即是否绘制超过了边界的部分。


contentRect

CALayer的contentsRect属性允许我们在图层边框里显示寄宿图的一个子域,它使用了单位坐标,单位坐标指定在0到1之间,是一个相对值(像素和点就是绝对值)。

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

contentRect

这个的一个比较好的应用是类似Cocos Creator里面的图集,就是把多张图片放到一张上面,然后通过contentRect截取你要的部分,这样可以减少载入时间(单张大图载入时间小于多张小图)、内存使用、渲染消耗。

图集

但是如果你要改其中一张的尺寸其他都得改比较麻烦。。有些软件可以自己拼合图片并且输出xml文件记录各个图片的位置,这样就可以不用代码写死啦,读xml并设置就好啦。


contentsCenter

contentsCenter其实是一个CGRect,它定义了一个固定的边框和一个在图层上可拉伸的区域。 有点类似点九图的感觉。

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

可拉伸区域contentCenter

其实在xib里面也可以设置哦,就是Stretching~


Custom Drawing自定义绘制

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

drawRect: 方法没有默认的实现,因为对UIView来说,寄宿图并不是必须的。如果UIView检测到-drawRect:方法被调用了,它就会为视图分配一个寄宿图,这个寄宿图的像素尺寸等于视图大小乘以contentsScale的值。如果你不需要寄宿图,那就不要创建这个方法了,这会造成CPU资源和内存的浪费,这就是为什么苹果建议:如果没有自定义绘制的任务就不要在子类中写一个空-drawRect:方法

setNeedsDisplay被调用的时候,上次drawRect绘制结果产生的缓存才会被清空,进行重绘,表现上虽然是当你更改一些UIView的属性的时候进行了重绘,其实底层都是setNeedsDisplay

而再底层一点,其实UIView的重绘是依赖于CALayer来重绘并且缓存产生的图片

CALayer有一个可选的delegate属性,实现了CALayerDelegate协议,当CALayer需要一个内容特定的信息时,就会从协议中请求。CALayerDelegate是一个非正式协议,其实就是说没有CALayerDelegate @protocol可以让你在类里面引用啦。(一般layer的delegate都是view,尽量别改)

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

- (void)displayLayer:(CALayer *)layer;

如果代理不实现displayLayer方法,CALayer就会转而尝试调用下面这个方法:

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

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

@interface LayerViewController () <CALayerDelegate>{
    UIImageView *testView;
}

@end

@implementation LayerViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    
    testView = [[UIImageView alloc] initWithFrame:CGRectMake(100, 100, 200, 200)];
    [self.view addSubview:testView];
    
    CALayer *yellowLayer = [[CALayer alloc] init];
    yellowLayer.frame = CGRectMake(50, 50, 100, 100);
    yellowLayer.backgroundColor = [UIColor yellowColor].CGColor;
    yellowLayer.delegate = self;
    [testView.layer addSublayer:yellowLayer];
    
    // [yellowLayer display];
    
    testView.layer.cornerRadius = 20;
    
    testView.image = [UIImage imageNamed:@"li.jpg"];
}

- (void)drawLayer:(CALayer *)layer inContext:(CGContextRef)ctx {
    CGContextSetLineWidth(ctx, 10);
    CGContextSetStrokeColorWithColor(ctx, [UIColor blackColor].CGColor);
    CGContextStrokeEllipseInRect(ctx, layer.bounds);
}

@end

上面的代码如果运行会发现只有一张图片上面有一块黄色,但并没有椭圆,这是因为没有执行[yellowLayer display],如果解开注释就会有啦,但是也不会超出范围绘制哦。

  • 不同于UIView,当图层显示在屏幕上时,CALayer不会自动重绘它的内容(调用delegate的绘制)。它把重绘的决定权交给了开发者,需要手动在layer上调用-display。

  • 尽管我们没使用masksToBounds属性,绘制的那个圆仍然沿边界被裁剪了。这是因为当你使用CALayerDelegate绘制寄宿图的时候,并没有对超出边界外的内容提供绘制。
    这个我理解就是传入的ctx是已经计算好尺寸的画布了, 就不能绘制超出画布的区域。

当创建UIView时,它会自动把图层的delegate设置为自己,如果需要绘制就重写drawRect,如果需要重绘UIView会自己帮我们调用layer的display。


布局

  • UIView有三个比较重要的布局属性:frame,bounds和center
  • CALayer对应的叫:frame,bounds和position

frame代表了图层的外部坐标(也就是在父图层上占据的空间)
bounds是内部坐标({0, 0}通常是指图层的左上角)
center和position都代表了相对于父图层anchorPoint所在的位置。

布局属性

视图的frame,bounds和center属性仅仅是存取方法,当操作视图的frame,实际上是在改变位于视图下方CALayer的frame,不能够独立于layer之外改变视图的frame。

当我们修改一view的frame的时候,其实改变的是layer的,就和改view的背景颜色其实实际改的是layer的颜色。

bounds是左上角相对于自己坐标系的坐标,所以如果将bounds改为(-10,-10),那么相当于将坐标系向右下移动了10,那么所有子view都会像右下移动10。这里注意父view不会动,是子view移动哦

更改bounds的大小,bounds的大小代表当前视图的长和宽,修改长宽后,中心点继续保持不变, 长宽进行改变;通过bounds修改长宽看起来就像是以中心点为基准点对长宽两边同时进行缩放。

frame其实是虚拟属性,是由bounds、position和transform计算得到的,当任一因素改变的时候,frame就会变;如果frame改变,同理position也会受到影响改变。


当对图层做变换的时候,比如旋转或者缩放,frame实际上代表了覆盖在图层旋转之后的整个轴对齐的矩形区域,也就是说frame的宽高可能和bounds不在一致了。

图片旋转
anchorPoint

图层的anchorPoint通过position来控制它的frame的位置,改变anchorPoint并不会改变position,所以anchorPoint可以被理解为用于移动视图的把柄。

默认来说,anchorPoint位于图层的中间点,所以图层的将会以这个点为中心放置。anchorPoint并没有被UIView暴露出来,这也是视图的position属性被叫做center的原因。但是图层的anchorPoint可以被移动。

移动anchorPoint
坐标系转换

view提供一些方法可以将其他view坐标系中的坐标点转换到当前view的坐标系中,或者将自己坐标系的点转换到其他view的坐标系中:

// 将像素point由point所在视图转换到目标视图view中,返回在目标视图view中的像素值
- (CGPoint)convertPoint:(CGPoint)point toView:(UIView *)view;

// 将像素point从view中转换到当前视图中,返回在当前视图中的位置
- (CGPoint)convertPoint:(CGPoint)point fromView:(UIView *)view;

// 将rect由rect所在视图转换到目标视图view中,返回在目标视图view中的rect
- (CGRect)convertRect:(CGRect)rect toView:(UIView *)view;

// 将rect从view中转换到当前视图中,返回在当前视图中的rect
- (CGRect)convertRect:(CGRect)rect fromView:(UIView *)view;

layer也是一样的,提供对应的接口:

- (CGPoint)convertPoint:(CGPoint)point fromLayer:(CALayer *)layer;
- (CGPoint)convertPoint:(CGPoint)point toLayer:(CALayer *)layer;
- (CGRect)convertRect:(CGRect)rect fromLayer:(CALayer *)layer;
- (CGRect)convertRect:(CGRect)rect toLayer:(CALayer *)layer;

翻转的几何结构

常规说来,在iOS上,一个图层的position位于父图层的左上角,但是在Mac OS上,通常是位于左下角。Core Animation可以通过 geometryFlipped 属性来适配这两种情况,它决定了一个图层的坐标是否相对于父图层垂直翻转,是一个 BOOL 类型。在iOS上通过设置它为 YES意味着它的子图层将会被垂直翻转, 也就是将会沿着底部排版而不是通常的顶部(它的所有子图层也同理,除非把它们 的 geometryFlipped 属性也设为YES )。


Z坐标轴

UIView严格的二维坐标系不同,CALayer存在于一个三维空间当中。除了我们已经讨论过的positionanchorPoint属性之外, CALayer 还有另外两个属性,zPositionanchorPointZ,二者都是在Z轴上描述图层位置的浮点类型。

注意这里并没有更深的属性来描述由宽和高做成的 bounds了,图层是一个完全扁平的对象,你可以把它们想象成类似于一页二维的坚硬的纸片,用胶水粘成一个 空洞,就像三维结构的折纸一样。

zPosition 属性在大多数情况下其实并不常用。在第五章,我们将会涉及 CATransform3D ,你会知道如何在三维空间移动和旋转图层,除了做变换之 外,zPosition最实用的功能就是改变图层的显示顺序了。

其实并不需要增加太多,视图都非常地薄,所以给zPosition提高一个像素就可以让后面的视图前置,当然0.1或者0.0001也能够做到,但是最好不要这样,因为浮点类型四舍五入的计算可能会造成一些不便的麻烦。

self.greenView.layer.zPosition = 1.0f;

Hit Testing

CALayer并不关心任何响应链事件,所以不能直接处理触摸事件或者手势。但是它有一系列的方法帮你处理事件:-containsPoint:和-hitTest:

  • containsPoint:接受一个在本图层坐标系下的CGPoint(注意如果不是相对于自身图层的需要先转换再传入判断),如果这个点在图层frame范围内就返回YES。

  • hitTest:方法同样接受一个CGPoint类型参数,而不是BOOL类型,它返回图层本身,或者包含这个坐标点的叶子节点图层。
    这样就不用像使用-containsPoint:那样遍历子layer看point是不是被子layer contain,只需要看父layer hitTest返回的layer与哪个子layer相同即可。如果这个点在最外面图层的范围之外,则返回nil。

※ 注意哦:当调用图层的-hitTest:方法时,测算的顺序严格依赖于图层树当中的图层顺序(和UIView的响应链事件类似)。之前提到的zPosition属性可以明显改变屏幕上图层的顺序,但不能改变事件传递的顺序。

也就是如果我们手动修改了后面layer的zPosition让它到最前面,你点击的时候可能hitTest返回的永远不会是这个看起来是最前面的layer。


自动布局

view的UIViewAutoresizingMask类型的一些常量,可以应用于当父视图改变尺寸的时候,相应view的frame也跟着更新的场景,但是对于CALayer并不适用。

也就是说如果有个view1包含view2,当你改view1尺寸的时候其实view2也会变,但是如果是layer1包含layer2,那么layer1尺寸变化,layer2并不会变。

但如果想随意控制CALayer的布局,就需要手动操作。最简单的方法就是使用CALayerDelegate如下函数:

- (void)layoutSublayersOfLayer:(CALayer *)layer;

当layer的bounds发生改变,或者layer的-setNeedsLayout 方法被调用的时候,这个函数将会被执行。但是不能像UIView的autoresizingMask和constraints属性做到自适应屏幕旋转。


视觉效果

圆角

CALayer有一个叫做conrnerRadius的属性控制着图层角的曲率。它是一个浮点数,默认为0(为0的时候就是直角),但是你可以把它设置成任意值。默认情况下,这个曲率值只影响背景颜色而不影响背景图片或是子图层。不过,如果把masksToBounds设置成YES的话,图层里面的所有东西都会被截取。

图层边框

borderWidth是以点为单位的定义边框粗细的浮点数,默认为0。borderColor定义了边框的颜色,默认为黑色。

borderColor是CGColorRef类型,而不是UIColor,所以它不是Cocoa的内置对象。

边框是绘制在图层边界里面的,而且在所有子内容之前,也在子图层之前(最上面)。边框不会管你的内容大小,只和layer的边界有关。

阴影
  • shadowOpacity是一个必须在0.0(不可见)和1.0(完全不透明)之间的浮点数。如果设置为1.0,将会显示一个有轻微模糊的黑色阴影稍微在图层之上。若要改动阴影的表现,你可以使用CALayer的另外三个属性:shadowColor,shadowOffset和shadowRadius。

  • shadowColor属性控制着阴影的颜色,和borderColor和backgroundColor一样,它的类型也是CGColorRef。阴影默认是黑色。

  • shadowOffset属性控制着阴影的方向和距离。它是一个CGSize的值,宽度控制这阴影横向的位移,高度控制着纵向的位移。shadowOffset的默认值是 {0, -3},意即阴影相对于Y轴有3个点的向上位移。

  • shadowRadius属性控制着阴影的模糊度,当它的值是0的时候,阴影就和视图一样有一个非常确定的边界线。当值越来越大的时候,边界线看上去就会越来越模糊和自然。苹果自家的应用设计更偏向于自然的阴影,所以一个非零值再合适不过了。

和图层边框不同,图层的阴影继承自内容的外形,而不是根据边界和角半径来确定。为了计算出阴影的形状,Core Animation会将寄宿图(包括子视图,如果有的话)考虑在内,然后通过这些来完美搭配图层形状从而创建一个阴影。

阴影

于是就有了maskToBounds和阴影的冲突啦
阴影通常就是在Layer的边界之外,如果你开启了masksToBounds属性,所有从图层中突出来的内容都会被才剪掉。

如果你想沿着内容裁切,你需要用到两个图层:一个只画阴影的空的父图层(也就是对mask后的内容进行阴影),和一个用masksToBounds裁剪内容的子图层。

shadowPath属性

我们已经知道图层阴影并不总是方的,而是从图层内容的形状继承而来。这看上去不错,但是实时计算阴影也是一个非常消耗资源的,尤其是图层有多个子图层,每个图层还有一个有透明效果的寄宿图的时候。

如果你事先知道你的阴影形状会是什么样子的,你可以通过指定一个shadowPath来提高性能。shadowPath是一个CGPathRef类型(一个指向CGPath的指针)。CGPath是一个Core Graphics对象,用来指定任意的一个矢量图形。我们可以通过这个属性单独于图层形状之外指定阴影的形状。

如果是一个矩形或者是圆,用CGPath会相当简单明了。但是如果是更加复杂一点的图形,UIBezierPath类会更合适,它是一个有UIKit提供的在CGPath基础上的OC包装类。

testView = [[UIImageView alloc] initWithFrame:CGRectMake(100, 100, 200, 200)];
[self.view addSubview:testView];

testView.layer.cornerRadius = 20;

testView.image = [UIImage imageNamed:@"li.jpg"];

//路径阴影
float paintingWidth = testView.frame.size.width;
float paintingHeight = testView.frame.size.height;
UIBezierPath *path = [UIBezierPath bezierPath];
[path moveToPoint:CGPointMake(-5, -5)];
//添加直线
[path addLineToPoint:CGPointMake(paintingWidth /2, -15)];
[path addLineToPoint:CGPointMake(paintingWidth +5, -5)];
[path addLineToPoint:CGPointMake(paintingWidth +15, paintingHeight /2)];
[path addLineToPoint:CGPointMake(paintingWidth +5, paintingHeight +5)];
[path addLineToPoint:CGPointMake(paintingWidth /2, paintingHeight +15)];
[path addLineToPoint:CGPointMake(-5, paintingHeight +5)];
[path addLineToPoint:CGPointMake(-15, paintingHeight /2)];
[path addLineToPoint:CGPointMake(-5, -5)];
//设置阴影路径
testView.layer.shadowPath = path.CGPath;
testView.layer.shadowOpacity = 1;

testView.frame = CGRectMake(300, 300, 100, 100);
改变view的frame阴影没变

这里我最开始设定的testView.layer.shadowPath其实是正好包裹testView的,但是改变testView的frame以后虽然阴影跟着view移动了,但是大小没有变,也就是相对位置不会变,其实就是重新根据shadowPath绘制了一遍,反正shadowPath是相对view自身坐标系的。

也就是说,当我们给定shadowPath以后,内容改变后view是不会自动计算shadow了就,如果有改变需要手动重新设置shadowPath,但这个可以改善性能啊。


layer的mask属性:图层蒙版

CALayer有一个属性叫做mask,这个属性本身就是个CALayer类型,有和其他图层一样的绘制和布局属性。它类似于一个子图层,相对于父图层(即拥有该属性的图层)布局,但是它却不是一个普通的子图层。不同于那些绘制在父图层中的子图层,mask图层定义了父图层的部分可见区域。

mask图层的Color属性是无关紧要的,真正重要的是图层的轮廓。mask属性就像是一个饼干切割机,mask图层实心的部分会被保留下来,其他的则会被抛弃。

layer的mask

CALayer蒙板图层真正厉害的地方在于蒙板图不局限于静态图。任何有图层构成的都可以作为mask属性,这意味着你的蒙板可以通过代码甚至是动画实时生成。

拉伸过滤

当图片需要显示不同的大小的时候,有一种叫做拉伸过滤的算法就起到作用了。它作用于原图的像素上并根据需要生成新的像素显示在屏幕上。

事实上,重绘图片大小也没有一个统一的通用算法。这取决于需要拉伸的内容,放大或是缩小的需求等这些因素。CALayer为此提供了三种拉伸过滤方法,他们是:

kCAFilterLinear
kCAFilterNearest
kCAFilterTrilinear
  • kCAFilterLinear过滤器采用双线性滤波算法,它在大多数情况下都表现良好。双线性滤波算法通过对多个像素取样最终生成新的值,得到一个平滑的表现不错的拉伸。但是当放大倍数比较大的时候图片就模糊不清了。

  • kCAFilterTrilinearkCAFilterLinear非常相似,大部分情况下二者都看不出来有什么差别。但是,较双线性滤波算法而言,三线性滤波算法存储了多个大小情况下的图片(也叫多重贴图),并三维取样,同时结合大图和小图的存储进而得到最后的结果。

  • kCAFilterNearest是一种比较武断的方法。从名字不难看出,这个算法(也叫最近过滤)就是取样最近的单像素点而不管其他的颜色。这样做非常快,也不会使图片模糊。但是,最明显的效果就是,会使得压缩图片更糟,图片放大之后也显得块状或是马赛克严重。

放大缩小的三种插值

但是有的时候,如果是差异较大色块,用线性插值可能会使边缘颜色混合模糊,但是nearest可以只保留一个,边缘更清晰,如设置:

view.layer.magnificationFilter = kCAFilterNearest;
组透明

(这个问题最新的iOS好像已经没有了,你设置了父view的透明度以后,看起来不会出现父子view的颜色差,虽然子view没有设置透明度,但和父view颜色一致)

UIView有一个叫做alpha的属性来确定视图的透明度。CALayer有一个等同的属性叫做opacity,这两个属性都是影响子层级的。也就是说,如果你给一个图层设置了opacity属性,那它的子图层都会受此影响。

左边是一个不透明的按钮,右边是50%透明度的相同按钮。我们可以注意到,里面的标签的轮廓跟按钮的背景很不搭调:


透明度问题

这是由透明度的混合叠加造成的,当你显示一个50%透明度的图层时,图层的每个像素都会一半显示自己的颜色,另一半显示图层下面的颜色。这是正常的透明度的表现。但是如果图层包含一个同样显示50%透明的子图层时,你所看到的视图,50%来自子视图,25%来了图层本身的颜色,另外的25%则来自背景色。

理想状况下,当你设置了一个图层的透明度,你希望它包含的整个图层树像一个整体一样的透明效果。你可以通过设置Info.plist文件中的UIViewGroupOpacity为YES来达到这个效果,但是这个设置会影响到这个应用,整个app可能会受到不良影响。如果UIViewGroupOpacity并未设置,iOS 6和以前的版本会默认为NO(也许以后的版本会有一些改变)。

另一个方法就是,你可以设置CALayer的一个叫做shouldRasterize属性来实现组透明的效果,如果它被设置为YES,在应用透明度之前,图层及其子图层都会被整合成一个整体的图片,这样就没有透明度混合的问题了。(缓存bitmap)

为了启用shouldRasterize属性,我们设置了图层的rasterizationScale属性。默认情况下,所有图层拉伸都是1.0, 所以如果你使用了shouldRasterize属性,你就要确保你设置了rasterizationScale属性去匹配屏幕,以防止出现Retina屏幕像素化的问题。

这个属性需要注意性能问题哦。

相关文章

网友评论

    本文标题:[iOS] 核心高级动画技巧 — Part1

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