今天讓我們討論一下在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
代表了想像中視角與螢幕的距離,這個距離只需要大概估算,不需要很精準的計算。
現在修改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
變數:
var 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 有所了解,如有問題,請留言給我。另外,你可以在這裡下載完整的範例項目。