《iOS Core Animation: Advanced Techniques》笔记

Chapter 1

</br>
Core Animation不是一个严谨的框架。CAAnimation才是一个框架名,它是QuartzCore的一部分。
Core Animation是用C写的,所以要注意类型转换,用“无缝桥接” __bridge
</br>

UIView是视图,CALayer是图层。

</br>

UIViewCALayer的关系:

  • UIView处理用户交互,CALayer不管。

  • 每个UIView都有一个CALayer实例的图层属性,不能在Interface Builder中设置UIViewlayer属性。
    </br>

处理视图比单独处理图层更方便。

Chapter 2

</br>
知识点:

  • CALayercontents属性
  • UIViewcontentMode属性和CALayercontentsGravity属性
  • CALayercontentsScale属性
  • UIViewclipsToBounds属性和CALayermaskToBounds属性
  • CALayercontentsRect属性
  • CALayercontentsCenter属性(可在Interface Builder中设置)
  • CALayerdelegate属性,遵循CALayerDelegate
  • CALayer-displayer方法

</br>
寄宿图(图层中包含的图):
CALayercontents属性

UIImage *image = [UIImage imageNamed:@"picture"];
    
self.layerView.layer.contents = (__bridge id)(image.CGImage);

这样的寄宿图会在图层中被拉伸以适应视图的大小,可以加上下面这句,让显示的图片适应原来的比例

self.layerView.contentMode = UIViewContentModeScaleAspectFit;

由此可见,UIView大多数视觉相关的属性比如上面用的contentMode,对这些属性的操作其实是对对应图层的操作。

CALayercontentMode属性叫contentsGravity

self.layerView.layer.contentsGravity = kCAGravityResizeAspect;

可以达到像刚才设置UIViewcontentMode属性同样的效果。

注意防止用错误的contentsScale去显示Retina图片。CGImage没有UIImage中的拉伸概念。当用UIImage读取的图片都是Retina图片,在UIImage转换为CGImage时,拉伸的元素将会丢失。contentsScale默认值为1.0,如果把contentsGravity属性设为kCAGravityCenter,不设置contentsScale(即采用默认值),就会出现这种错误现实Retina图片的情况。

正确做法:

UIImage *image = [UIImage imageNamed:@"picture"];
self.layerView.layer.contents = (__bridge id)(image.CGImage);

self.layerView.layer.contentsGravity = kCAGravityCenter;
self.layerView.layer.contentsScale = image.scale;

默认情况下,UIViewCALayer都会绘制超过边界的内容或子视图。
只要设置UIView的属性clipsToBoundsCALayermaskToBounds设置为YES。就不会超出边界了。

self.layerView.clipsToBounds = YES;
//或
self.layerView.layer.maskToBounds = YES;

除了可以使用CALayercontents属性设置寄宿图,还可以-drawRect方法自定义绘图,此方法没有默认的实现,因为UIView不一定有寄宿图,如果不需要寄宿图,就不要实现此方法,否则会浪费资源。

CALayer要显式调用-display方法,当图层显示在屏幕上时,CALayer不会自动重绘它的内容。它把重绘的决定权交给了开发者。
没有用masksToBounds属性,绘制的那个圆仍然沿边界被裁剪了。这是因为使用CALayerDelegate绘制寄宿图的时候,并没有对超出边界外的内容提供绘制支持。

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

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

Chapter 3

</br>
知识点:

  • UIViewframeboundscenter属性和CALayerframeboundsposition属性
  • CALayeranchorPoint属性
  • CALayerzPosition属性
  • CALayer-containsPoint:方法和-hitTest:方法

布局

UIView三个重要布局属性:frameboundscenter
对应CALayerframeboundsposition
frame代表了图层的外部坐标(也就是在父图层上占据的空间),bounds是内部坐标({0, 0}通常是图层的左上角),centerposition都代表了相对于父图anchorPoint所在的位置。

视图的frameboundscenter属性仅仅是存取方法,当操纵视图的frame,实际上是在改变位于视图下方CALayerframe,不能够独立于图层之外改变视图的frame。对于视图或者图层来说,frame并不是一个非常清晰的属性,它其实是一个虚拟属性,是根据boundspositiontransform计算而来,所以当其中任何一个值发生改变,frame都会变化。相反,改变frame的值同样会影响到他们当中的值。

