😬

[Swift] CGAffineTransformで、scaleしてからtranslateするときは注意

2024/08/04に公開

TL; DR;

CGAffineTransformで、Viewに複数の移動や変形を適用する時は、その適用順によって結果が変わる。
理由:
CGAffineTransformは行列の乗算によって移動先の場所を計算する。
行列の乗算は交換法則が成り立たないので、適応する順番が大事。

CGAffineTransformとは何か

Viewの移動や回転、変形のために使用される3x3行列を表す構造体。

\begin{pmatrix} a & b & 0\\ c & d & 0 \\ t_x & t_y & 1 \end{pmatrix}

3行目は常に[0,0,1]になっている。
その他の部分 (a, b, c, d, t_x, t_y)は、変数になっていて、実現したい移動によって変更できる。

[x,y]のAffineTransformによる移動先[x', y']は、以下のように計算される。

[x', y', 1]=[x, y, 1]\times \begin{pmatrix} a & b & 0\\ c & d & 0 \\ t_x & t_y & 1 \end{pmatrix}

ここで"\times"は行列の乗算を表す。
このとき、

x' = ax +cy + t_x \\ y' = bx + dy + t_y

になる。

CGAffineTransformの例

同じoriginとsizeの2つのViewがある。(黄色のViewと、青のView)

青いViewをAffineTransformする。

Scale

縦横をそれぞれ半分(0.5倍)にする。

// Scale: 0.5
let transform = CGAffineTransform(scaleX: 0.5, y: 0.5)
view2.transform = transform


このときのAffine行列は、

\begin{pmatrix} 0.5 & 0 & 0\\ 0 & 0.5 & 0 \\ 0 & 0 & 1 \end{pmatrix}

Translate

Viewのsizeはそのまま、x,y方向にそれぞれ50ずつ平行移動する。

// Translate: 50
let transform = CGAffineTransform(translationX: 50, y: 50)
view2.transform = transform


このときのAffine行列は、

\begin{pmatrix} 1 & 0 & 0\\ 0 & 1 & 0 \\ 50 & 50 & 1 \end{pmatrix}

Rotation

Viewのsizeと中心はそのまま、45^\circ回転する。

// Rotation: 45
let transform = CGAffineTransform(rotationAngle: 45 * .pi / 180)
view2.transform = transform


このときのAffine行列は、

\begin{pmatrix} \cos 45^\circ & \sin 45^\circ & 0\\ -\sin 45^\circ & \cos 45^\circ & 0 \\ 0 & 0 & 1 \end{pmatrix}

複数の操作を適用するとき

たとえば、Aという操作とBという操作を適用させたいとき

let transform = CGAffineTransform(A).someTransform(B)

のように書くと

[x', y', 1]=[x, y, 1]\times B \times A

のような順番で計算される。(コードではA → Bのように書いているが、実際はB → Aの順で適用される)
行列の乗算での交換法則は、一般的には成り立たない(A \times B \neq B \times A)ので、適用順によって、結果が変化する。

concatenatingを使用すると、コードに書いた順で、操作が適用される。
以下のコードは、変形や移動の操作は、A → Bの順で適用される。

let transform = CGAffineTransform(A).concatenating(CGAffineTransform(B))

Scale -> Translateの順番で適用させたいとき

let transform = CGAffineTransform(translationX: 50, y: 50).scaledBy(x: 0.5, y: 0.5)
view2.transform = transform

もしくは、

let transform2 = CGAffineTransform(scaleX: 0.5, y: 0.5).concatenating(CGAffineTransform(translationX: 50, y: 50))
view2.transform = transform


このときのAffine行列は、

Scale \times Translate = \begin{pmatrix} 0.5 & 0 & 0\\ 0 & 0.5 & 0 \\ 0 & 0 & 1 \end{pmatrix} \times \begin{pmatrix} 1 & 0 & 0\\ 0 & 1 & 0 \\ 50 & 50 & 1 \end{pmatrix} \\ = \begin{pmatrix} 0.5 & 0 & 0\\ 0 & 0.5 & 0 \\ 50 & 50 & 1 \end{pmatrix}

Translate → Scaleの順番で適用させたいとき

// Translate: 50 -> Scale 0.5
let transform = CGAffineTransform(scaleX: 0.5, y: 0.5).translatedBy(x: 50, y: 50)
view2.transform = transform

もしくは、

let transform2 = CGAffineTransform(translationX: 50, y: 50).concatenating(CGAffineTransform(scaleX: 0.5, y: 0.5))
view2.transform = transform


このときのAffine行列は、

Translate \times Scale = \begin{pmatrix} 1 & 0 & 0\\ 0 & 1 & 0 \\ 50 & 50 & 1 \end{pmatrix} \times \begin{pmatrix} 0.5 & 0 & 0\\ 0 & 0.5 & 0 \\ 0 & 0 & 1 \end{pmatrix} \\ = \begin{pmatrix} 0.5 & 0 & 0\\ 0 & 0.5 & 0 \\ 25 & 25 & 1 \end{pmatrix}

上記の2つの例を比較すると、Translate → ScaleとScale → Translateの場合で、Affine行列と最終的なViewが異なる。

まとめ

CGAffineTransformで、Viewに複数の移動や変形を適用する時は、その適用順によって結果が変わる。
CGAffineTransformは行列の乗算によって移動先の場所を計算する。
行列の乗算は交換法則が成り立たないので、適応する順番が大事。

Sample Code

使用したサンプルコード
import UIKit

class ViewController: UIViewController {
    
    let view1 = UIView()
    let view2 = UIView()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        setupView()
//        setupSample_Scale()
//        setupSample_Translate()
//        setupSample_Rotation()
        
//        setupSample_Scale_Translate()
//        setupSample_Translate_Scale()
    }
    
    private func setupView() {
        view.addSubview(view1)
        view.addSubview(view2)
        
        view1.backgroundColor = .yellow
        view2.layer.borderColor = UIColor.blue.cgColor
        view2.layer.borderWidth = 5
        
        view1.frame = CGRect(x: 100, y: 100, width: 200, height: 200)
        view2.frame = CGRect(x: 100, y: 100, width: 200, height: 200)
    }
    
    // Scale: 0.5
    private func setupSample_Scale() {
        let transform = CGAffineTransform(scaleX: 0.5, y: 0.5)
        view2.transform = transform
        print(transform)
    }
    
    // Translate: 50
    private func setupSample_Translate() {
        let transform = CGAffineTransform(translationX: 50, y: 50)
        view2.transform = transform
    }
    
    // Rotation: 45
    private func setupSample_Rotation() {
        let transform = CGAffineTransform(rotationAngle: 45 * .pi / 180)
        view2.transform = transform
    }


    // Translate: 50 -> Scale 0.5
    private func setupSample_Scale_Translate() {
        let transform = CGAffineTransform(scaleX: 0.5, y: 0.5).translatedBy(x: 50, y: 50)
//        let transform = CGAffineTransform(translationX: 50, y: 50).concatenating(CGAffineTransform(scaleX: 0.5, y: 0.5))
        view2.transform = transform
    }
    
    // Scale: 0.5 -> Translate: 50
    private func setupSample_Translate_Scale() {
        let transform = CGAffineTransform(translationX: 50, y: 50).scaledBy(x: 0.5, y: 0.5)
//        let transform = CGAffineTransform(scaleX: 0.5, y: 0.5).concatenating(CGAffineTransform(translationX: 50, y: 50))
        view2.transform = transform
    }
}

References

Discussion