在这一章中,我们将要研究可以用来对图层旋转,摆放或者扭曲的CGAffineTransform
,以及可以将扁平物体转换成三维空间对象的 CATransform3D
(而不是仅仅对圆角矩形添加下沉阴影)。
仿射变换
实际上UIView
的transform
属性是一个CGAffineTransform
类型,用于在二维空间做旋转,缩放和平
移。CGAffineTransform
是一个可以和二维空间向量(例如 CGPoint
)做乘法 的3X2的矩阵
用 CGPoint
的每一列和 矩阵的每一行对应元素相乘再求和,就形成了一个新的 类型的结果。要解释一下图中显示的灰色元素,为了能让矩阵做乘法,左边矩阵的列数一定要和右边矩阵的行数个数相同,所以要给矩阵填充一些标志值,使得既可以让矩阵做乘法,又不改变运算结果,并且没必要存储这些添加的值,因为它们的值不会发生变化,但是要用来做运算。
当对图层应用变换矩阵,图层矩形内的每一个点都被相应地做变换,从而形成一个 新的四边形的形状。 CGAffineTransform
中的“仿射”的意思是无论变换矩阵用什么值,图层中平行的两条线在变换之后任然保持平行, CGAffineTransform 可以做出任意符合上述标注的变换
创建一个 CGAffineTransform
对矩阵数学做一个全面的阐述就超出本书的讨论范围了,不过如果你对矩阵完全不熟悉的话,矩阵变换可能会使你感到畏惧。幸运的是,Core Graphics
提供了一系列函数,对完全没有数学基础的开发者也能够简单地做一些变换。如下几个函数都 创建了一个 CGAffineTransform 实例:
CGAffineTransformMakeRotation(CGFloat angle)
CGAffineTransformMakeScale(CGFloat sx, CGFloat sy)
CGAffineTransformMakeTranslation(CGFloat tx, CGFloat ty)
旋转和缩放变换都可以很好解释--分别旋转或者缩放一个向量的值。平移变换是指 每个点都移动了向量指定的x
或者y
值--所以如果向量代表了一个点,那它就平移了 这个点的距离。
UIView
可以通过设置transform
属性做变换,但实际上它只是封装了内部图层的变换。
CALayer
同样也有一个transform
的属性,但它的类型是 CATransform3D
,而不是CGAffineTransform
。CALayer
对应于UIView
的transform
属性叫做affineTransform
。
- (void)viewDidLoad
{
[super viewDidLoad];
//rotate the layer 45 degrees
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
的四分之一,如果对换算不太清楚的话,可以用如下的宏做换算:
#define RADIANS_TO_DEGREES(x) ((x)/M_PI*180.0)
混合变换
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)
Demo
我们来用这些函数组合一个更加复杂的变换,先缩小50%
,再旋转30
度,最后向右移动200
个像素
- (void)viewDidLoad
{
[super viewDidLoad];
//create a new transform
CGAffineTransform transform = CGAffineTransformIdentity;
//scale by 50%
transform = CGAffineTransformScale(transform, 0.5, 0.5);
//rotate by 30 degrees
transform = CGAffineTransformRotate(transform, M_PI / 180.0 * 30);
//translate by 200 points
transform = CGAffineTransformTranslate(transform, 200, 0);
//apply transform to layer
self.layerView.layer.affineTransform = transform;
}
3D变换
CG
的前缀告诉我们, CGAffineTransform
类型属于Core Graphics
框架,Core Graphics
实际上是一个严格意义上的2D
绘图API
,并且 CGAffineTransform
仅仅 对2D
变换有效。
我们提到了zPosition
属性,可以用来让图层靠近或者远离相机 (用户视角),transform
属性( CATransfrom3D
类型)可以真正做到这点,即让图层在3D
空间内移动或者旋转。
和 CGAffineTransform
类似, CATransform3D
也是一个矩阵,但是和2x3
的矩 阵不同,CATransform3D
是一个可以在3
维空间内做变换的4x4
的矩阵。
和CGAffineTransform
矩阵类似,Core Animation
提供了一系列的方法用来创建和组合CATransform3D
类型的矩阵, 和Core Graphics
的函数类似, 但是3D
的平移和旋转多出了一个z
参数,并且旋转函数除了angle
之外多出了x
,y
,z
三个参数,分别决定了每个坐标方向上的旋转:
CATransform3DMakeRotation(CGFloat angle, CGFloat x, CGFloat y, CGFloat z)
CATransform3DMakeScale(CGFloat sx, CGFloat sy, CGFloat sz)
CATransform3DMakeTranslation(Gloat tx, CGFloat ty, CGFloat tz)
你应该对X
轴和Y
轴比较熟悉了,分别以右和下为正方向,Z
轴和这两个轴分别垂直,指向视角外为正方向。
Demo
使用代码利用CATransform3DMakeRotation
对视图内的图层 绕Y轴做了45度角的旋转,我们可以把视图向右倾斜,这样会看得更清晰。
- (void)viewDidLoad
{
[super viewDidLoad];
//rotate the layer 45 degrees along the Y axis
CATransform3D transform = CATransform3DMakeRotation(M_PI_4, 0,1,0);
self.layerView.layer.transform = transform;
}
透视投影
在真实世界中,当物体远离我们的时候,由于视角的原因看起来会变小,理论上说远离我们的视图的边要比靠近视角的边跟短,但实际上并没有发生,而我们当前的 视角是等距离的,也就是在3D变换中任然保持平行,和之前提到的仿射变换类似。
在等距投影中,远处的物体和近处的物体保持同样的缩放比例,这种投影也有它自 己的用处(例如建筑绘图,颠倒,和伪3D
视频),但当前我们并不需要。
为了做一些修正,我们需要引入投影变换(又称作 z
变换)来对除了旋转之外的变 换矩阵做一些修改,Core Animation
并没有给我们提供设置透视变换的函数,因此 我们需要手动修改矩阵值,幸运的是,很简单:
CATransform3D
的透视效果通过一个矩阵中一个很简单的元素来控制:m34
。 用于按比例缩放X
和Y
的值来计算到底要离视角多远。
m34
的默认值是0
,我们可以通过设置m34
为-1.0 / d
来应用透视效果, d
代 表了想象中视角相机和屏幕之间的距离,以像素为单位,那应该如何计算这个距离 呢?实际上并不需要,大概估算一个就好了。
因为视角相机实际上并不存在,所以可以根据屏幕上的显示效果自由决定它的防止 的位置。通常500-1000就已经很好了,但对于特定的图层有时候更小或者更大的值会看起来更舒服,减少距离的值会增强透视效果,所以一个非常微小的值会让它看起来更加失真,然而一个非常大的值会让它基本失去透视效果。
- (void)viewDidLoad
{
[super viewDidLoad];
//create a new transform
CATransform3D transform = CATransform3DIdentity;
//apply perspective
transform.m34 = - 1.0 / 500.0;
//rotate by 45 degrees along the Y axis
transform = CATransform3DRotate(transform, M_PI_4, 0, 1, 0);
//apply to layer
self.layerView.layer.transform = transform;
}
灭点
当在透视角度绘图的时候,远离相机视角的物体将会变小变远,当远离到一个极限
距离,它们可能就缩成了一个点,于是所有的物体最后都汇聚消失在同一个点。
Core Animation
定义了这个点位于变换图层的anchorPoint
(通常位于图层中 心,但也有例外)。这就是说,当图层发生变换时,这个点永远位于图 层变换之前 anchorPoint 的位置。
当改变一个图层的 position
,你也改变了它的灭点,做3D
变换的时候要时刻记住这一点,当你视图通过调整 m34
来让它更加有3D
效果,应该首先把它放置于屏 幕中央,然后通过平移来把它移动到指定位置(而不是直接改变它的position
),这样所有的3D
图层都共享一个灭点。
sublayerTransform 属性
如果有多个视图或者图层,每个都做3D
变换,那就需要分别设置相同的m34
值,并且确保在变换之前都在屏幕中央共享同一个 position
,如果用一个函数封装这些 操作的确会更加方便,但仍然有限制(例如,你不能在Interface Builde
r中摆放视 图),这里有一个更好的方法。
CALayer
有一个属性叫做 sublayerTransform
。它也是 CATransform3D
类型,但和对一个图层的变换不同,它影响到所有的子图层。这意味着你可以一次性 对包含这些图层的容器做变换,于是所有的子图层都自动继承了这个变换方法。
相较而言,通过在一个地方设置透视变换会很方便,同时它会带来另一个显著的优 势:灭点被设置在容器图层的中点,从而不需要再对子图层分别设置了。这意味着 你可以随意使用 position
和frame
来放置子图层,而不需要把它们放置在屏幕中点,然后为了保证统一的灭点用变换来做平移。
@interface ViewController ()
@property (nonatomic, weak) IBOutlet UIView *containerView;
@property (nonatomic, weak) IBOutlet UIView *layerView1;
@property (nonatomic, weak) IBOutlet UIView *layerView2;
@end
@implementation ViewController
- (void)viewDidLoad
{
[super viewDidLoad];
//apply perspective transform to container
CATransform3D perspective = CATransform3DIdentity;
perspective.m34 = - 1.0 / 500.0;
self.containerView.layer.sublayerTransform = perspective; // 重点
//rotate layerView1 by 45 degrees along the Y axis
CATransform3D transform1 = CATransform3DMakeRotation(M_PI_4, 0,1,0);
self.layerView1.layer.transform = transform1;
//rotate layerView2 by 45 degrees along the Y axis
CATransform3D transform2 = CATransform3DMakeRotation(-M_PI_4,0,1,0);
self.layerView2.layer.transform = transform2;
}
通过相同的透视效果分别对视图做变换.png
背面
我们既然可以在3D
场景下旋转图层,那么也可以从背面去观察它。如果我们把角度修改为 M_PI (180度)而不是当前的 M_PI_4 (45度),那么将会把图层完全旋转一个半圈,于是完全背对了相机视角。
如你所见,图层是双面绘制的,反面显示的是正面的一个镜像图片。
但这并不是一个很好的特性,因为如果图层包含文本或者其他控件,那用户看到这 些内容的镜像图片当然会感到困惑。另外也有可能造成资源的浪费:想象用这些图 层形成一个不透明的固态立方体,既然永远都看不见这些图层的背面,那为什么浪 费GPU来绘制它们呢?
CALayer
有一个叫做doubleSided
的属性来控制图层的背面是否要被绘制。这是一个BOOL
类型,默认为YES
,如果设置为 NO
,那么当图层正面从相机视角消失的时候,它将不会被绘制。
扁平化图层
如果内部图层相对外部图层做了相反的变换(这里是绕Z
轴的旋转),那么按照逻 辑这两个变换将被相互抵消。
@interface ViewController ()
@property (nonatomic, weak) IBOutlet UIView *outerView;
@property (nonatomic, weak) IBOutlet UIView *innerView;
@end
@implementation ViewController
- (void)viewDidLoad
{
[super viewDidLoad];
//rotate the outer layer 45 degrees
CATransform3D outer = CATransform3DMakeRotation(M_PI_4, 0, 0,1);
self.outerView.layer.transform = outer;
//rotate the inner layer -45 degrees
CATransform3D inner = CATransform3DMakeRotation(-M_PI_4, 0, 0,1);
self.innerView.layer.transform = inner;
}
旋转后的视图.png
运行结果和我们预期的一致。现在在3D
情况下再试一次。修改代码,让内外两个视 图绕Y
轴旋转而不是Z
轴,再加上透视效果,以便我们观察。注意不能用 sublayerTransform
属性,因为内部的图层并不直接是容器图层的子图层,所以这里分别对图层设置透视变换。
- (void)viewDidLoad
{
[super viewDidLoad];
//rotate the outer layer 45 degrees
CATransform3D outer = CATransform3DIdentity;
outer.m34 = -1.0 / 500.0;
outer = CATransform3DRotate(outer, M_PI_4, 0, 1, 0);
self.outerView.layer.transform = outer;
//rotate the inner layer -45 degrees
CATransform3D inner = CATransform3DIdentity;
inner.m34 = -1.0 / 500.0;
inner = CATransform3DRotate(inner, -M_PI_4, 0, 1, 0);
self.innerView.layer.transform = inner;
}
绕Y轴做相反旋转的预期结果.png
绕Y轴做相反旋转的真实结果.png
但其实这并不是我们所看到的,这是由于尽管Core Animation
图层存在于3D
空间之内,但它们并不都存在同一个 3D
空间。每个图层的3D
场景其实是扁平化的,当你从正面观察一个图层,看到的 实际上由子图层创建的想象出来的3D
场景,但当你倾斜这个图层,你会发现实际上 这个3D
场景仅仅是被绘制在图层的表面。
这使得用Core Animation
创建非常复杂的3D
场景变得十分困难。你不能够使用图层树去创建一个3D
结构的层级关系--在相同场景下的任何3D
表面必须和同样的图层保持一致,这是因为每个的父视图都把它的子视图扁平化了。
至少当你用正常的CALayer
的时候是这样, CALayer
有一个叫做CATransformLayer
的子类来解决这个问题。具体在“特殊的图层”中将会 具体讨论。
固体对象
现在你懂得了在3D
空间的一些图层布局的基础,我们来试着创建一个固态的3D
对 象(实际上是一个技术上所谓的空洞对象,但它以固态呈现)。我们用六个独立的视图来构建一个立方体的各个面。
光亮和阴影
如果想让立方体看起来更加真实,需要自己做一个阴影效果。你可以通过改变每个面的背景颜色或者 直接用带光亮效果的图片来调整。
如果需要动态地创建光线效果,你可以根据每个视图的方向应用不同的alpha
值做出半透明的阴影图层,但为了计算阴影图层的不透明度,你需要得到每个面的正太向量(垂直于表面的向量),然后根据一个想象的光源计算出两个向量叉乘结果。 叉乘代表了光源和图层之间的角度,从而决定了它有多大程度上的光亮。
我们用GLKit
框架来做向量的计算(你需要引入 GLKit
库来运行代码),每个面的 CATransform3D
都被转换成 GLKMatrix4
,然后通过GLKMatrix4GetMatrix3
函数得出一个3×3
的旋转矩阵。这个旋转矩阵指定了图层的方向,然后可以用它来得到正太向量的值。
点击事件
点击事件的处理由视图在父视图中的顺序决定的,并不是3D
空间中的Z
轴顺序。
你也许认为把 doubleSided
设置成 NO
可以解决这个问题,因为它不再渲染视图 后面的内容,但实际上并不起作用。因为背对相机而隐藏的视图仍然会响应点击事 件(这和通过设置 hidden
属性或者设置 alpha
为0
而隐藏的视图不同,那两种 方式将不会响应事件)。所以即使禁止了双面渲染仍然不能解决这个问题(虽然由于性能问题,还是需要把它设置成 NO
)。
这里有几种正确的方案:把其他视图 userInteractionEnabled
属性 都设置成 NO
来禁止事件传递。或者简单通过代码把修改视图在父视图的顺序。
总结
这一章涉及了一些2D
和3D
的变换。你学习了一些矩阵计算的基础,以及如何用Core Animation
创建3D
场景。你看到了图层背后到底是如何呈现的,并且知道了不能把扁平的图片做成真实的立体效果,最后我们用demo
说明了触摸事件的处理,视图中图层添加的层级顺序会比屏幕上显示的顺序更有意义。
网友评论