3
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Last updated at Posted at 2022-10-07

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

[うまくいかない]コード例

Netで入手できるドキュメントや記事を参考に書けば、割と簡単に動くSwiftUIコードができる。
しかし(たぶん多くのケースで)下記のような感じで座標がおかしい動きになると思われます。例えば、Pinch拡大縮小による座標変化の影響がDrag移動に出てきて、拡大の際に中心がズレるし、移動の際にカーソルから外れてしまうという[うまくいかない]状況。
badSample.gif

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)の影響を補正する変換式を加える。

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

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 ダウンロード可

環境情報:
Xcode 14.3, Swift 5.8

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

3
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?