iOS CALayer图层漫谈(四)

上一篇《iOS CALayer图层漫谈(三)》我们聊了CALayer视觉效果的一些知识,这一篇呢我们开始聊一下CALayer的变换的一些知识,这一部分很多地方可能比较难以理解,所以在这过程中希望大家多动手实践一下。

CALayer的仿射变换

在前面文章中我们曾提到过一个虚拟时钟实现的例子,例子中我们通过UIView的transform属性实现时钟指针的旋转。那么接下来我们就来详细的聊一聊transform这个属性。

transform属性是用作于二维空间旋转、缩放和平移的这样一个属性,为CGAffineTransform类型,实质是一个可以和二维空间向量做乘法的3*2矩阵。说到矩阵可能有人会说对矩阵知识掌握的并不是很多,不要害怕,Core Graphics为我们提供了一系列的函数,即使没有数学基础也可以进行一些相应的变换操作。

下面给大家列出几个用来创建CGAffineTransform对象的函数:

CGAffineTransformMakeRotation(CGFloat angle);
CGAffineTransformMakeScale(CGFloat sx, CGFloat sy);
CGAffineTransformMakeTranslation(CGFloat tx, CGFloat ty);

上面三个函数分别对应:旋转、缩放和平移。旋转是指旋转向量的角度,缩放是缩放一个向量的值的大小,平移则是每个点都向向量方向移动向量长度的距离。

下面我们来看一下通过这几种变换方式变换后的实际效果:

view.transform = CGAffineTransformMakeScale(0.5, 0.5);
view.transform = CGAffineTransformMakeRotation(M_PI_4);
view.transform = CGAffineTransformMakeTranslation(100, 100);


其实我们通过UIView的transform属性进行的变换,只是封装了内部layer图层的变换而得到的。CALayer也有一个同名的transform属性,不过类型是CATransform3D而不是CGAffineTransform,而且所用法也有所差异,这个属性后面我们也会着重聊。

其实CALayer真正对应于UIView的transform的属性叫做affineTransform,它同样使用的是CGAffineTransform类型,所以我们可以将上面的代码改写成下面这样,并且呈现出的效果都是相同的。

view.layer.affineTransform = CGAffineTransformMakeScale(0.5, 0.5);

view.layer.affineTransform = CGAffineTransformMakeRotation(M_PI_4);

view.layer.affineTransform = CGAffineTransformMakeTranslation(100, 100);

除了上面进行的一些单一的变换操作外,我们还可以进行一些更为复杂的混合变换,比如在旋转的基础上再进行缩放,等等...,下面这几个函数就为我们提供了进行混合变换的功能:

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

上面几个函数通过一个已有的CGAffineTransform转换对象生成一个新的转换对象,在原有变换的基础上混合入新的变换,这样图层就可以依次进行多种变换。除此之外我们还可以通过:

CGAffineTransformConcat(CGAffineTransform t1, CGAffineTransform t2);

将两个CGAffineTransform对象直接进行混合。

除了进行各种变换外,还有一个初始状态值也很重要,那就是CGAffineTransformIdentity,这个值可以指定视图(或图层)回到初始状态下。

下面我们通过一个例子来实现一个复杂的变换:首先让图层旋转45°,再缩小50%,然后沿向量(100,100)方向,平移向量长度的距离

CGAffineTransform transform = CGAffineTransformMakeRotation(M_PI_4);
transform = CGAffineTransformScale(transform, 0.5, 0.5);
transform = CGAffineTransformTranslate(transform, 100, 100);
view.layer.affineTransform = transform;


通过最后呈现的效果来看,好像跟我们想象中的最终位置不太一样,图层并没有像我们想象的那样向右下角平移,而是向正下方平移了。为什么会这样呢?因为旋转会改变视图(或图层)的方向体系,当旋转45°后,相对于当前视图的右下角方向,变成了垂直向下的方向了。所以在进行混合变换的时候,尤其在含有旋转变换的时候,一定要注意混合的逻辑顺序。

调整一下混合变换的逻辑顺序,我们再来看一下:

CGAffineTransform transform = CGAffineTransformMakeScale(0.5, 0.5);
transform = CGAffineTransformTranslate(transform, 100, 100);
transform = CGAffineTransformRotate(transform, M_PI_4);
view.layer.affineTransform = transform;
变成了我们想要的效果

CALayer的3D变换