�View和Layer的坐标系关系

旋转后View和Layer的坐标系关系

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

锚点

视图的center属性和图层的position属性都指定了anchorPoint相对于父图层的位置。图层的anchorPoint通过position来控制它的frame的位置,你可以认为anchorPoint是用来移动图层的把柄。默认来说,anchorPoint位于图层的中点,所以图层的将会以这个点为中心放置。anchorPoint属性并没有被UIView
接口暴露出来,这也是视图的position属性被叫做“center”的原因。但是图层的anchorPoint可以被移动,比如你可以把它置于图层frame的左上角,于是图层的内容将会向右下角的position方向移动,而不是居中了。

修改anchorPoint

注意到,当改变了anchorPointposition属性保持固定的值并没有发生改变,但是frame却移动了。

contentsRectcontentsCenter属性类似,anchorPoint单位坐标来描述,也就是图层的相对坐标,图层左上角是{0, 0},右下角是{1, 1},因此默认坐标是{0.5, 0.5}anchorPoint可以通过指定xy值小于0或者大于1,使它放置在图层范围之外。

钟摆例子使用anchorPoint

每个时针使用一个UIImageView

运行后 时针的位置很奇怪

给各时针修改锚点:

self.hourHand.layer.anchorPoint = CGPointMake(0.5f, 0.9f);
self.minuteHand.layer.anchorPoint = CGPointMake(0.5f, 0.9f);
self.secondHand.layer.anchorPoint = CGPointMake(0.5f, 0.9f);
比较正常的指针

</br>

坐标系

和视图一样,图层在图层树当中也是相对于父图层按层级关系放置,一个图层的position依赖于它父图层的bounds,如果父图层发生了移动,它的所有子图层也会跟着移动。
这样对于放置图层会更加方便,因为你可以通过移动根图层来将它的子图层作为一个整体来移动,但是有时候你需要知道一个图层的绝对位置,或者是相对于另一个图层的位置,而不是它当前父图层的位置。CALayer给不同坐标系之间的图层转换提供了一些工具类方法:

