UIView.transform的高階玩法
最近在重構之前上架的一款畫板應用,期間用到了一些UIView的transform相關的特性。藉此機會也系統整理了一下transform相關的知識。
在進入正題之前需要補充一點線性代數(數學專業應該叫高等代數)相關的知識。
齊次座標系
所謂齊次座標系就是將一個原本是n維的向量用一個n+1維向量來表示。對於一個向量v以及基oabc,可以找到一組座標(v 1 ,v 2 ,v 3 )使得v=v 1 a+v 2 b+v 3 c(1-1)。而對於一個點p,則可以找到一組座標(p 1 ,p 2 ,p 3 )使得p - o = p 1 a + p 2 b + p 3 c(1-2)
從上面對向量和點的表達,我們可以看出為了在座標系中表示一個點我們可以把點的位置看作是對於這個基的原點o所進行的一個位移,即一個向量p - o,我們在表達這個向量的同時用等價的方式表達出了點p: p = o + p 1 a + p 2 b + p 3 c(1-3)。(1-1),(1-3)是座標系下表達一個向量和點的不同表達方式。這裡可以看出,雖然都是用代數分量的形式表達向量和點,但表達一個點比一個向量需要額外的資訊。如果我寫一個代數分量表達(1,4,7),誰知道它是個向量還是一個點。我們現在把(1-1),(1-3)寫成矩陣的形式:

1-4

1-5
這裡(a b c o)是座標基矩陣,左邊的行向量分別是向量v和點p在基下的座標。這樣,向量和點再同一個基下就有了不同的表達:三維向量的第四個代數分量是0,而三維點的第四個代數分量是1。像這種用四個代數分量表示三維幾何概念的方式是一種 齊次座標 表示。這樣,上面的(1,4,7)如果寫成(1,4,7,0),它就是個向量;如果是(1,4,7,1)它就是個點。
由於齊次座標使用了4個分量來表達3D概念或者說用了3個分量來表達2D概念,從而使得放射變換可以使用矩陣進行。
平面幾何變換的定義
如果有一種法則T,對平面點集中的每個點A,都對應平面上唯一的一個點T(A),則T稱為平面上的一個變換,T(A)稱為A的像。變換是函式概念的自然推廣。
平面上的圖形由點組成,因而平面上的變換T會將一個圖形C變到另一個圖形T(C),T(C)稱為C的像。從這個意義上說,可以稱T為幾何變換。例如對圖形作平移變換、旋轉變換、縮放變換、對稱變換等都是幾何變換。
在平面直角座標系中,點A由座標(x,y)表示。在變換T下,點A(x,y)的像為A'(x',y'),其中x'和y'都是x,y的函式:
x' = f 1 (x,y), y' = f 2 (x,y)
因此,函式f 1 ,f 2 能夠確定一個平面上的變換T。如果能夠從方程組中反解出x和y:
x = g 1 (x', y'), y = g 2 (x', y')
則由函式g 1 ,g 2 確定了T的逆變換,記為T -1 。設平面曲線C的引數方程為:
x = x(t), y = y(t), t∈D
其中D是函式x(t),y(t)的定義域,則曲線C在變換T下的像T(C)的引數方程為
x = f 1 (x(t),y(t)), y = f 2 (x(t), y(t)), t∈D
平面幾何變換及其矩陣表示
平面圖形幾何變換
1、平移變換
平移變換是將圖形中的每一個點從一個位置(x,y)移動到另一個位置(x',y')的變換,t x ,t y 稱為平移距離,則平移變換公式為:


平移變換
2、旋轉變換
旋轉變換是以某個參考點為圓心,將影象上的各點(x,y)圍繞圓心轉動一個逆時針角度θ,變為新的座標(x',y')的變換。當參考點為(0,0)時,旋轉變換的公式為:

由於:

所以可化簡為:


旋轉變換
3、比例變換
比例變換是使物件按比例因子(s x ,s y )放大或縮小的變換


比例變換
平面圖形幾何變換的矩陣表示

從變換功能上可以把T2D分為四個子矩陣。其中

是對圖形的縮放、旋轉、對稱、錯切等變換;

