如何使用 CATransform3D 处理 3D 影像、制做互动立体旋转的效果 ?

今天让我们讨论一下在iOS App的立体图像处理,而要讲这题目就不可不讲解 –CATransform3D。CATransform3D 是一个用来处理 3D 影像,像是旋转、缩放、平移等 3D 影像的控制。CATransform3D 是采用三维坐标系统,x 轴向下为正,y 向右为正,z 轴则是垂直于屏幕向外为正。

你可以这样理解CATransform3D ,它本身就是一个 4×4 的矩阵:

在这边,我们不需要了解矩阵中每一个数字是什么意思,因为在 CATransform3D 中已经有方法可以处理大部分的功能:

? CATransform3DTranslate

? CATransform3DRotate

? CATransform3DInvert

? CATransform3DScale

? CATransform3DAffine

如果只说道理,相信很难理解CATransform3D的运作,最好还是实作一个简单的范例程序以阐释当中的原理。在这篇,我要教大家如何做出一个可以被转动的骰子,下图就是完成品:

开始建立范例App

如常开启Xcode并使用Single View Application建立一个新项目。在Main.storyboard,先在View Controller 做一个简单的 View,将背景颜色转为蓝色(或其他颜色也可以)。

在 ViewController 加入IBOutlet,并将之连接至新建立的View:

@IBOutlet weak var blueView: UIView!

如何将View变得立体

现在要加入一个方法让 View (即blueView) 以 y 轴为中心旋转 45度,CATransform3D 的方法都是输入一个矩阵去改变内容。以 CATransform3DRotate 为例,第一个参数是定义好的矩阵,之后再用角度与 x、y、z 所形成的向量做旋转:

func viewTransform() {
    var transform = CATransform3DIdentity
    let angle = CGFloat(45)
    blueView.layer.transform = CATransform3DRotate(transform, angle, 0, 1, 0)
}

之后,再在viewDidLoad()方法呼叫viewTransform():

override func viewDidLoad() {
    super.viewDidLoad()
    
    viewTransform()
}

现在可先试试执行程序,你会发觉那个View实际上旋转之后并没有 3D 的感觉,只是变瘦的 View 。

在真实世界中,当物体远离我们时,由于视角的关系会看起来变小,理论上来说,比较远的边会比近的边要短,但是实际上对系统来说他们是一样长的,所以我们要做一些修正。透过设置 CATransform3D 的 m34 为 -1.0 / d 来让影像有远近的 3D 效果,d 代表了想象中视角与屏幕的距离,这个距离只需要大概估算,不需要很精准的计算。

解说:m34 用于按比例缩放 x 和 y 的值来呈现视角的远近。

现在修改viewTransform()并加入一行程序代码以设置m34:

func viewTransform() {
    var transform = CATransform3DIdentity
    let angle = CGFloat(45)
    transform.m34  = -1 / 500
    blueView.layer.transform = CATransform3DRotate(transform, angle, 0, 1, 0)
}

完成后,再试试执行App看看效果如何。现在那个应该有一种立体的感觉:

手势控制

接下来我们要改为手势控制这个 View 的角度,在ViewController类别先加一个angle变量:

angle = CGPoint.init(x: 0, y: 0)

之后,修改viewTransform方法:

func viewTransform(sender: UIPanGestureRecognizer) {
    
    let point = sender.translation(in: blueView)
    let angleX = angle.x + (point.x/30)
    let angleY = angle.y - (point.y/30)
    
    var transform = CATransform3DIdentity
    transform.m34 = -1 / 500
    transform = CATransform3DRotate(transform, angleX, 0, 1, 0)
    transform = CATransform3DRotate(transform, angleY, 1, 0, 0)
    blueView.layer.transform = transform
    
    if sender.state == .ended {
        angle.x = angleX
        angle.y = angleY
    }
}

另外,因为我们会用UIPanGestureRecognizer来识别手势,viewDidLoad也作出相应的修改:

