图层树和寄宿图 -- iOS Core Animation 系列一

本系列文章算是一系列读书笔记,想了解更多,请看原文

1.图层树

1.1 视图

一个视图就是在屏幕上显示的一个矩形块(比如图片,文字或者视频),它能够拦截类似于鼠标点击或者触摸手势等用户输入。视图在层级关系中可以互相嵌套,一个视图可以管理它的所有子视图的位置。
在iOS中,所有的视图都是从UIView这个基类派生出来的。UIView可以处理触摸时间,支持Core Graphics绘图,可以仿射变换等等操作。

1.2 CALayer

CALayer平时大家也很常见,比如简单的设置个圆角,或者边线等操作都会用到。CALayer类在概念上和UIView类似,也是一些被层级关系树管理的矩形块,也可以包含一些内容,并且管理子视图的位置。

UIView最大的区别是CALayer不能处理用户的操作交互

CALayer不清楚具体的响应链,但是它提供了一些方法来判断是否某个触点在某个图层范围内。

1.3 平行的层级关系

每个UIView都对应着一个CALayer,视图的职责是创建并管理这个图层,以确保党子视图在层级关系中添加或者被移除的时候,他们对应的图层也同样的在对应的层级关系树中有相同的操作。

真正用来在屏幕上显示的是图层(CALayer),UIView是对它的一个封装,提供一些交互触摸功能,和一些Core Animation底层的接口。

iO S提供UIViewCALayer两个平行的层级关系,应该也是为了解耦,做职责分离。 以便能适应 iOS 和 Mac OS 的系统。

对于简单的需求我们无需深入了解CALayer使用UIView就很方便灵活了。但是有时候我们只使用UIView还是会有些捉襟见肘的,CALayer暴露了一些UIView没有提供的功能:

  • 阴影、圆角、边框
  • 3D变换
  • 非矩形范围
  • 透明遮罩
  • 非线性动画

2.寄宿图

2.1 contents属性

CALayer有一个属性叫做contents,这个属性是id类型的,可以是任何类型的对象。也即是意味着在写代码的时候,可以给contents赋任何值(显示不显示是另一回事)。只有赋CGImage的时候才能正确显示。

contents 这个奇怪的表现是由 Mac OS 的历史原因造成的,因为在 Mac OS 系统上,这个属性对 CGImageNSImage 类型的值都起作用。但是在 iOS上,如果将 UIImage 的值赋给它,只能得到一个空白的图层。

事实上,真正赋值的类型应该是CGImageRef,这是一个指向CGImage结构的指针。UIImage有一个CGImage属性,它返回一个CGImageRef,但是这个值不能直接赋值给CALayercontents,因为CGImageRef不是一个真正的Cocoa对象,而是Core Foundation类型。

Core FoundationCocoa对象是不兼容的,可以通过bridged转换:

layer.contents = (__bridge id)image.CGImage;
2.1.1 示例

既然CALayercontents可以赋值各种类型,我们可以尝试一下用CALayer实现UIImageView的效果。代码如下:

@implementation ViewController

- (void)viewDidLoad {
  [super viewDidLoad];
  self.view.backgroundColor = [UIColor lightGrayColor];
  
  UIView *layerView = [[UIView alloc] initWithFrame:CGRectMake(100, 200, 50, 100)];
  layerView.backgroundColor = [UIColor whiteColor];
  [self.view addSubview:layerView];
  
  
  UIImage *image = [UIImage imageNamed:@"test"];
  layerView.layer.contents = (__bridge id)image.CGImage;
}

运行一下,效果如下:

虽然可以实现类似UIImageView的显示效果,但平常并不推荐使用这种方法。

2.1.2 contentGravity

上面示例的图片有点扁,因为我们设置的frame是个长方形,而图片本身是一个正方形。所以被挤压了。平时使用UIImageView时遇到类似情况,可以设置contentMode来解决。同样:

  layerView.contentMode = UIViewContentModeScaleAspectFill;

这样就可以解决了。

UIView大多数视觉相关的属性比如contentMode,对这些属性的操作其实是对对应图层的操作。
CALayercontentMode对应的属性叫做contentsGravity,这是一个NSString类型,而UIKit部分是枚举。contentsGravity可选的常量值有如下:

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

contentMode一样, contentsGravity目的是决定内容在图层中怎么对齐,将上面设置contentMode的代码可以替换如下:

  layerView.layer.contentsGravity = kCAGravityResizeAspectFill;

运行后的效果是一致的。

2.1.3 contentsScale

contentsScale属性定义了寄宿图的像素尺寸和视图大小的比例,默认情况下是一个1.0的浮点数。
contentsScale并不是总会对寄宿图的效果有影响,因为contents设置了contentsGravity属性,导致经常设置了contentsScale却没反应。

如果单纯的想放大图层的 contents图片,可以使用图层的 transformaffineTransform

contentsScale其实属于支持高分辨率屏幕机制的一部分,是用来判断在绘制图层的时候应该为寄宿图创建的空间大小,和需要显示的图片拉伸度(假设没有设置contentsGravity)。UIView有一个类似但是很少用的contentScaleFactor属性。
如果contentsScale设置为1.0,将会以每个点1个像素绘制图片,如果2.0,则以每个点2个像素绘制图片(这就是Retina屏)。
修改contentsScale并不会对我们使用kCAGravityResizeAspectFill有影响,因为kCAGravityResizeAspectFill就是拉伸图片适应图层而已。但是如果把contentsGravity设置成kCAGravityCenter(这个值不会拉伸图片),变化见下图:

如图所示,图片会变的有点大,而且有像素的颗粒感。因为CGImageUIImage不一样,它没有拉伸的感念。用UIImage读取图片时,读取了高质量的Retina图片。但用CGImage设置的时候,拉伸的概念就被丢失了,不过可以手动设置contentsScale来做到同样效果:

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