是對圖形進行平移變換;

是對圖形作投影變換,g的作用是在x軸的1/g處產生一個滅點,而h的作用是在y軸的1/h處產生一個滅點;i是對整個圖形做伸縮變換。平移變換、旋轉變換、比例變換、錯切變換這4中基本變換都可以表示為3x3的變換矩陣和齊次座標相乘的形式
1、平移變換的矩陣表示
平移變換的矩陣表示為

t x ,t y 分別表示x軸方向和y軸方向的平移距離。
2、旋轉變換矩陣表示
旋轉變換的矩陣表示為

逆時針旋轉時θ取正值,順時針旋轉時θ取負值
3、比例變換的矩陣表示
比例變換的矩陣表示為

- 當b=d=0時,a和e的取值決定了縮放效果,a和e>1放大,<1縮小
- 當b=d=0,a=-1,e=1時有x'=-x,y'=y產生與y軸對稱的圖形
- 當b=d=0,a=1,e=-1時有x'=x,y'=-y產生與x軸對稱的圖形
- 當b=d=0,a=e=-1時有x'=-x,y'=-y產生與原點對稱的圖形
- 當b=d=1,a=e=0時有x'=y,y'=x產生與直線y=x對稱的圖形
- 當b=d=-1,a=e=0時有x'=-y,y'=-x產生與直線y=-x對稱的圖形
4、錯切變換的矩陣表示
錯切變換的矩陣表示為

其中當d = 0時,x' = x + by, y' = y,此時,圖形的y座標不變,x座標隨初值(x, y)及變換系數b作線性變化;當b = 0時,x' = x,y' = dx + y,此時,圖形的x座標不變,y座標隨初值(x, y)及變換系數d作線性變化。
5、複合變換
一個比較複雜的變換要連續進行若干個基本變換才能完成。例如圍繞任意點(x f , y f )的旋轉,需要通過3個基本變換T(x f , y f ),R(θ),T(x f , y f )才能完成。這些由基本變換構成的連續變換序列稱為複合變換。
變換的矩陣形式使得複合變換的計算工作量大為減少。以繞任意點旋轉為例,本應進行如下3次變換,分別是
- p' = pT(-x f , -y f ) 將原點移動到任意點位置
- p'' = p'R(θ) 旋轉
- p = p''T(x f , y f ) 將原點歸位
合併之後為p = pT(-x f , -y f )R(θ)T(x f , y f )
令T c = T(-x f , -y f )R(θ)T(x f , y f )則有p = pT c ,T c 稱為複合變換矩陣。由上面推到可知在計算複合變換時,首先可將各基本變換矩陣按次序想乘,形成總的複合變換矩陣T c 然後,座標只需與T c 想乘一次,便可同時完成一連串基本變換。因此採用複合變換矩陣可以大大節省座標乘法所耗費的運算時間。下面我們看幾個基本的複合變換:
複合平移:
對同一圖形做兩次平移相當於將兩次平移相加起來,即

複合縮放:
以原點為參考點對同一圖形做兩次連續的縮放相當於將縮放操作相乘,即:

複合旋轉:
以原點為參考點對同一圖形做兩次連續的旋轉相當於將兩次的旋轉角度相加, 即:

縮放、旋轉變換都與參考點有關,上面進行的各種縮放、旋轉變換都是以原點為參考點的。如果相對某個一般的參考點(xf,yf)作縮放、旋轉變換,相當於將該點移到座標原點處,然後進行縮放、旋轉變換,最後將(xf,yf)點移回原來的位置。如關於(xf,yf)的縮放變換為:

各種複雜的變換無非是一些基本變換的組合,利用數學方法也就是矩陣的 乘法來解決複合變換問題,關鍵是將其分解為一定順序的基本變換,然後逐一 進行這些基本變換;或者求出這些基本變換矩陣連乘積,即求出複合變換矩陣, 從而使複合變化問題得到解決。
寫了這麼多隻是想把平面仿射變換的基本原理描述清楚,以便能對UIView.transform有更深入的理解。
接下來我們進入正題
UIView外部座標系
這裡說的座標系是UIView相對於其父檢視的相對位置和大小