-(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

这些方法可以把定义在一个图层坐标系下的点或者矩形转换成另一个图层坐标系下的点或者矩形。

Z坐标轴

UIView严格的二维坐标系不同,CALayer
存在于一个三维空间当中。除了我们已经讨论过的positionanchorPoint属性之外,CALayer还有另外两个属性,zPositionanchorPointZ,二者都是在Z轴上描述图层位置的浮点类型。
zPosition属性在大多数情况下其实并不常用。在第五章,我们将会涉及CATransform3D,你会知道如何在三维空间移动和旋转图层,除了做变换之外,zPosition最实用的功能就是改变图层的显示顺序了。
通常,图层是根据它们子图层的sublayers出现的顺序来类绘制的,这就是所谓的画家的算法--就像一个画家在墙上作画--后被绘制上的图层将会遮盖住之前的图层,但是通过增加图层的zPosition,就可以把图层向相机方向前置,于是它就在所有其他图层的前面了(或者至少是小于它的zPosition值的图层的前面)。

在IB设置的层次结构中,greenView在pinkView后面

为greenView图层设置zPosition属性:

self.greenView.layer.zPosition = 1.0f;
运行后,greenView显示在pinkView前

Hit Testing

最好使用图层相关视图,而不是创建独立的图层关系。其中一个原因就是要处理额外复杂的触摸事件。
CALayer并不关心任何响应链事件,所以不能直接处理触摸事件或者手势。但是它有一系列的方法帮你处理事件:-containsPoint:-hitTest:

-containsPoint:接受一个在本图层坐标系下的CGPoint,如果这个点在图层frame范围内就返回YES。下面看例:

@interface ViewController ()
@property (weak, nonatomic) IBOutlet UIView *layerView;
@property (strong, nonatomic) CALayer *blueLayer;
@end

@implementation ViewController
- (void)viewDidLoad {
    [super viewDidLoad];

    self.blueLayer = [CALayer layer];
    self.blueLayer.frame = CGRectMake(50.0f, 50.0f, 100.0f, 100.0f);
    self.blueLayer.backgroundColor = [UIColor blueColor].CGColor;
    [self.layerView.layer addSublayer:self.blueLayer];
}
-(void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    
    CGPoint point = [[touches anyObject] locationInView:self.view];
    point = [self.layerView.layer convertPoint:point fromLayer:self.view.layer];
    if ([self.layerView.layer containsPoint:point]) {
        point = [self.blueLayer convertPoint:point fromLayer:self.layerView.layer];
        if ([self.blueLayer containsPoint:point]) {
            NSLog(@"Inside Blue Layer.");
        } else {
            NSLog(@"Inside White Layer.");
        }
    }
}

效果图

当点击白色视图图层的子图层时,打印Inside Blue Layer,点击白色视图图层时,打印Inside White Layer

-hitTest:方法同样接受一个CGPoint类型参数,它返回图层本身,或者包含这个坐标点的叶子节点图层。这意味着不再需要像使用-containsPoint:那样,人工地在每个子图层变换或者测试点击的坐标。如果这个点在最外面图层的范围之外,则返回nil。具体使用-hitTest:方法被点击图层的代码如下:

-(void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    
CGPoint point = [[touches anyObject] locationInView:self.view];
CALayer *layer = [self.layerView.layer hitTest:point];
if (layer == self.blueLayer) {
    NSLog(@"Inside Blue Layer");
} else if (layer == self.layerView.layer) {
    NSLog(@"Inside White Layer");
    }
}

得到跟上面一样的效果图

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

这意味着如果改变了图层的z轴顺序,你会发现将不能够检测到最前方的视图点击事件,这是因为被另一个图层遮盖住了,虽然它的zPosition值较小,但是在图层树中的顺序靠前。

</br>

Chapter 4

</br>
知识点:

  • CALayercornerRadius属性
  • CALayerborderWidth属性
  • CALayerborderColor属性
  • CALayershadowOpacity属性
  • CALayershadowColor属性
  • CALayershadowOffset属性
  • CALayershadowRadius属性
  • CALayermask属性
  • CALayermagnificationFilter属性和minificationFilter属性
  • CALayershouldRasterize属性
  • CALayerrasterizationScale属性

</br>
制造矩形圆角,用CALayercornerRadius属性,CGFloat类型,默认值为0(直角)。

borderWidth属性用来设置图层的边界宽度,CGFloat类型。borderColor属性就是设置图层的颜色,CGColorRef类型,默认为黑色。如果图层的子图层超过了边界,或者是寄宿图在透明区域有一个透明蒙板,边框仍然会沿着图层的边界绘制出来。

阴影:shadowOpacity属性取值在0.0(不可见)到1.0(完全不透明)之间,CGFloat类型。可以改变三个相关属性改变阴影的表现:shadowColorshadowOffsetshadowRadius
shadowOffsetCGSize,宽度控制横向位移,高度控制纵向位移,其默认值为{0, -3}(阴影向上)。
shadowRadius控制阴影的模糊度,CGFloat类型。当它值为0(默认值)时,阴影就有一个和视图一样有一个明确地边界值,值越大边界越模糊,越接近自然阴影的效果。

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

第一个雪人超出视图边界,阴影也是如此

但是CALayermaskToBounds属性会把阴影也去掉:

�阴影被maskToBounds属性去掉

如果想沿着内容裁切,你需要用到两个图层:一个只画阴影的空的外图层,和一个用masksToBounds裁剪内容的内图层。

�在原视图底部添加一个画阴影的图层(即外图层)
添加外图层后的效果图

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

因此如果事先知道阴影形状,可以使用shadowPath提高性能,其类型为CGPathRef
CGPath是一个Core Graphics对象,用来指定任意的一个矢量图形。我们可以通过这个属性单独于图层形状之外指定阴影的形状。

//enable layer shadows 
self.layerView1.layer.shadowOpacity = 0.5f; 
self.layerView2.layer.shadowOpacity = 0.5f;
 //create a square shadow CGMutablePathRef 
squarePath = CGPathCreateMutable(); 
CGPathAddRect(squarePath, NULL, self.layerView1.bounds); 
self.layerView1.layer.shadowPath = squarePath; 
 //create a circular shadow 
CGMutablePathRef circlePath = CGPathCreateMutable(); 
CGPathAddEllipseInRect(circlePath, NULL, self.layerView2.bounds); 
self.layerView2.layer.shadowPath = circlePath; 

效果图

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

图层蒙板

CALayermask属性,其类型也是CALayer。有和其他图层一样的绘制和布局属性。它类似于一个子图层,相对于父图层布局,但是它却不是一个普通的子图层。不同于那些绘制在父图层中的子图层,mask图层定义了父图层的部分可见区域。如果mask图层比父图层要小,只有在mask图层里面的内容才是它关心的,除此以外的一切都会被隐藏起来。

下面用一个UIImageViewUIView的子类)演示一下:

