核心动画Core Animation,其实是由Layer Kit这样一个名字演变而来。它实际上是一个复合引擎,可以将存储在图层树体系中的不同独立图层,尽可能快地组合成不同的可视内容呈现于屏幕上;所以做动画只是Core Animation的特性之一;
Core Animation直接作用于CALayer上,而图层树又是形成了UIKit以及我们在iOS应用程序所能在屏幕上看见一切的基础。因此,在讨论动画之前,我们有必要对于图层这一概念进行深入的理解。
本篇主要内容: 1.理解视图与图层 2.CALyer寄宿图与contents属性 3.UIView方法绘制自定义寄宿图 4.CALyer方法绘制自定义寄宿图 5.Frame与Bounds的区别 6.中心点(position)与锚点(anchorPoint) 7.视图与图层的坐标系
UIView我们都非常熟悉, 但它其实是对于CALayer的一层封装,我们在创建UIView时,其内部会自动创建CALayer图层对象(即UIView的关联图层),UIView调用drawRect:方法进行绘图,并且将所有的内容绘制到自己的图层上,绘制完毕后,系统会将图层拷贝到屏幕上,于是就完成了UIView显示。
视图的的职责就是创建并管理这个图层,以确保子视图在层级关系中添加或者被移除的时候,它们的关联图层也同样对应在层级关系树当中有相同的操作。我们在访问UIView的frame,bounds等属性又或者设置动画,其实也都是在操作其关联图层CALayer的特性。
但是,UIView因为继承了UIResponder而具备响应事件的能力;而CALayer并不清楚具体的响应者链(iOS通过视图等级关系用来传送触摸事件的机制),于是它并不能响应事件,即使它也提供一些方法来判断是否一个触点在图层的范围之内。
最后,总结UIView(视图)与CALayer(图层)的关系:UIView = CALayer(负责绘制显示内容的功能) + 处理用户交互的功能。
下面的图示很好的展示了UIView与CALayer的底层上的区别:
图层与视图的底层关系.png
UIView、UIColor、UIImage都定义于UIKit框架中; CALayer定义在QuartzCore框架中的CoreAnimation中; CGImageRef、CGColorRef两种数据类型是定义在Core Graphics框架中;
QuartzCore框架和CoreGraphics框架可以跨平台使用,在iOS和Mac OS上都能使用 ,但是UIKit却只能在iOS中使用;为了保证可移植性,QuartzCore是不能直接使用UIImage和UIColor的,如果使用需要将其转化为CGImageRef、CGColorRef
使用图层十分简单,区别在于图层必须添加到图层上,具体代码如下:
- (void)viewDidLoad {
[super viewDidLoad];
CALayer *colorLayer = [CALayer new];
colorLayer.backgroundColor = [UIColor orangeColor].CGColor;
colorLayer.frame = CGRectMake(30, 30, kDeviceWidth -60, 200);
[self.view.layer addSublayer:colorLayer];
}
苹果为我们提供了简洁方便的UIView的接口,而且为UIView增加了处理触摸事件的能力,但这种简单的设计也不可避免带来灵活上的缺陷,如果我们需要在底层做一些改变,或者使用一些没有在UIView上实现的接口功能,此时就需要我们介入Core Amimation底层了。 下面是一些UIView没有暴露出来的CALayer的功能:
CALayer具有和UIView一样的层级关系树,可用于显示一个矩形块。但事实上它还通过contents属性包含并显示一张图片,称之为CALayer的寄宿图。CALayer的contents属性虽被定义为id,但是真正可以被赋值的类型是CGImageRef,指向的是一个CGImage结构的指针。
在Mac OS系统上,contents属性对于CGIamge和NSImage类型的值都起作用;而对于iOS平台,虽然UIImage的CGImage属性也返回一个CGImageRef,但如果将这个值直接赋值给CALayer的contents,却会得到一个编译错误。这是因为CGImageRef并不是一个真正的Cocoa对象,而是一个Core Foundation类型;
具体解决方法就是使用bridged关键字,下面是用于演示的代码:
- (void)viewDidLoad {
[super viewDidLoad];
UIView *colorView = [UIView new];
colorView.backgroundColor = [UIColor orangeColor];
colorView.frame = CGRectMake(30, 30, kDeviceWidth -60, 200);
[self.view addSubview:colorView];
UIImage *headerImage = [UIImage imageNamed:@"header"];
colorView.layer.contents = (__bridge id)headerImage.CGImage;
}
效果图如下:
测试CALayer寄宿图1.png
我们没有通过UIImageView的方法,而是直接利用CALaye显示了一张图片。这似乎很酷,但惊喜之余,我们也发现了仍然存在的小缺憾,那就是此时的图片显示效果是变形的;那它是否也可以像UIImageView一样具有可设置的方法呢,答案是肯定的,我们可以使用如下的代码,将图片自适应显示:
colorView.layer.contentsGravity = kCAGravityResizeAspect;
效果图如下:
测试CALayer寄宿图2.png
另外,类似的对于CALayer的显示设置和UIView具有下面的对应关系(这里仅简单总结概念和用处):
CALayer属性 | UIView属性 | 属性说明 |
---|---|---|
contentsGravity(NSString) | contentMode (枚举) | 内容填充方式(填满、自适应等) |
contentsScale(CGFloat) | contentScaleFactor(CGFloat) | 像素尺寸和视图大小的比例,默认1.0;1.0:以每个点1个像素绘制图片;2.0:以每个点2个像素绘制图片,Retina屏幕; |
maskToBounds(BOOL) | clipsToBounds(BOOL) | 超出边界的内容或者子视图是否显示 |
contentsRect (CGRect) | 允许在图层边框里显示寄宿图的一个子域 | |
contentCenter (CGRect) | 定义一个固定的边框和一个在图层上可拉伸的区域 |
给contents赋值CGImage的值并不是唯一设置寄宿图的方法,我们也可以直接使用Core Graphics直接绘制寄宿图,即通过继承UIView并实现-drawRect:的方式。
-drawRect:方法是UIView没有默认实现的方法,因为寄宿图并不是必须的;但如果UIView检测到此方法被实现了,此方法会被自动调用,然后我们就可以在其中使用Core Graphics绘制自己需要的内容了;下面的代码就演示了drawRect自定义绘制寄宿图的具体操作,实现了一个环形的绘制:
@implementation TestLayerVC
- (void)viewDidLoad {
//测试drawRect自定义绘制寄宿图
CustomCircleView *customCircleView = [CustomCircleView new];
customCircleView.frame = CGRectMake((kDeviceWidth - 100)/2, 250, 100 , 100);
[self.view addSubview:customCircleView];
}
@end
@implementation CustomCircleView
- (instancetype)initWithFrame:(CGRect)frame
{
self = [super initWithFrame:frame];
if (self) {
//使用drawRect,默认背景色为黑色;以下两种方式解决:
// self.opaque = NO;
self.backgroundColor = [UIColor purpleColor];
}
return self;
}
- (void)drawRect:(CGRect)rect{
//获取画布
CGContextRef context = UIGraphicsGetCurrentContext();
//画笔颜色
CGContextSetStrokeColorWithColor(context, [UIColor redColor].CGColor);
//画笔宽度
CGFloat lineWidth = 5;
CGContextSetLineWidth(context, lineWidth);
//圆点坐标
CGFloat centerX = CGRectGetWidth(rect)/2.0;
CGFloat centerY = CGRectGetHeight(rect)/2.0;
CGFloat cusRadius = self.frame.size.width/2.0 - lineWidth/2.0;
double PI = 3.14159265358979323846;
//绘制路径:初始角度、结束角度
CGContextAddArc(context, centerX, centerY, cusRadius, 1.5*PI, 1.5*PI + 2*PI, NO);
CGContextDrawPath(context, kCGPathStroke);
}
绘制效果如下:
自定义绘制寄宿图1.png
特别注意1:如果没有自定义绘制任务不需要寄宿图,就不要在子类中写一个空的-drawRect:方法,否则会造成CPU资源和内存的浪费; 特别注意2:如果我们将绘制过程的角度参数改为动态,并结合定时器调用-setNeedsDisplay方法,就可以实现环形动画的效果(这里就不做具体演示了);
虽然-drawRect:方法是实现了自定义寄宿图绘制,但事实上还是底层的CALayer重绘并保存了因此产生的图片;CALayer有一个可选的delegate属性,实现了CALayerDelegate非正式协议,当CALayer需要一个内容特定信息时,就会从协议中请求;而当需要被绘制时,CALayer会通过如下的方法来请求代理给它提供寄宿图;
//方法1:可以直接设置contents属性;
- (void)displayLayer:(CALayer *)layer;
//方法2:在不实现方法1时,CALayer就会转而尝试调用此的方法;
- (void)drawLayer:(CALayer *)layer inContext:(CGContextRef)ctx;
在调用方法2之前,CALayer会创建了一个合适尺寸的空寄宿图(尺寸由bounds和contentScale决定)和一个Core Graphics的绘制上下文环境,为绘制寄宿图做准备,并将其以ctx参数传入。现在我们以方法2为例,演示CALayer绘制自定义寄宿图的过程,具体代码如下:
@implementation TestLayerVC
- (void)viewDidLoad {
CALayer *blueLayer = [CALayer layer];
blueLayer.frame =CGRectMake((kDeviceWidth - 100)/2, 400, 100 , 100);
blueLayer.backgroundColor = [UIColor purpleColor].CGColor;
blueLayer.delegate = self;
blueLayer.contentsScale = [UIScreen mainScreen].scale;
[self.view.layer addSublayer:blueLayer];
[blueLayer display];
}
- (void)drawLayer:(CALayer *)layer inContext:(CGContextRef)ctx{
CGContextSetLineWidth(ctx, 10.f);
CGContextSetStrokeColorWithColor(ctx, [UIColor redColor].CGColor);
CGContextStrokeEllipseInRect(ctx, layer.bounds);
}
@end
效果图如下:
自定义绘制寄宿图2.png
代码分析: 1. 主动绘制 我们需要显式的调用-display方法;这不同于UIView,当图层显示到屏幕上时,CALayer不会自动重绘它的内容,CALayer把重绘的决定权交给了开发者;
2.绘制特点 尽管没有使用masksToBounds属性,但示例中绘制的视图依然被裁剪了,这是因为通过CALayer绘制寄宿图并没有对超出边界外的内容提供绘制支持;
3.设置代理 CALayerDelegate不能是UIView和UIViewController,如上述代码的演示就会造成崩溃; UIView本身携带的layer的代理就是自己,如果将一个layer的代理设置成它,那它本身的layer就会受到影响,通常表现为野指针崩溃;而UIViewController在经历Push和Pop之后也可能被释放,造成野指针崩溃;所以,对于这个问题的解决方案是:创建继承于NSObject的类,用于实现CALayerDelegate并管理CALayer的绘制逻辑;
使用总结:当我们需要自定义寄宿图时,其实不必实现displayLayer:和-drawLayer: inContext:方法来绘制寄宿图。通常的做法还是实现UIView的-drawRect:方法,这样UIView就会自动帮我们做完剩下的工作,包括需要重绘的时候调用-display方法;
我们已经知道UIView的很多布局属性其实都来自于图层;UIView的布局属性包括:frame、bouns、center,分别对应了CALayer中frame、bounds、position。为了能清楚区分,图层用了position,视图用了center,但它们都代表了同样的值,另外CALayer的锚点(anchorPoint)没有在UIView中公开。
UIView属性 | CALayer属性 | 属性说明 |
---|---|---|
frame | frame | 表示相对于其父视图的坐标位置 |
bounds | bounds | 表示相对于其自身的坐标位置,{0,0}通常是其左上角 |
center | position | 表示父图层所在锚点AnchorPoint所在的位置 |
frame&&bounds.png
上图对原有视图做了旋转变换,之后的frame实际上代表了覆盖在图层旋转之后的整个轴对齐的矩形区域,此时frame的宽和高和bounds不再一致了。
其实,对于视图和图层来说,frame是根据bounds、position、和transform计算而来的;所以当其中的任何一个值发生变化时,frame就会发生变化,相反改变frame也同样影响他们当中的值。
position与anchorPoint是两个容易混淆的概念,我们首先从Xcode中找到关于它们的注释说明如下:
/* The position in the superlayer that the anchor point of the layer's
* bounds rect is aligned to. Defaults to the zero point. Animatable. */
@property CGPoint position;
/* Defines the anchor point of the layer's bounds rect, as a point in
* normalized layer coordinates - '(0, 0)' is the bottom left corner of
* the bounds rect, '(1, 1)' is the top right corner. Defaults to
* '(0.5, 0.5)', i.e. the center of the bounds rect. Animatable. */
@property CGPoint anchorPoint;
我们可以看出,position被用于描述当前layer在superlayer中的位置,而且是通过当前layer的anchorPoint来确定的。换句话来讲就是:position是当前layer的anchorPoint在superLayer中的位置。
我们也可以更确切理解为:position是相对于superLayer来讲,而anchorPoint是相对于当前layer来讲;只不过在默认情况下,anchorPoint与position是重合的;锚点是用单位坐标来描述的(即图层的相对坐标),图层的左上角是{0,0},右下角是{1,1},因此图层的默认锚点是{0.5, 0.5},表示图层的中间位置代表了其位置position。
下面的图示是将锚点从{0.5,0.5}改为了{0,0},我们在这里更容易看到position与anchorPoint之间的关系:
anchorPoint.png
如图,修改图层锚点会改变layer的frame,但是其position不会改变,这看起来似乎有点奇怪,但是我们依然可以通过一些计算方式看出端倪:
position.x = frame.origin.x + 0.5 * bounds.size.width;
position.y = frame.origin.y + 0.5 * bounds.size.height;
这里的0.5参数,其实就是由于锚点默认值得到的,所以改进公式如下:
position.x = frame.origin.x + anchorPoint.x * bounds.size.width;
position.y = frame.origin.y + anchorPoint.y * bounds.size.height;
此时,我们如果通过修改anchorPoint的值来进行测试,就会发现改变的只有frame的origin,这就说明修改position与anchorPoint中任何一个属性都不能影响另一个属性,由此我们也可以再次改进公式:
frame.origin.x = position.x - anchorPoint.x * bounds.size.width;
frame.origin.y = position.y - anchorPoint.y * bounds.size.height;
最后得出结论:frame的origin坐标由position与anchorPoint来共同决定;
锚点就相当于一个支点,可以形象的理解为一颗固定了图层的图钉,尤其是我们在做旋转动画时,可能会需要设置此属性来决定图层是围绕哪一个点旋转的;但这时候我们又不得不考虑一个问题:修改锚点可以让我们的动画围绕非中心点旋转,但是这也改变了原有视图的位置frame,这是我们不想要的结果,该如何解决呢?这里提供一种方法如下:
- (void)setAnchorPoint:(CGPoint)anchorPoint forView:(UIView *)view{
CGPoint oldOrigin = view.frame.origin;
view.layer.anchorPoint = anchorPoint;
CGPoint newOrigin = view.frame.origin;
CGPoint transition;
transition.x = newOrigin.x - oldOrigin.x;
transition.y = newOrigin.y - oldOrigin.y;
view.center = CGPointMake (view.center.x - transition.x, view.center.y - transition.y);
}
下面再来具体演示一下修改锚点改变动画状态的用法,我们分别创建橙色视图默认围绕中心旋转,而紫色视图围绕左顶点旋转,关键代码如下:
#import "TestLayerFiveVC.h"
@interface TestLayerFiveVC ()
@property (nonatomic,strong) UIView *viewA;
@property (nonatomic,strong) UIView *viewB;
@end
@implementation TestLayerFiveVC
- (void)viewDidLoad {
[super viewDidLoad];
[self.view addSubview:self.viewA];
[self.view addSubview:self.viewB];
[self.viewA mas_makeConstraints:^(MASConstraintMaker *make) {
make.top.equalTo(self.view).offset(100);
make.centerX.equalTo(self.view);
make.width.height.mas_equalTo(100);
}];
[self.view addSubview:self.viewB];
[self.viewB mas_makeConstraints:^(MASConstraintMaker *make) {
make.top.equalTo(self.viewA.mas_bottom).offset(200);
make.centerX.equalTo(self.view);
make.width.height.mas_equalTo(100);
}];
[self.view layoutIfNeeded];
//ViewA的旋转动画
[self addRotationAnimation:self.viewA withDuration:3];
//修改ViewB的锚点,并恢复其原先的Frame,使其可以绕着左上角顶点旋转
[self resetAnchorPoint:CGPointMake(0, 0) forView:self.viewB];
[self addRotationAnimation:self.viewB withDuration:3];
}
- (void)resetAnchorPoint:(CGPoint)anchorPoint forView:(UIView *)view{
CGPoint oldOrigin = view.frame.origin;
view.layer.anchorPoint = anchorPoint;
CGPoint newOrigin = view.frame.origin;
CGPoint transition;
transition.x = newOrigin.x - oldOrigin.x;
transition.y = newOrigin.y - oldOrigin.y;
//重新设置原来视图位置
view.center = CGPointMake (view.center.x - transition.x, view.center.y - transition.y);
[view mas_remakeConstraints:^(MASConstraintMaker *make) {
make.top.equalTo(view.superview).offset(view.center.y - transition.y);
make.leading.equalTo(view.superview).offset(view.center.x - transition.x);
make.width.mas_equalTo(view.width);
make.height.mas_equalTo(view.height);
}];
}
- (void)addRotationAnimation:(__kindof UIView *)view withDuration:(CFTimeInterval)dutation {
CABasicAnimation *rotationAnimation = [CABasicAnimation animationWithKeyPath:@"transform.rotation.z"];
rotationAnimation.toValue = [NSNumber numberWithFloat: -M_PI * 2.0];
rotationAnimation.duration = dutation;
rotationAnimation.cumulative = YES;
rotationAnimation.repeatCount = MAXFLOAT;
rotationAnimation.removedOnCompletion = NO;
[view.layer addAnimation:rotationAnimation forKey:@"rotationAnimation"];
}
效果图如下:
锚点动画.gif
CALayer给不同坐标系之间的图层转换提供了一些工具类方法:
- (CGPoint)convertPoint:(CGPoint)p fromLayer:(nullable CALayer *)l;
- (CGPoint)convertPoint:(CGPoint)p toLayer:(nullable CALayer *)l;
- (CGRect)convertRect:(CGRect)r fromLayer:(nullable CALayer *)l;
- (CGRect)convertRect:(CGRect)r toLayer:(nullable CALayer *)l;
与此对应的UIView也具有相似的方法如下:
- (CGPoint)convertPoint:(CGPoint)point toView:(nullable UIView *)view;
- (CGPoint)convertPoint:(CGPoint)point fromView:(nullable UIView *)view;
- (CGRect)convertRect:(CGRect)rect toView:(nullable UIView *)view;
- (CGRect)convertRect:(CGRect)rect fromView:(nullable UIView *)view;
通过这些方法,我们可以把定义在一个图层(或视图)坐标系下的点或者矩形转换为另一个图层(或视图)坐标系下的点或者矩形;开发过程中我们通常操作的对象都是视图,所以下面以视图为例简单演示其用法:首先创建添加两个宽高都是100*100的橙色、紫色视图在控制器的View上,
坐标系.png
使用下面的代码进行测试,结果如下:
CGPoint targetPoint = CGPointMake(10, 10);
CGPoint point1 = [purpleView convertPoint:targetPoint toView:orangeView]; //代码1
CGPoint point2 = [orangeView convertPoint:targetPoint fromView:purpleView];
NSLog(@"\npoint1:%@\npoint2:%@",NSStringFromCGPoint(point1),NSStringFromCGPoint(point2));
/*测试结果:
point1:{160, 60}
point2:{160, 60}
*/
代码分析: 这里分别测试了convertPoint的两种用法(convertRect与其相似),我们可以将代码1理解为:参考organView为坐标系时,purpleView上坐标为target的点的坐标值;