现在效果如下:


为了突出layerView的存在感,我把layerViewframe调整到CGRectMake(100, 200, 100, 150)

2.1.4 maskToBounds

看上面最新的运行图,发现图片超出了视图的边界。因为默认情况下,UIView仍会绘制超过边界的内容,在CALayer也不例外。
UIView有个clipsToBounds属性来决定是否显示超出边界的内容。CALayer对应的属性叫做maskToBounds,把它设置成YES就可以不显示超出部分的图片了。

2.1.5 contentsRect

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

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

上图设置的 contentsRect{0, 0, 0.5, 0.5}

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

contentsRect在 App 中最有趣的地方可以用作 image sprites(图片拼合)。图片拼合后可以打包到一张大图上一次载入,相比多次载入不同的图片,这样做的性能更优。

2.1.6 图片拼接代码示例:

@interface ViewController ()
@property (weak, nonatomic) IBOutlet UIView *view1;
@property (weak, nonatomic) IBOutlet UIView *view2;
@property (weak, nonatomic) IBOutlet UIView *view3;
@property (weak, nonatomic) IBOutlet UIView *view4;


@end

@implementation ViewController

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

- (void)viewDidLoad {
  [super viewDidLoad];
  
  UIImage *image = [UIImage imageNamed:@"test_1"];
  [self addSpriteImage:image withContentRect:CGRectMake(0, 0, 0.5, 0.5) toLayer:self.view1.layer];

  [self addSpriteImage:image withContentRect:CGRectMake(0.5, 0, 0.5, 0.5) toLayer:self.view2.layer];

  [self addSpriteImage:image withContentRect:CGRectMake(0, 0.5, 0.5, 0.5) toLayer:self.view3.layer];

  [self addSpriteImage:image withContentRect:CGRectMake(0.5, 0.5, 0.5, 0.5) toLayer:self.view4.layer];
}

运行的效果如下:

本来原文是用四张不同的图做拼接,我只是展示下这种功能实现,所以偷懒只用了一张图片。如果有不解之处请看原文

2.1.7 contentsCenter

contentsCenter看名字大部分人会误以为是和位置有关,其实它是一个CGRect。它定义了一个苦丁的边框和在图层上可拉伸的区域。
默认情况下,contentsCenter{0, 0, 1, 1},意味着如果大小改变(contentsGravity),寄宿图会被均匀的拉伸。
假设我们增加原点的值,并减小尺寸的值,例如将它变为{0.25, 0.25, 0.5, 0.5}将会在寄宿图周围留出一个边框。如下图:


上图是借用原书的图。

这效果看起来和UIImage里的resizableImageWithCapInsets:非常类似,它可以运用到任何寄宿图,包括在Core Graphics运行时绘制的图形。


同一图片使用不同的 contentsCenter

contentsCenter使用起来也很方便,可以用代码:

layer.contentsCenter = CGRectMake(0.25, 0.25, 0.5, 0.5);

也可以在XIB里面设置:

2.2 Custom Drawing

除了给contents赋值CGImage来设置寄宿图之外,还可以直接用Core Graphics来绘制寄宿图。

-drawRect: 通过继承UIView来实现此方法进行自定义绘制。这个方法默认是没有被实现的。因为对于UIView来说,寄宿图不是必须的。如果UIView检测到-drawRect:被调用,会自动给视图分配一个寄宿图。这个寄宿图的像素尺寸等于视图大小乘以contentsScale

如果你不需要寄宿图,不要写这个方法,会造成资源浪费,详细部分见 《内存恶鬼drawRect》

视图在屏幕上出现的时候-drawRect:会自动被调用。-drawRect:方法里面的代码利用Core Graphics绘制一个寄宿图,然后被缓存起来直到需要被更显(一般是调用了- setNeedDisplay方法)。

CALayer有一个可选的delegate属性,当CALayer需要内容的时候,会从这个delegate里面查询。
当需要被重绘时,CALayer会从下面这个代理方法请求一个寄宿图来展示:

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

如果这个方法没有被实现,CALayer会尝试下面这个:

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

drawLayer:被调用之前,CALayer创建了一个合适尺寸的寄宿图(尺寸由boundscontentsScale决定)和一个Core Graphics的绘制上下文环境,并作为ctx传入。

2.2.1示例:

下面我们使用CALayerDelegate是做个示例。

- (void)viewDidLoad {
  [super viewDidLoad];
  
  UIView *layerView = [[UIView alloc] initWithFrame:CGRectMake(100, 200, 150, 150)];
  layerView.backgroundColor = [UIColor lightGrayColor];
  [self.view addSubview:layerView];
  
  CALayer *blueLayer = [CALayer layer];
  blueLayer.frame = CGRectMake(50.0f, 50.0f, 100.0f, 100.0f);
  blueLayer.backgroundColor = [UIColor blueColor].CGColor;
  
  blueLayer.delegate = self;
  
  blueLayer.contentsScale = [UIScreen mainScreen].scale;
  [layerView.layer addSublayer:blueLayer];
  //
  [blueLayer display];
}
- (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);
}

  • blueLayer上显式调用了-display。因为当图层显示在屏幕上时,CALayer不会自动重绘,这和UIView不同。需要手动调用。
  • 我们没有调用masksToBounds。但是绘制的圆仍然被裁剪了。这是因为我们在CALayerDelegate方法中,没有对超出边界歪的内容提供绘制支持。

除非创建一个单独的图层,我们平时基本不会用到CALayerDelegate。因为UIView在创建时,会自动的吧图层的代理设置为自己,然后提供了一个-displayLayer:方法实现。


- 系列一完 -

你可能感兴趣的:(iphone,ios,objective-c)