UIView外部座標系
如上圖以父檢視左上角為座標原點,x軸從原點向右遞增,y軸從原點向下遞增,通過改變UIView的frame和center可以調整UIView的位置和大小,當然UIView是對CALayer的封裝也可以直接調整layer的frame和position達到相同的效果。
基於此我們可以調整UIView的位置和大小,或者通過UIView的位置和大小進行適當的動畫展示,當然也僅限於此,對於旋轉、切變是無能為力的。
- 設定View的frame和center會改變其位置和大小,同時會改變View的bounds,bounds是View相對於自身的尺寸bounds=(0,0,view.width,view.height)
- 設定完成frame或者center之後可以通過調整bounds重新設定frame,如果frame = (x,y,w,h) 重新設定bounds = (0,0,w',h')則新的frame=(x',y',w',h')

- 當然如果在設定完bounds之後再設定frame則bounds會被重置為(0,0,view.width,view.height)
UIView內部座標系
UIView除了剛剛我們說的外部座標系,還有一個內部座標系。

UIView內部座標系
跟笛卡爾座標系(直角座標系)稍微有點區別,以UIView檢視中心為座標原點,x軸從原點向右遞增,y軸從原點向下遞增,通過改變UIView的transform可以對其進行仿射變換,如上面我們提到的縮放、旋轉、平移、切變等。有了這個特性UIView能做的事情就更多了,當然也可以藉此做更有意思的動畫。
在內部座標系中原點的位置可以通過anchorPoint調整,UIView沒有開放出來,可以訪問CALayer獲取。

anchorPoint
參考上圖通過調整anchorPoint的值可以修改內部座標系的原點位置,設定(0,0)可以把原點移動到View的左上角,設定(1,1)可以把原點移動到右下角,設定(0.5, 0.5)可以把原點移動到View中心。當然anchorPoint的值也不限制在[0,1],可以推廣到任意浮點值,相應的調整規則類似,比如設定為(-1,-1)則可以把原點移動到左上角再向左上偏移一個View的位置。
anchorPoint值的修改不只會調整原點位置,同時也會修改View的frame,修改規則如下:

基於View的transform可以進行仿射變換,所有的變化都是基於原點位置進行的,因此anchorPoint的設定可以產生更多有意思的效果,
後續我們一個個看
跟anchorPoint的設定一樣,transform的設定也會引起frame的調整

Transform修改
見上圖以旋轉變換為例,旋轉變換會讓原有圖形的frame從白色框變為虛線框,我們假設原有View的四個點為p 0 p 1 p 2 p 3 則旋轉變換之後的點為:
p 0 ' = p 0 T(θ)
p 1 ' = p 1 T(θ)
p 2 ' = p 2 T(θ)
p 3 ' = p 3 T(θ)
則frame = (x',y',w',h')

UIView內部座標系和外部座標系的聯絡
我們把上面提到的兩個座標系結合起來看一下