从上面聊的CGAffineTransform类型的前缀CG可以看出,它是属于Core Graphics框架的,其实Core Graphics框架严格意义上来说是一个二维绘图框架。在前面文章我们也提到过,CALayer其实是存在于三维空间中的,所以除了能实现我们前面介绍的一些二维空间的变换外,CALayer是可以进行三维空间变换的,也就是3D变换。

在介绍3D变换之前,我们又不得不提到矩阵,3D变换其实也是通过矩阵运算实现的,而三维空间变换利用的则是4*4矩阵,同样,即使你矩阵的知识基础,系统依然为我们提供了一套简便的API接口,让我们轻而易举的实现3D变换效果。

CATransform3DMakeRotation(CGFloat angle, CGFloat x, CGFloat y, CGFloat z);
CATransform3DMakeScale(CGFloat sx, CGFloat sy, CGFloat sz);
CATransform3DMakeTranslation(CGFloat tx, CGFloat ty, CGFloat tz);

其实从函数的参数中可以看出,平移和缩放多出了一个z参数,而旋转则多出了xyz三个参数,这三个参数分别决定图层在对应坐标轴上的旋转量。

我们知道iOS系统中x、y轴分别指向正右、指向正下,z轴的正方向为垂直屏幕指向用户方向:



二维空间的旋转其实是围绕z轴进行的,但是当我们的旋转是围绕x、y轴进行的时候就突破了屏幕的二维空间,并在用户视角看来是发生了倾斜。下面我们就来试着对图层绕y轴做一个45°的旋转

view.layer.transform = CATransform3DMakeRotation(M_PI_4, 0, 1, 0);

但图层看起来好像并没有被旋转,仅仅是在水平方向上压缩了。没错,因为我们现在看到的是一个投影效果,并不是像实际中那样是以一个斜向的视角去看它,因为在真实世界中,由于透视的原因物体离我们远的边要比离我们近的边要短。

那如何实现我们想看到效果呢?这里CATransform3D矩阵中的m34元素起到了决定性作用,m34通过比例缩放xy的值来确定图层离视角有多远。m34的默认值是0,我们通过设置为-1.0/d来应用透视效果,d代表了想象中的视角和屏幕之间的距离,单位为像素,而这个值我们无需给定一个准确值,大概估算一个就好了,通常取值范围为500 ~ 1000

修改一下代码,我们重新看一下效果:

CATransform3D transform3D = CATransform3DIdentity;
transform3D.m34 = -1.f /500.f;
transform3D = CATransform3DRotate(transform3D, M_PI_4, 0, 1, 0);
view.layer.transform = transform3D;
我们想要的仿真效果

灭点的概念

灭点说起来是一个很理论性的概念,但是在我们现实世界中这是一个无时无刻不存在于我们视觉中的一个点。我们知道当我们远离一个物体的时候,物体在我们的视野中会越变越小,直到缩小成一个点,最后所有物体都汇聚消失在同一个点,这个点就是灭点。


当我们要在应用中实现现实的效果的时候,这个点通常是在视图的中心,至少是包含所有3D对象的视图中。Core Animation定义了灭点位于图层的anchorPoint位置,并且当我们对图层进行变换的时候,是不影响这个点的位置的,但是我们前面聊过,改变一个图层的position是可以改变锚点相对于父图层的位置的,此时灭点会随着anchorPoint而改变位置。所以当我们向三维空间摆放一个图层并希望它具有真实的空间效果的时候,我们不应该通过position属性来调整它的位置,而是应该先将它摆放到屏幕的中央,再通过平移使它到达指定位置,因为这样才能保证它的锚点(以及我们关心的灭点)在屏幕的中央。

但当我们的视图或图层多起来的时候,让所有视图或图层的灭点都集中在屏幕中央,实现起来就很困难了,还好CALayer为我们提供了一个属性叫做sublayerTransform,它同样是CATransform3D类型,但和transform不同,它会影响到所有子图层,这意味着你可以一次性的对一个图层以及它所包含的所有子图层进行整体的变换,这样也解决了我们需要分别调整灭点的重复操作。

CALayer的背面

上面的例子我们将图层围绕y轴旋转45°,如果我们旋转180°去到图层的背面图层将会怎样显示呢?


我们看到的是图层翻转180°后的镜像。虽然这符合作为单一图层在三维空间的呈现效果,但当我们在三维空间绘制一个不透明固体(后面会提到)并进行旋转的时候,就会产生不必要的开销,因为每个图层只作为立方体的一个面只需展示出它的正面就可以了,而埋藏在立方体中的背面我们永远是看不到的,所以我们不需要来绘制每个图层背面,而增加不必要的开销。因此CALayer提供了一个专门的属性用来控制是否进行反面绘制,这个属性叫做doubleSided,默认为YES。当我们将它设置为NO,图层正面消失之后将不再绘制它的背面。