��概念图

UIImage *viewImage = [UIImage imageNamed:@"Igloo"];
    
//set mask
CALayer *maskLayer = [CALayer layer];
maskLayer.frame = self.imageView.bounds;
UIImage *maskImage = [UIImage imageNamed:@"Cone.png"];
maskLayer.contents = (__bridge id)maskImage.CGImage;
    
self.imageView.image = viewImage;
self.imageView.layer.mask = maskLayer;
效果图

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

拉伸过滤

</br>
总得来讲,当我们视图显示一个图片的时候,都应该正确地显示这个图片(意即:以正确的比例和正确的1:1像素显示在屏幕上)。原因如下:

  • 能够显示最好的画质,像素既没有被压缩也没有被拉伸。
  • 能更好的使用内存,因为这就是所有你要存储的东西。
  • 最好的性能表现,CPU不需要为此额外的计算。

不过有时候,显示一个非真实大小的图片确实是我们需要的效果。比如说一个头像或是图片的缩略图,再比如说一个可以被拖拽和伸缩的大图。这些情况下,为同一图片的不同大小存储不同的图片显得又不切实际。

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

重绘图片大小没有一个统一的通用算法。这取决于需要拉伸的内容,放大或是缩小的需求等这些因素。CALayer为此提供了三种拉伸过滤方法:
(1)kCAFilterLinear
(2)kCAFilterNearest
(3)kCAFilterTrilinear

minification(缩小图片)和magnification(放大图片)默认的过滤器都是kCAFilterLinear,这个过滤器采用双线性滤波算法,它在大多数情况下都表现良好。双线性滤波算法通过对多个像素取样最终生成新的值,得到一个平滑的表现不错的拉伸。但是当放大倍数比较大的时候图片就模糊不清了。
kCAFilterTrilinearkCAFilterLinear非常相似,大部分情况下二者都看不出来有什么差别。但是,较双线性滤波算法而言,三线性滤波算法存储了多个大小情况下的图片(也叫多重贴图),并三维取样,同时结合大图和小图的存储进而得到最后的结果。
这个方法的好处在于算法能够从一系列已经接近于最终大小的图片中得到想要的结果,也就是说不要对很多像素同步取样。这不仅提高了性能,也避免了小概率因舍入错误引起的取样失灵的问题。对于大图来说,双线性滤波和三线性滤波表现得更出色。
kCAFilterNearest是一种比较武断的方法。从名字不难看出,这个算法(也叫最近过滤)就是取样最近的单像素点而不管其他的颜色。这样做非常快,也不会使图片模糊。但是,最明显的效果就是,会使得压缩图片更糟,图片放大之后也显得块状或是马赛克严重。对于没有斜线的小图来说,最近过滤算法要好很多。

三者对比图
�三者对比图

总的来说,对于比较小的图或者是差异特别明显,极少斜线的大图,最近过滤算法会保留这种差异明显的特质以呈现更好的结果。但是对于大多数的图尤其是有很多斜线或是曲线轮廓的图片来说,最近过滤算法会导致更差的结果。换句话说,线性过滤保留了形状,最近过滤则保留了像素的差异。

拉伸实验,使用一个1x的图片显示:

1x数字图

