SwiftUI：画像のDrag&Pinchで移動・拡大縮小・回転させるテクニック

![CoolTechDragPinch.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2212777/8ac5ccd6-0c73-28e9-81bd-6ebbab2fd099.jpeg)
SwiftUIで 画像（オブジェクト）をドラッグ移動したり、拡大縮小したり、回転したり、という実装を考える場合、普通にAppleドキュメントやネット記事を参考にコードを書くと描画座標がうまくいかないことに遭遇する。
これは、SwiftUIがView座標系のデバイス投影を変化させて移動、拡縮、回転を実現することに関係する。画像オブジェクトそのものの座標を変更している訳ではない。頭の中で座標系を考えるときに注意が必要となる。
この理由を、まずは、[うまくいかない]コード例で理解してから、[うまくいく]コードをご覧ください。

### [うまくいかない]コード例
Netで入手できるドキュメントや記事を参考に書けば、割と簡単に動くSwiftUIコードができる。
しかし（たぶん多くのケースで）下記のような感じで座標がおかしい動きになると思われます。例えば、Pinch拡大縮小による座標変化の影響がDrag移動に出てきて、拡大の際に中心がズレるし、移動の際にカーソルから外れてしまうという[うまくいかない]状況。
![badSample.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2212777/83731ceb-e768-70c0-8d68-74c5ff0ce169.gif)

```swift:badSample.swift
import SwiftUI

struct ContentView: View {
    @State var offset:CGSize = .zero // drag value
    @State var lastOffset: CGSize = .zero // hold last drag value
    @State var scale:CGFloat = 1.0 // pinch scale value
    @State var lastScale: CGFloat = 1.0 // hold last scale value
    let imageWidth:CGFloat  = 160 // object width  for initial placement
    let imageHeight:CGFloat = 120 // object height for initial placement
    
    var dragGesture: some Gesture {
        DragGesture()
            .onChanged {
                offset = CGSize(width: lastOffset.width + $0.translation.width, height: lastOffset.height + $0.translation.height)
            }
            .onEnded{ _ in
                lastOffset = offset
            }
    }
    var scaleGuesture: some Gesture {
        MagnificationGesture()
            .onChanged {
                scale = $0 * lastScale
            }
            .onEnded{ _ in 
                lastScale = scale
            }
    }
    
    var body: some View {
        ZStack {

            Image(systemName: "photo.fill") // sample drag image
                .resizable()
                .frame(width: imageWidth, height: imageHeight) // placement size
                .offset(offset)
                .scaleEffect(scale)
                .gesture(dragGesture)
                .simultaneousGesture(scaleGuesture)
            
            VStack {
                Spacer()
                Button(action: {
                    offset = .zero; lastOffset = .zero
                    scale = 1.0; lastScale = 1.0
                }, label: { Text("Reset") } )
            }
        }
    }
}
```

こうなると座標の補正が必要になる、そこで、下記部分に拡大縮小（scale）の影響を補正する変換式を加える。

```swift:modify
    var dragGesture: some Gesture {
        DragGesture()
            .onChanged {
// before         offset = CGSize(width: lastOffset.width + $0.translation.width, height: lastOffset.height + $0.translation.height)
// change ->>
                offset = CGSize(width: lastOffset.width + $0.translation.width/lastScale, height: lastOffset.height + $0.translation.height/lastScale)
            }
   :
    var scaleGuesture: some Gesture {
        MagnificationGesture()
            .onChanged {
                scale = $0 * lastScale
// add code ->>
                offset = CGSize(width: lastOffset.width/scale, height: lastOffset.height/scale)
            }
            .onEnded{ _ in
                lastScale = scale
// add code ->>
                lastOffset = offset
            }
    }
```

（上記変更）すると、座標のズレは無くなり、うまく動くコードに修正できる。

さて、ここに、さらに回転ジェスチャ認識と回転移動を追加することを考える、すると、さらに厄介なことになるので...ここでは省略するが、回転での座標補正となると、(x', y') = (x * cosθ - y * sinθ, x * sinθ + y * cosθ) という座標変換式も登場することになり、...これを うまく座標補正するのはかなり厄介である。
実は、それよりも ずっと 良い方法がある。

## [うまくいく]コード
結論から言えば、座標補正の変換式など書かなくても良いのである。
Viewのモディファイアの順番さえ注意すれば、座標変換は不要となる。
・ここでのView：　Image(systemName: "photo.fill")
・ここでのモディファイア：　.rotationEffect .scaleEffect .offset  【参考：下記コード中の★★★】
![DragAndPinchView.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2212777/e2867b65-b63f-1ec4-20b7-0e1e3166f815.gif)

```swift:ObjectDragAndPinch.swift
import SwiftUI

struct ContentView: View {
    @State var offset:CGSize = .zero // drag value
    @State var lastOffset: CGSize = .zero // hold last drag value
    @State var scale:CGFloat = 1.0 // pinch scale value
    @State var lastScale: CGFloat = 1.0 // hold last scale value
    @State var angle:Angle = .zero // pinch angle value
    @State var lastAngle:Angle = .zero // hold last angle value
    let minScale = 0.2 // minimum scale value
    let maxScale = 5.0 // maximum scale value
    let imageWidth:CGFloat  = 160 // object width  for initial placement
    let imageHeight:CGFloat = 120 // object height for initial placement
    
    var dragGesture: some Gesture {
        DragGesture()
            .onChanged {
                offset = CGSize(width: lastOffset.width + $0.translation.width, height: lastOffset.height + $0.translation.height)
            }
            .onEnded{ _ in
                lastOffset = offset
            }
    }
    var scaleGuesture: some Gesture {
        MagnificationGesture()
            .onChanged {
                if ($0 > minScale) && ($0 < maxScale) { // scaling range for pinch
                    scale = $0 * lastScale
                }
            }
            .onEnded{ _ in
                lastScale = scale
            }
    }
    var rotateGesture: some Gesture {
        RotationGesture(minimumAngleDelta: .degrees(8)) // minimun start angle = 8degrees
            .onChanged {
                angle = $0 + lastAngle
            }
            .onEnded { _ in
                lastAngle = angle
            }
    }
    
    var body: some View {
        ZStack {

            Image(systemName: "photo.fill") // sample drag image
                .resizable()
                .frame(width: imageWidth, height: imageHeight) // placement size
            // ★★★ the following order is required for coordinate match ★★★
                .rotationEffect(angle, anchor: .center) // rotationEffect must be first
                .scaleEffect(scale) // scaleEffect must be after rotationEffect
                .offset(offset) // offset is last
            // multiple gesture :Drag to move, Pinch to scale, Pinch to rotate
                .gesture(dragGesture)
                .gesture(SimultaneousGesture(rotateGesture, scaleGuesture))
            
            VStack {
                Spacer()
                Button(action: {
                    offset = .zero; lastOffset = .zero
                    scale = 1.0; lastScale = 1.0
                    angle = .zero; lastAngle = .zero
                }, label: { Text("Reset") } )
            }
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}
```
参考: <https://github.com/daisymind/DragAndPinchView> ：SwiftUI Project ダウンロード可

:::note info
環境情報：
Xcode 14.3, Swift 5.8
:::

編集後記：
実は...この結論に辿り着くまで（気がつくまで）に、幾何学計算でかなり悩んでいる。紙と鉛筆で謎解きに悪戦苦闘したが、結局、順番に座標変換するということは...と気が付けば、何のことはない・・・これでイイのである。