旋转抵消问题

在现实生活中有这样一个场景:我们在跑步机上跑步,跑步机旋转的同时我们也在奔跑,但是我们相对于地面是静止的,这就是一个相对抵消的现象。本质是我们的参考系不同。在图层的旋转中也涉及相对抵消的问题,当一个含有子图层的图层在二维平面中顺时针旋转45°,它的子图层逆时针旋转45度的时候,子图层相对于屏幕来说并没有发生偏转,因为它本身的逆时针旋转被父图层的顺时针旋转抵消掉了。就像下图中的黑色子图层本来是逆时针旋转45°,但由于被父图层的顺时针45°抵消掉了,黑色图层相对于屏幕并没有旋转。



这是二维平面的情况,当我们延展到三维空间中CALayer是否也有旋转抵消的问题呢?我们来看一下实例:

CATransform3D transform3D = CATransform3DIdentity;
transform3D.m34 = -1.f /700.f;
transform3D = CATransform3DRotate(transform3D, M_PI_4, 0, 1, 0);
view.layer.transform = transform3D;

CATransform3D transform3D1 = CATransform3DIdentity;
transform3D1.m34 = -1.f/700.f;
layer.transform = CATransform3DRotate(transform3D1, -M_PI_4, 0, 1, 0);
[view.layer addSublayer:layer];

从具体呈现出的效果可以看出,并没有产生抵消的情况,因为我们从图中可以明显看出添加在图片图层上的黑色图层依然围绕y轴旋转了45°,没错!是这样的。

因为,虽然图层都存在于3D空间中,但它们并不在同一个3D空间里,每一个图层的3D空间效果都是由该图层通过扁平化的绘制呈现出来的,所以图层与图层的3D空间之间并不存在参考系的交集,所以此时并不会产生旋转抵消的问题。

所以,在我们进行图层的旋转变换的时候,二维空间的旋转和三维空间的旋转是有所不同的,这是我们需要注意的地方。

固体对象

前面我们提到过利用图层构建一个固体立方体,下面我们就要用我们刚聊过的3D变换知识来构建一个固体正方体:

首先我们有6张已经做好的图片:


图片资源链接:
https://pan.baidu.com/s/1jJ8xt10
密码:xvlq

我们将创建6个UIButton,并将6张图作为这6个buttonimage,然后将这6个button添加到一个容器视图上,而不是我们控制器的根视图:

UIView * containerView = [[UIView alloc] initWithFrame:CGRectMake(0, 100, 375, 375)];
[self.view addSubview:containerView];

UIButton * faceBtn1 = [UIButton buttonWithType:UIButtonTypeCustom];
[faceBtn1 setFrame:CGRectMake(0, 0, 140, 140)];
[faceBtn1 setCenter:CGPointMake(containerView.frame.size.width/2,containerView.frame.size.height/2)];
[faceBtn1 setImage:[UIImage imageNamed:@"1.png"] forState:UIControlStateNormal];
[faceBtn1 setTag:1];
[faceBtn1 addTarget:self action:@selector(clickFaceBtn:) forControlEvents:UIControlEventTouchUpInside];
faceBtn1.layer.doubleSided = NO; // 不绘制图层的背面
[containerView addSubview:faceBtn1];

...

// 由于其他faceBtn2、faceBtn3、faceBtn4、faceBtn5、faceBtn6的创建跟faceBtn1相同,这里就省略了

将6个button通过3D变换放置到空间的指定位置:

CATransform3D transform3D;

transform3D = CATransform3DMakeTranslation(0, 0, 70);
faceBtn1.layer.transform = transform3D; // 前面

transform3D = CATransform3DMakeTranslation(-70, 0, 0);
transform3D = CATransform3DRotate(transform3D, -M_PI_2, 0, 1, 0);
faceBtn2.layer.transform = transform3D; // 左面

transform3D = CATransform3DMakeTranslation(70, 0, 0);
transform3D = CATransform3DRotate(transform3D, M_PI_2, 0, 1, 0);
faceBtn3.layer.transform = transform3D; // 右面

transform3D = CATransform3DMakeTranslation(0, -70, 0);
transform3D = CATransform3DRotate(transform3D, M_PI_2, 1, 0, 0);
faceBtn4.layer.transform = transform3D; // 上面