內外座標系
影響View位置和形狀的幾個引數有:
- frame
- center
- transform
- bounds
- anchorPoint
遵循如下規則:
- 在設定transform之前可以通過frame和center調整View的大小和尺寸,frame的改變會影響bounds,設定bounds會重新修改frame和center,規則參考之前
- View的transform參考內部座標系,transform的改變會影響frame和center,但是不會修改bounds
- 在設定了transform修改之後仍然可以通過調整bounds來修改frame和center也可以直接修改center,transform會根據新的bounds和center來計算新的frame,參考之前
- anchorPoint的修改會影響transform的原點位置從而產生不同的變換效果,也會引起frame的重新計算
UIView.transform的高階玩法
上面的理論知識已經寫了很多了,接下來我們實際體驗一下,看一下View的transform結構
struct CGAffineTransform { CGFloat a, b, c, d; CGFloat tx, ty; };
結合上面關於線性代數相關的知識,可以發現View的transform最終都轉換成了矩陣運算
UIView的複合變換
UIView *view = [UIView new]; view.backgroundColor = [UIColor redColor]; view.frame = CGRectMake(200, 200, 100, 100); [self.view addSubview:view]; [UIView animateWithDuration:5 animations:^{ // 先平移 CGAffineTransform move = CGAffineTransformMakeTranslation(100, 100); // 後旋轉 CGAffineTransform rotation = CGAffineTransformMakeRotation(M_PI); view.transform = CGAffineTransformConcat(rotation, move); }];

先平移後旋轉
先不解釋,我們接著再看一個變換
UIView *view = [UIView new]; view.backgroundColor = [UIColor redColor]; view.frame = CGRectMake(200, 200, 100, 100); [self.view addSubview:view]; [UIView animateWithDuration:5 animations:^{ // 先旋轉 CGAffineTransform rotation = CGAffineTransformMakeRotation(M_PI); // 後平移 CGAffineTransform move = CGAffineTransformMakeTranslation(100, 100); view.transform = CGAffineTransformConcat(move,rotation); }];

先旋轉後平移
綜合上面兩個不同順序的變換,由於View內部座標系的原點在複合變換的過程中一直跟隨View在移動因此平移和旋轉的順序會決定不同的結果。
- 如果原點在整個變換過程中一直不變,則需要先旋轉後平移
- 如果原點在整個變換過程中一直跟隨View,則需要先平移後旋轉
目的就是保證旋轉始終是圍繞原點進行
AnchorPoint
如果不修改AnchorPoint則所有的變化都是基於View的中心進行,但是可以通過修改anchorPoint改變原點的位置從而改變變換的效果
UIView *view = [UIView new]; view.backgroundColor = [UIColor redColor]; view.frame = CGRectMake(200, 200, 100, 100); [self.view addSubview:view]; view.layer.anchorPoint = CGPointMake(0, 0); [UIView animateWithDuration:5 animations:^{ view.transform = CGAffineTransformMakeRotation(M_PI); }];

繞點旋轉
如上圖可以實現繞點旋轉的效果
綜合應用
借用一個案例來對transform做一個綜合的應用,這個案例也是從實際專案中產生的。先看最終效果:

綜合應用
最近在用一些零散的時間重構之前上架的一款畫板應用,希望為畫布增加更加靈活的操作方式,在雙指拖拽畫布的同時可以實現定點的縮放和旋轉,可以通過雙指點選完成筆跡的撤銷,通過三指點選完成筆跡的重做。
把問題拆解一下,為了達到上面展示的效果,需要解決以下問題:
- 手勢的控制,雙指拖拽,雙指捏合,雙指旋轉
- 處理各手勢之間的衝突和配合
- 處理View的平移、旋轉、縮放複合變換
- 其中旋轉和縮放變換要以雙指連線的中點為旋轉或縮放中心
手勢控制
綜合分析以上問題首先需要為畫布增加一個容器,然後才能在容器上新增手勢,通過手勢控制畫布的frame和transform
/// 畫布 var canvasView: UIView? = nil { didSet { if self.canvasView != nil { self.addSubview(self.canvasView!); self.canvasView?.backgroundColor = UIColor.white; // 移動到容器中心 self.canvasView!.center = CGPoint(x: self.bounds.size.width/2, y: self.bounds.size.height/2); // transform歸零,設定為單位矩陣 self.canvasView!.transform = CGAffineTransform.identity; } } }
新增需要的手勢
// 雙指點選 let doubleTouchesGesture = UITapGestureRecognizer(target: self, action: #selector(gestureRecognizer(gesture:))); doubleTouchesGesture.numberOfTapsRequired = 1; doubleTouchesGesture.numberOfTouchesRequired = 2; doubleTouchesGesture.delegate = self; self.addGestureRecognizer(doubleTouchesGesture); // 三指點選 let tripleTouchesGesture = UITapGestureRecognizer(target: self, action: #selector(gestureRecognizer(gesture:))); tripleTouchesGesture.numberOfTapsRequired = 1; tripleTouchesGesture.numberOfTouchesRequired = 3; tripleTouchesGesture.delegate = self; self.addGestureRecognizer(tripleTouchesGesture); // 縮放 let pinchGesture = UIPinchGestureRecognizer(target: self, action: #selector(gestureRecognizer(gesture:))); pinchGesture.delegate = self; self.addGestureRecognizer(pinchGesture); // 移動 let panGesture = UIPanGestureRecognizer(target: self, action: #selector(gestureRecognizer(gesture:))); panGesture.minimumNumberOfTouches = 2; panGesture.delegate = self; self.addGestureRecognizer(panGesture); // 旋轉 let rotationGesture = UIRotationGestureRecognizer(target: self, action: #selector(gestureRecognizer(gesture:))); rotationGesture.delegate = self; self.addGestureRecognizer(rotationGesture)
我們需要旋轉、移動和縮放同時觸發並且在觸發旋轉、移動或者縮放的時候雙指點選不能被觸發,但是如果使用者使用三指點選時,三指手勢要優先觸發。因此需要對手勢的delegate做一點處理
// MARK: - UIGestureRecognizerDelegate extension CanvasContentView: UIGestureRecognizerDelegate { func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { // 各手勢之間要併發進行 return true; } func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldBeRequiredToFailBy otherGestureRecognizer: UIGestureRecognizer) -> Bool { if (gestureRecognizer is UIPanGestureRecognizer || gestureRecognizer is UIRotationGestureRecognizer || gestureRecognizer is UIPinchGestureRecognizer) && otherGestureRecognizer is UITapGestureRecognizer { // 移動、旋轉、縮放時要避免雙指點選觸發 if otherGestureRecognizer.numberOfTouches == 3 { // 三指點選時使用者意圖明顯,因此要優先觸發 return false; } return true; } return false; } }
這樣各種手勢就可以相互配達到我們的需求
繞固定點的旋轉

繞固定點旋轉
如上圖,如果是畫布繞其中心旋轉是很容易實現的,不需要調整View原點位置直接旋轉θ角度即可。如果旋轉點不在畫布中心處理起來就要麻煩一點。有兩種方案可以實現
- 1、調整anchorPoint把View座標原點移動到旋轉點位置,然後通過transform設定讓View旋轉θ
- 2、拆解繞點旋轉變換為:先把View中心移動到目標位置,然後旋轉θ角度
分析一下看一下哪種方案更合適,如果調整anchorPoint必然會引起frame的改變,也就是center位置的變化,需要在anchorPoint調整之後恢復center的位置,另外如果View在初始狀態是比較容易通過旋轉中心點的座標推算出anchorPoint的新位置,但是一旦View發生了旋轉就很難再計算出新的anchorPoint的位置。而方案2只需要計算出旋轉過程中View中心點的位置變化即可。
根據之前的理論知識座標系中的一個點繞另一個點的旋轉變換可以表示為:

化簡之後為:

看一下部分程式碼實現:
private func rotateAt(center: CGPoint, rotation: CGFloat) { self.gestureParams.rotation = self.gestureParams.rotation + rotation; // x = (x1 - x0)cosθ - (y1 - y0)sinθ + x0 // y = (y1 - y0)cosθ + (x1 - x0)sinθ + y0 let x1 = self.canvasView!.center.x; let y1 = self.canvasView!.center.y; let x0 = center.x; let y0 = self.bounds.size.height - center.y; let x = (x1 - x0) * cos(rotation) - (y1 - y0) * sin(rotation) + x0 let y = (y1 - y0) * cos(rotation) + (x1 - x0) * sin(rotation) + y0; self.canvasView!.center = CGPoint(x: x, y: y); self.canvasView!.transform =CGAffineTransform.identity.rotated(by: self.gestureParams.rotation).scaledBy(x: self.gestureParams.scale, y: self.gestureParams.scale); }
以固定點為中心縮放

以固定點為中心縮放
跟旋轉類似以固定點為中心的縮放依然可以選擇兩種方案,我們依然以選擇第二中方案,先把中心點移動到目標位置然後進行縮放
變換矩陣表示為:

化簡為:

看一下部分程式碼
private func scaleAt(center: CGPoint, scale: CGFloat) { // x' = Sx(x - x0) + x0 // y' = Sy(y - y0) + y0 let formerScale = self.gestureParams.scale; self.gestureParams.scale = scale * self.gestureParams.scale; self.gestureParams.scale = min(max(self.minScale, self.gestureParams.scale), self.maxScale); let currentScale = self.gestureParams.scale/formerScale; let x = self.canvasView!.center.x; let y = self.canvasView!.center.y; let x1 = currentScale * (x - center.x) + center.x; let y1 = currentScale * (y - center.y) + center.y; self.canvasView!.center = CGPoint(x: x1, y: y1); self.canvasView!.transform =CGAffineTransform.identity.rotated(by: self.gestureParams.rotation).scaledBy(x: self.gestureParams.scale, y: self.gestureParams.scale); }
手勢資訊收集和轉換
最主要的問題其實都已經解決掉了,接下來就是把手勢資訊轉換為我們需要的資料即可,這裡不做過多的解釋了,直接貼程式碼:
// MARK: - Gestures extension CanvasContentView { @objc func gestureRecognizer(gesture: UIGestureRecognizer) { if self.canvasView != nil { switch gesture { case is UIPinchGestureRecognizer: let pinchGesture = gesture as! UIPinchGestureRecognizer; if pinchGesture.state == .began || pinchGesture.state == .changed { // 計算縮放的中心點和縮放比例,每次縮放的比例需要累計 var center = pinchGesture.location(in: self); if pinchGesture.numberOfTouches == 2 { let center0 = pinchGesture.location(ofTouch: 0, in: self); let center1 = pinchGesture.location(ofTouch: 1, in: self); center = CGPoint(x: (center0.x + center1.x)/2, y: (center0.y + center1.y)/2); } self.scaleAt(center: center, scale: pinchGesture.scale); pinchGesture.scale = 1; self.delegate?.canvasContentView(self, scale: self.gestureParams.scale); } break; case is UIPanGestureRecognizer: let panGesture = gesture as! UIPanGestureRecognizer; let location = panGesture.location(in: self); ifpanGesture.state == .began { // 記錄開始位置 self.gestureParams.from = location; self.gestureParams.lastTouchs = gesture.numberOfTouches; }else if panGesture.state == .changed { if self.gestureParams.lastTouchs != panGesture.numberOfTouches { self.gestureParams.from = location; } // 計算偏移量 self.gestureParams.lastTouchs = panGesture.numberOfTouches; let x = location.x - self.gestureParams.from.x; let y = location.y - self.gestureParams.from.y; self.gestureParams.from = location; self.translate(x: x, y: y); self.delegate?.canvasContentView(self, x: x, y: y); } break; case is UIRotationGestureRecognizer: let rotatioGesture = gesture as! UIRotationGestureRecognizer; if rotatioGesture.state == .began || rotatioGesture.state == .changed { // 計算旋轉的中心點和旋轉角度,每次旋轉的角度需要累計 var center = rotatioGesture.location(in: self); if rotatioGesture.numberOfTouches == 2 { let center0 = rotatioGesture.location(ofTouch: 0, in: self); let center1 = rotatioGesture.location(ofTouch: 1, in: self); center = CGPoint(x: (center0.x + center1.x)/2, y: (center0.y + center1.y)/2); } self.rotateAt(center: center, rotation: rotatioGesture.rotation); rotatioGesture.rotation = 0; self.delegate?.canvasContentView(self, rotation: self.gestureParams.rotation); } break; case is UITapGestureRecognizer: let tapGesture = gesture as! UITapGestureRecognizer; if tapGesture.numberOfTouches == 2 { self.delegate?.canvasContentView(self, tapTouches: 2); }else if tapGesture.numberOfTouches == 3 { self.delegate?.canvasContentView(self, tapTouches: 3); } break; default: break; } } } }
完整程式碼
ofollow,noindex">https://github.com/fuxiaoghost/CanvasContentView
寫了很多,總結一句,UIView在二維狀態下的形變多數情況都可以轉換為仿射變換或者多個仿射變換的複合變換,從而用矩陣運算的知識解決。以後再遇到比較有意思的問題我會繼續補充……