最近在重構之前上架的一款畫板應用,期間用到了一些UIView的transform相關的特性。借此機會也系統整理了一下transform相關的知識。
在進入正題之前需要補充一點線性代數(數學專業應該叫高等代數)相關的知識。
齊次坐標系
所謂齊次坐標系就是將一個原本是n維的向量用一個n+1維向量來表示。對于一個向量v以及基oabc,可以找到一組坐標(v1,v2,v3)使得v=v1a+v2b+v3c(1-1)。而對于一個點p,則可以找到一組坐標(p1,p2,p3)使得p - o = p1a + p2b + p3c(1-2)
從上面對向量和點的表達,我們可以看出為了在坐標系中表示一個點我們可以把點的位置看作是對于這個基的原點o所進行的一個位移,即一個向量p - o,我們在表達這個向量的同時用等價的方式表達出了點p: p = o + p1a + p2b + p3c(1-3)。(1-1),(1-3)是坐標系下表達一個向量和點的不同表達方式。這里可以看出,雖然都是用代數分量的形式表達向量和點,但表達一個點比一個向量需要額外的信息。如果我寫一個代數分量表達(1,4,7),誰知道它是個向量還是一個點。我們現在把(1-1),(1-3)寫成矩陣的形式:
這里(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' = f1(x,y), y' = f2(x,y)
因此,函數f1,f2能夠確定一個平面上的變換T。如果能夠從方程組中反解出x和y:
x = g1(x', y'), y = g2(x', y')
則由函數g1,g2確定了T的逆變換,記為T-1。設平面曲線C的參數方程為:
x = x(t), y = y(t), t∈D
其中D是函數x(t),y(t)的定義域,則曲線C在變換T下的像T(C)的參數方程為
x = f1(x(t),y(t)), y = f2(x(t), y(t)), t∈D
平面幾何變換及其矩陣表示
平面圖形幾何變換
1、平移變換
平移變換是將圖形中的每一個點從一個位置(x,y)移動到另一個位置(x',y')的變換,tx,ty稱為平移距離,則平移變換公式為:
2、旋轉變換
旋轉變換是以某個參考點為圓心,將圖像上的各點(x,y)圍繞圓心轉動一個逆時針角度θ,變為新的坐標(x',y')的變換。當參考點為(0,0)時,旋轉變換的公式為:
由于:
所以可化簡為:
3、比例變換
比例變換是使對象按比例因子(sx,sy)放大或縮小的變換
平面圖形幾何變換的矩陣表示
從變換功能上可以把T2D分為四個子矩陣。其中
是對圖形的縮放、旋轉、對稱、錯切等變換;
是對圖形進行平移變換;
是對圖形作投影變換,g的作用是在x軸的1/g處產生一個滅點,而h的作用是在y軸的1/h處產生一個滅點;i是對整個圖形做伸縮變換。平移變換、旋轉變換、比例變換、錯切變換這4中基本變換都可以表示為3x3的變換矩陣和齊次坐標相乘的形式
1、平移變換的矩陣表示
平移變換的矩陣表示為
tx,ty分別表示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、復合變換
一個比較復雜的變換要連續進行若干個基本變換才能完成。例如圍繞任意點(xf, yf)的旋轉,需要通過3個基本變換T(xf, yf),R(θ),T(xf, yf)才能完成。這些由基本變換構成的連續變換序列稱為復合變換。
變換的矩陣形式使得復合變換的計算工作量大為減少。以繞任意點旋轉為例,本應進行如下3次變換,分別是
- p' = pT(-xf, -yf) 將原點移動到任意點位置
- p'' = p'R(θ) 旋轉
- p = p''T(xf, yf) 將原點歸位
合并之后為p = pT(-xf, -yf)R(θ)T(xf, yf)
令Tc = T(-xf, -yf)R(θ)T(xf, yf)則有p = pTc,Tc稱為復合變換矩陣。由上面推到可知在計算復合變換時,首先可將各基本變換矩陣按次序想乘,形成總的復合變換矩陣Tc然后,坐標只需與Tc想乘一次,便可同時完成一連串基本變換。因此采用復合變換矩陣可以大大節省坐標乘法所耗費的運算時間。下面我們看幾個基本的復合變換:
復合平移:
對同一圖形做兩次平移相當于將兩次平移相加起來,即
復合縮放:
以原點為參考點對同一圖形做兩次連續的縮放相當于將縮放操作相乘,即:
復合旋轉:
以原點為參考點對同一圖形做兩次連續的旋轉相當于將兩次的旋轉角度相加, 即:
縮放、旋轉變換都與參考點有關,上面進行的各種縮放、旋轉變換都是以原點為參考點的。如果相對某個一般的參考點(xf,yf)作縮放、旋轉變換,相當于將該點移到坐標原點處,然后進行縮放、旋轉變換,最后將(xf,yf)點移回原來的位置。如關于(xf,yf)的縮放變換為:
各種復雜的變換無非是一些基本變換的組合,利用數學方法也就是矩陣的 乘法來解決復合變換問題,關鍵是將其分解為一定順序的基本變換,然后逐一 進行這些基本變換;或者求出這些基本變換矩陣連乘積,即求出復合變換矩陣, 從而使復合變化問題得到解決。
寫了這么多只是想把平面仿射變換的基本原理描述清楚,以便能對UIView.transform有更深入的理解。
接下來我們進入正題
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視圖中心為坐標原點,x軸從原點向右遞增,y軸從原點向下遞增,通過改變UIView的transform可以對其進行仿射變換,如上面我們提到的縮放、旋轉、平移、切變等。有了這個特性UIView能做的事情就更多了,當然也可以借此做更有意思的動畫。
在內部坐標系中原點的位置可以通過anchorPoint調整,UIView沒有開放出來,可以訪問CALayer獲取。
參考上圖通過調整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的調整
見上圖以旋轉變換為例,旋轉變換會讓原有圖形的frame從白色框變為虛線框,我們假設原有View的四個點為p0 p1 p2 p3 則旋轉變換之后的點為:
p0' = p0T(θ)
p1' = p1T(θ)
p2' = p2T(θ)
p3' = p3T(θ)
則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);
if panGesture.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;
}
}
}
}
完整代碼
https://github.com/fuxiaoghost/CanvasContentView
寫了很多,總結一句,UIView在二維狀態下的形變多數情況都可以轉換為仿射變換或者多個仿射變換的復合變換,從而用矩陣運算的知識解決。以后再遇到比較有意思的問題我會繼續補充……