override func viewDidLoad() {
    super.viewDidLoad()

    let panGesture = UIPanGestureRecognizer(target: self, action: #selector(viewTransform))
    blueView.addGestureRecognizer(panGesture)
}

在这边用 CATransform3DRotate 的方法对 X 轴与 Y 轴做转动,以手势在 View 上每次移动的相对坐标为基准(即是sender.translation(in: blueView)),直接去更改整个 View 的翻转角度。

又再试一试执行App,尝试用鼠标转动View,你会发现非常失控!

因为判定旋转角度的依据,就是手势在这个 View 上面移动的位置,而 View 在转动的时候坐标会随着旋转而不停的变动,以至于手势无法准确的控制,所以要拿一个不会动的对象当作基准,并且不能因为转动而改变可控的面积,解决办法如下(改动的程序代码以黄色标示):

override func viewDidLoad() {
    super.viewDidLoad()

    let subView = UIView.init(frame: blueView.bounds)
    subView.backgroundColor = UIColor.blue
    blueView.addSubview(subView)
        blueView.backgroundColor = UIColor.clear

    let panGesture = UIPanGestureRecognizer(target: self, action: #selector(viewTransform))
    blueView.addGestureRecognizer(panGesture)
}

func viewTransform(sender: UIPanGestureRecognizer) {

    let point = sender.translation(in: blueView)
    let angleX = angle.x + (point.x/30)
    let angleY = angle.y - (point.y/30)

    var transform = CATransform3DIdentity
    transform.m34 = -1 / 500
    transform = CATransform3DRotate(transform, angleX, 0, 1, 0)
    transform = CATransform3DRotate(transform, angleY, 1, 0, 0)
    blueView.layer.sublayerTransform = transform

    if sender.state == .ended {
        angle.x = angleX
        angle.y = angleY
    }
}

View 需要用手势移动当作依据,所以不直接对这个 View 做旋转,而是旋转 View 里面的 sublayer,layer 里面的有个方法可以实作这个功能 sublayerTransform ,并把内容以 subView 的方式加入,然后把 blueView 的 backgroundColor 拿掉,这样就能很正常的转动了。

将普通的View变成骰子

接下来,就是把这个 blueView 换成骰子。然而骰子并不是平面,是一个立体的对象,那要如何在平面上做出一个立体的对象?在这边要利用 CATransform3DTranslate 与 CATransform3DRotate 来做出立体对象的效果,首先做骰子的 1、2、3 点。

现在先下载骰子的图像,并将所有的图加进Xcode项目。我们会先把 View 修改成 1 点,顺便修改 View 的名称。先将原本的blueView从storyboard 删除,之后在ViewController建立diceView:

let diceView = UIView()

将以下viewDidLoad以及viewTransform方法更新至如下,同时加入一个新方法addDice:

override func viewDidLoad() {
    super.viewDidLoad()

    addDice()

    let panGesture = UIPanGestureRecognizer(target: self, action: #selector(viewTransform))
    diceView.addGestureRecognizer(panGesture)
}

func addDice() {

    let viewFrame = UIScreen.main.bounds

    diceView.frame = CGRect(x: 0, y: viewFrame.maxY / 2 - 50, width: viewFrame.width, height: 100)

    //1
    let dice1 = UIImageView.init(image: UIImage(named: "dice1"))
    dice1.frame = CGRect(x: viewFrame.maxX / 2 - 50, y: 0, width: 100, height: 100)

    diceView.addSubview(dice1)

    view.addSubview(diceView)
}

func viewTransform(sender: UIPanGestureRecognizer) {

    let point = sender.translation(in: diceView)
    let angleX = angle.x + (point.x/30)
    let angleY = angle.y - (point.y/30)

    var transform = CATransform3DIdentity
    transform.m34 = -1 / 500
    transform = CATransform3DRotate(transform, angleX, 0, 1, 0)
    transform = CATransform3DRotate(transform, angleY, 1, 0, 0)
    diceView.layer.sublayerTransform = transform

    if sender.state == .ended {
        angle.x = angleX
        angle.y = angleY
    }
}

我们在addDice建立相等于blue view的view并同时加载1点的图像,如你试执行程序,应该会得到以下的效果:

加入骰子第 2、3 点,并分别垂直于 1 点:

func addDice() {

    let viewFrame = UIScreen.main.bounds

    var diceTransform = CATransform3DIdentity

    diceView.frame = CGRect(x: 0, y: viewFrame.maxY / 2 - 50, width: viewFrame.width, height: 100)

    //1
    let dice1 = UIImageView.init(image: UIImage(named: "dice1"))
    dice1.frame = CGRect(x: viewFrame.maxX / 2 - 50, y: 0, width: 100, height: 100)

    //2
    let dice2 = UIImageView.init(image: UIImage(named: "dice2"))
    dice2.frame = CGRect(x: viewFrame.maxX / 2 - 50, y: 0, width: 100, height: 100)
    diceTransform = CATransform3DRotate(CATransform3DIdentity, (CGFloat.pi / 2), 0, 1, 0)
    dice2.layer.transform = diceTransform

    //3
    let dice3 = UIImageView.init(image: UIImage(named: "dice3"))
    dice3.frame = CGRect(x: viewFrame.maxX / 2 - 50, y: 0, width: 100, height: 100)
    diceTransform = CATransform3DRotate(CATransform3DIdentity, (-CGFloat.pi / 2), 1, 0, 0)
    dice3.layer.transform = diceTransform

    diceView.addSubview(dice1)
    diceView.addSubview(dice2)
    diceView.addSubview(dice3)

    view.addSubview(diceView)
}

又再试一试效果,看来不错!但还是不太像一颗骰子。

要解决这问题,我们就要平移每一个 imageView。CATransform3DRotate 不是只有转了影像,而是整个坐标系统,所以只需要每个面的 z 轴都增加(减少) 50 就能够做一半的正方体:

func addDice() {

    let viewFrame = UIScreen.main.bounds

    var diceTransform = CATransform3DIdentity

    diceView.frame = CGRect(x: 0, y: viewFrame.maxY / 2 - 50, width: viewFrame.width, height: 100)

    //1
    let dice1 = UIImageView.init(image: UIImage(named: "dice1"))
    dice1.frame = CGRect(x: viewFrame.maxX / 2 - 50, y: 0, width: 100, height: 100)
    diceTransform = CATransform3DTranslate(diceTransform, 0, 0, 50)
    dice1.layer.transform = diceTransform

    //2
    let dice2 = UIImageView.init(image: UIImage(named: "dice2"))
    dice2.frame = CGRect(x: viewFrame.maxX / 2 - 50, y: 0, width: 100, height: 100)
    diceTransform = CATransform3DRotate(CATransform3DIdentity, (CGFloat.pi / 2), 0, 1, 0)
    diceTransform = CATransform3DTranslate(diceTransform, 0, 0, 50)
    dice2.layer.transform = diceTransform

    //3
    let dice3 = UIImageView.init(image: UIImage(named: "dice3"))
    dice3.frame = CGRect(x: viewFrame.maxX / 2 - 50, y: 0, width: 100, height: 100)
    diceTransform = CATransform3DRotate(CATransform3DIdentity, (-CGFloat.pi / 2), 1, 0, 0)
    diceTransform = CATransform3DTranslate(diceTransform, 0, 0, 50)
    dice3.layer.transform = diceTransform

    diceView.addSubview(dice1)
    diceView.addSubview(dice2)
    diceView.addSubview(dice3)

    view.addSubview(diceView)
}

现在再试试执行程序,应该有了三个面!

剩下的就是把三个对应的面加进去,就能完成一颗骰子:

func addDice() {

    let viewFrame = UIScreen.main.bounds

    var diceTransform = CATransform3DIdentity

    diceView.frame = CGRect(x: 0, y: viewFrame.maxY / 2 - 50, width: viewFrame.width, height: 100)

    //1
    let dice1 = UIImageView.init(image: UIImage(named: "dice1"))
    dice1.frame = CGRect(x: viewFrame.maxX / 2 - 50, y: 0, width: 100, height: 100)
    diceTransform = CATransform3DTranslate(diceTransform, 0, 0, 50)
    dice1.layer.transform = diceTransform

    //6
    let dice6 = UIImageView.init(image: UIImage(named: "dice6"))
    dice6.frame = CGRect(x: viewFrame.maxX / 2 - 50, y: 0, width: 100, height: 100)
    diceTransform = CATransform3DTranslate(CATransform3DIdentity, 0, 0, -50)
    dice6.layer.transform = diceTransform

    //2
    let dice2 = UIImageView.init(image: UIImage(named: "dice2"))
    dice2.frame = CGRect(x: viewFrame.maxX / 2 - 50, y: 0, width: 100, height: 100)
    diceTransform = CATransform3DRotate(CATransform3DIdentity, (CGFloat.pi / 2), 0, 1, 0)
    diceTransform = CATransform3DTranslate(diceTransform, 0, 0, 50)
    dice2.layer.transform = diceTransform

    //5
    let dice5 = UIImageView.init(image: UIImage(named: "dice5"))
    dice5.frame = CGRect(x: viewFrame.maxX / 2 - 50, y: 0, width: 100, height: 100)
    diceTransform = CATransform3DRotate(CATransform3DIdentity, (-CGFloat.pi / 2), 0, 1, 0)
    diceTransform = CATransform3DTranslate(diceTransform, 0, 0, 50)
    dice5.layer.transform = diceTransform

    //3
    let dice3 = UIImageView.init(image: UIImage(named: "dice3"))
    dice3.frame = CGRect(x: viewFrame.maxX / 2 - 50, y: 0, width: 100, height: 100)
    diceTransform = CATransform3DRotate(CATransform3DIdentity, (-CGFloat.pi / 2), 1, 0, 0)
    diceTransform = CATransform3DTranslate(diceTransform, 0, 0, 50)
    dice3.layer.transform = diceTransform

    //4
    let dice4 = UIImageView.init(image: UIImage(named: "dice4"))
    dice4.frame = CGRect(x: viewFrame.maxX / 2 - 50, y: 0, width: 100, height: 100)
    diceTransform = CATransform3DRotate(CATransform3DIdentity, (CGFloat.pi / 2), 1, 0, 0)
    diceTransform = CATransform3DTranslate(diceTransform, 0, 0, 50)
    dice4.layer.transform = diceTransform

    diceView.addSubview(dice1)
    diceView.addSubview(dice2)
    diceView.addSubview(dice3)
    diceView.addSubview(dice4)
    diceView.addSubview(dice5)
    diceView.addSubview(dice6)

    view.addSubview(diceView)
}

在这边要注意的是 1 跟 6 都没有旋转,所以要一正一负。好了!终于完成,现在执行程就会有一颗会旋转骰子。

希望你透过这个实作对 CATransform3D 有所了解,如有问题,请留言给我。另外,你可以在这里下载完整的范例项目

原文链接:http://www.cocoachina.com/articles/19305

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

推荐阅读更多精彩内容