transform3D = CATransform3DMakeTranslation(0, 70, 0);
transform3D = CATransform3DRotate(transform3D, -M_PI_2, 1, 0, 0);
faceBtn5.layer.transform = transform3D; // 下面

transform3D = CATransform3DMakeTranslation(0, 0, -70);
transform3D = CATransform3DRotate(transform3D, M_PI, 0, 1, 0);
faceBtn6.layer.transform = transform3D; // 后面

接下来我们通过containerView.layersublayerTransform属性进行整体的旋转:

  • 旋转1
CATransform3D containerTransform = CATransform3DMakeRotation(M_PI_4, 0, 1, 0);
containerTransform = CATransform3DRotate(containerTransform, M_PI_4, 1, 0, 0);
containerView.layer.sublayerTransform = containerTransform;
  • 旋转2
CATransform3D containerTransform = CATransform3DMakeRotation(-M_PI_4, 0, 1, 0);
containerTransform = CATransform3DRotate(containerTransform, M_PI_4, 1, 0, 0);
containerView.layer.sublayerTransform = containerTransform;
  • 旋转3
CATransform3D containerTransform = CATransform3DMakeRotation(M_PI, 0, 1, 0);
containerTransform = CATransform3DRotate(containerTransform, M_PI_4, 0, 1, 0);
containerTransform = CATransform3DRotate(containerTransform, M_PI_4, 1, 0, 0);
containerView.layer.sublayerTransform = containerTransform;

光亮和阴影

为了使我们构建的正方体更加逼真,我们接下来要为这个正方体加上光影效果。

为了呈现出由于光照而产生的明暗面,我们需要计算出每个面的正太向量(垂直于表面的向量),然后根据一个想象的光源计算出两个向量的叉乘结果,叉乘代表了光源和图层之间的角度,从而决定了它有多大程度的光亮。
这里我们需要引入GLKit框架进行向量计算,我们将每个面的CATransform3D都转换成GLKMaatrix4,然后通过GLKMatrix4GetMatrix3函数得出一个3*3的旋转矩阵。这个矩阵制定了图层方向,然后用它来得到正太向量的值。

下面我们写一个方法,为每一个‘faceBtn’添加一个用来展示明暗的图层:

#define LIGHT_DIRECTION 1, 0, 0
#define AMBIENT_LIGHT 0.5

- (void)addLightingLayerToFace:(CALayer *)face{
    
    CATransform3D transform = face.transform;
    GLKMatrix4 matrix4 = *(GLKMatrix4 *)&transform;
    GLKMatrix3 matrix3 = GLKMatrix4GetMatrix3(matrix4);
    // 获得平面法向量
    GLKVector3 normal = GLKVector3Make(1, 0, 0);
    normal = GLKMatrix3MultiplyVector3(matrix3, normal);
    normal = GLKVector3Normalize(normal);
    // 计算平面法向量与光源向量叉乘
    GLKVector3 light = GLKVector3Normalize(GLKVector3Make(LIGHT_DIRECTION));
    float dotProduct = GLKVector3DotProduct(light, normal);

    CGFloat shadow = 1 + dotProduct - AMBIENT_LIGHT;
    
    CALayer *layer = [CALayer layer];
    layer.frame = face.bounds;
    layer.backgroundColor = [UIColor colorWithWhite:0 alpha:shadow].CGColor;
    [face addSublayer:layer];
}
一束从左向右照射的光源

点击事件

前面文章我们曾提到过,事件的响应顺序并不是按照图层树层级来的,而是按照我们所添加视图的视图树层级来响应的,所以在我们所构建的立方体上,‘faceBtn’并不是按照三维空间的位置次序去响应的,而是取决于我们构建的视图树是什么样的,也就是说当我们点击这个正方体朝向我们的‘faceBtn’的时候,而响应的很可能是隐藏于正方体后方其他的‘faceBtn’,因为在视图树的层级中正方体后方的‘faceBtn’所处的位置可能更高于朝向我们的‘faceBtn’。

所以想真正实现一个立方体的完美事件响应还是很复杂的。我们可以通过userInteractionEnabled属性控制各个面禁止响应事件,从而解决响应顺序错误的问题,但是这样实现起来很复杂;另一种解决办法是覆盖现有的‘faceBtn’,利用覆盖‘faceBtn’的视图来响应事件,这样就互不影响了。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 211,817评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,329评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,354评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,498评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,600评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,829评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,979评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,722评论 0 266
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,189评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,519评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,654评论 1 340
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,329评论 4 330
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,940评论 3 313
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,762评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,993评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,382评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,543评论 2 349

推荐阅读更多精彩内容