magification - kCAFilterLinear

magification - kCAFilterNearest

</br>
####组透明

iOS常见的做法是把一个空间的alpha值设置为0.5(50%)以使其看上去呈现为不可用状态。对于独立的视图来说还不错,但是当一个控件有子视图的时候就有点奇怪了,例如一个内嵌了UILabel的自定义UIButton:

Paste_Image.png

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

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

可以设置CALayer的一个叫做shouldRasterize
属性来实现组透明的效果,如果它被设置为YES,在应用透明度之前,图层及其子图层都会被整合成一个整体的图片,这样就没有透明度混合的问题了。
为了启用shouldRasterize属性,我们设置了图层的rasterizationScale属性。默认情况下,所有图层拉伸都是1.0, 所以如果你使用了shouldRasterize属性,你就要确保你设置了rasterizationScale属性去匹配屏幕,以防止出现Retina屏幕像素化的问题。

</br>

Chapter 5

</br>
第三章中UIViewtransform属性是一个CGAffineTransform类型,用于在二维空间做旋转,缩放和平移。CGAffineTransform是一个可以和二维空间向量(例如CGPoint)做乘法的3X2的矩阵:

用矩阵表示CGPoint和CGAffineTransform

式中用CGPoint的每一列和CGAffineTransform矩阵的每一行对应元素相乘再求和,就形成了一个新的CGPoint类型的结果。要解释一下图中显示的灰色元素,为了能让矩阵做乘法,左边矩阵的列数一定要和右边矩阵的行数个数相同,所以要给矩阵填充一些标志值,使得既可以让矩阵做乘法,又不改变运算结果,并且没必要存储这些添加的值,因为它们的值不会发生变化,但是要用来做运算。

因此,通常会用3×3(而不是2×3)的矩阵来做二维变换,你可能会见到3行2列格式的矩阵,这是所谓的以列为主的格式,上图所示的是以行为主的格式,只要能保持一致,用哪种格式都无所谓。

当对图层应用变换矩阵,图层矩形内的每一个点都被相应地做变换,从而形成一个新的四边形的形状。CGAffineTransform中的“仿射”的意思是无论变换矩阵用什么值,图层中平行的两条线在变换之后任然保持平行,CGAffineTransform可以做出任意符合上述标注的变换,�下图显示了一些仿射的和非仿射的变换:

仿射和非仿射

UIView可以通过设置transform属性做变换,但实际上它只是封装了内部图层的变换。

CALayer同样也有一个transform属性,但它的类型是CATransform3D,而不是CGAffineTransform,本章后续将会详细解释。CALayer对应于UIViewtransform属性叫做affineTransform
</br>

旋转

</br>
使用CALayeraffineTransform属性把一个图片旋转45°:

CGAffineTransform transform = CGAffineTransformMakeRotation(M_PI_4);
    
self.layerView.layer.affineTransform = transform;

效果图

注意我们使用的旋转常量是M_PI_4,而不是你想象的45,因为iOS的变换函数使用弧度而不是角度作为单位。弧度用数学常量pi的倍数表示,一个pi代表180°,所以四分之一的pi就是45°
C的数学函数库(iOS会自动引入)提供了pi的一些简便的换算,M_PI_4于是就是pi的四分之一

</br>

混合变换

</br>
Core Graphics提供了一系列的函数可以在一个变换的基础上做更深层次的变换

CGAffineTransformRotate(CGAffineTransform t, CGFloat angle)
CGAffineTransformScale(CGAffineTransform t, CGFloat sx, CGFloat sy) 
CGAffineTransformTranslate(CGAffineTransform t, CGFloat tx, CGFloat ty)

当操纵一个变换的时候,初始生成一个什么都不做的变换很重要--也就是创建一个CGAffineTransform类型的空值,矩阵论中称作单位矩阵Core Graphics同样也提供了一个方便的常量:

CGAffineTransformIdentity

最后,如果需要混合两个已经存在的变换矩阵,就可以使用如下方法,在两个变换的基础上创建一个新的变换:

CGAffineTransformConcat(CGAffineTransform t1, CGAffineTransform t2);

推荐阅读更多精彩内容