SwiftUIで 画像(オブジェクト)をドラッグ移動したり、拡大縮小したり、回転したり、という実装を考える場合、普通にAppleドキュメントやネット記事を参考にコードを書くと描画座標がうまくいかないことに遭遇する。
これは、SwiftUIがView座標系のデバイス投影を変化させて移動、拡縮、回転を実現することに関係する。画像オブジェクトそのものの座標を変更している訳ではない。頭の中で座標系を考えるときに注意が必要となる。
この理由を、まずは、[うまくいかない]コード例で理解してから、[うまくいく]コードをご覧ください。
[うまくいかない]コード例
Netで入手できるドキュメントや記事を参考に書けば、割と簡単に動くSwiftUIコードができる。
しかし(たぶん多くのケースで)下記のような感じで座標がおかしい動きになると思われます。例えば、Pinch拡大縮小による座標変化の影響がDrag移動に出てきて、拡大の際に中心がズレるし、移動の際にカーソルから外れてしまうという[うまくいかない]状況。
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)の影響を補正する変換式を加える。
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 【参考:下記コード中の★★★】
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
編集後記:
実は...この結論に辿り着くまで(気がつくまで)に、幾何学計算でかなり悩んでいる。紙と鉛筆で謎解きに悪戦苦闘したが、結局、順番に座標変換するということは...と気が付けば、何のことはない・・・これでイイのである。