はじめに
今回はモデルのジェスチャー操作を統合する方法について解説します。
モデルの移動、スケール、回転をジェスチャー操作で実現する際は、それぞれDragGesture、MagnifyGesture、 RotateGesture3Dを使用することが多いかと思います。
これらのジェスチャーを統合することで、「移動しながら回転させる」といったように、ジェスチャーで操作中に別のジェスチャーを実行する、という処理が可能になります。
この記事では、SwiftUIのViewであるModel3Dでモデルを表示する場合の処理を書いております。
Entityの場合は今回の方式は採用できません。Entityを使用する場合は以下の記事をご覧ください。
【visionOS】複数のジェスチャー操作を統合する(Entity編)
環境
- Swift Version: 6.0
- Xcode: 16.1
- visionOS: 2.1
- macOS: Sonoma 14.7
前準備
今回は、おもちゃのモデルを配置し、複数のジェスチャーで操作する、というシンプルな処理を書いてみます。
冒頭にも書きましたが、ジェスチャーはそれぞれ以下の構造体を使用しています。
- モデルの移動: DragGesture (片手でつまんで移動させる)
- モデルのスケール: MagnifyGesture (両手でつまんで広げる or 狭める)
- モデルの回転: RotateGesture3D (両手でつまんで上下左右に動かす)
import SwiftUI
import RealityKit
struct ImmersiveView: View {
var body: some View {
RealityView { content, attachments in
/// Model3Dを空間に設置
if let model3d = attachments.entity(for: "Model3D") {
model3d.position = [0, 1.0, -1.0]
content.add(model3d)
}
} update: { _, _ in
} attachments: {
Attachment(id: "Model3D") {
/// モデルをModel3Dで表示
Model3D(named: "Toy") { phase in
switch phase {
case .empty:
ProgressView()
case let .failure(error):
Text(error.localizedDescription)
case let .success(model):
model
.resizable()
.scaledToFit()
@unknown default:
fatalError()
}
}
.frame(depth: nil, alignment: .center)
.frame(width: 300)
}
}
}
}ViewModifierの作成
ジェスチャーの値を反映させるため、自作のViewModifierを作り、Viewに付与する形式で実装します。
import SwiftUI
/// Model3Dにジェスチャーを付与するViewModifier
/// ジェスチャー終了時もモデルの状態を保持する
struct KeepManipulationModifierForModel3D: ViewModifier {
/// 現在のモデルの状態(AffineTransform3D)
@State private var manipulationTransform: AffineTransform3D = .identity
func body(content: Content) -> some View {
content
.scaleEffect(manipulationTransform.scale)
.rotation3DEffect(manipulationTransform.rotation ?? .identity)
.offset(x: manipulationTransform.translation.x,
y: manipulationTransform.translation.y)
.offset(z: manipulationTransform.translation.z)
.animation(.spring, value: manipulationTransform)
}
}モデルの位置、スケール、回転の情報をAffineTransform3Dで保存しておきます。
AffineTransform3Dはアフィン変換を行列として定義するデータです。アフィン変換とは位置、スケール、回転をまとめて管理するアルゴリズムです。
後述しますが、ジェスチャーの情報をAffineTransform3Dに変換し、Viewの.gestureモディファイアで受け取り更新作業を行う、という流れになります。
複数のジェスチャーを結合する
複数のジェスチャーを結合する場合、SimultaneousGestureを使用します。
SimultaneousGesture
A simultaneous gesture is a container-event handler that evaluates its two child gestures at the same time. Its value is a struct with two optional values, each representing the phases of one of the two gestures.
(翻訳) 同時ジェスチャは、2 つの子ジェスチャを同時に評価するコンテナ・イベント・ハンドラです。その値は、2 つのジェスチャの一方のフェーズを表す 2 つのオプションの値を持つ構造体です。
引用:https://developer.apple.com/documentation/swiftui/simultaneousgesture#overview
SimultaneousGestureを実装する場合は、Gestureプロトコルに準拠した構造体に対して、新しくジェスチャーを追加する関数simultaneously(with: Other)を定義して新しいジェスチャーを設定します。
DragGesture()
.simultaneously(with: MagnifyGesture())型はSimultaneousGesture<DragGesture, MagnifyGesture>.Valueです。プロパティのfirst、secondでそれぞれ1つ目のジェスチャー、2つ目のジェスチャーの値を読み取ることができます。
DragGesture()
.simultaneously(with: MagnifyGesture())
.map { gesture in
let drag: DragGesture.Value? = gesture.first
let magnify: MagnifyGesture.Value? = gesture.second
}今回実装する、ジェスチャーの条件としては、以下の2点です。
- 3つのジェスチャー(移動、スケール、回転)を統合する
- 一つになったジェスチャーの値からAffineTransform3Dへ変換する
3つ目のジェスチャーを統合するには、さらに.simultaneouslyモディファイアを追加します。
DragGesture()
.simultaneously(with: MagnifyGesture())
.simultaneously(with: RotateGesture3D()) /// 回転ジェスチャーを追加この場合のデータの型は、SimultaneousGesture<SimultaneousGesture<DragGesture, MagnifyGesture>, RotateGesture3D>.Valueです。
SimultaneousGesture<DragGesture, MagnifyGesture>がfirst、RotateGesture3Dがsecondに割り振られます。
取得したジェスチャーの値をAffineTransform3Dへ変換するには、これらのデータから個々に分解して位置、スケール、回転のデータに振り分けて使用します。今回はそれらをタプルにまとめて返却してます。
extension SimultaneousGesture<
SimultaneousGesture<DragGesture, MagnifyGesture>,
RotateGesture3D>.Value {
func components() -> (Vector3D, Size3D, Rotation3D) {
let translation = self.first?.first?.translation3D ?? .zero
let magnification = self.first?.second?.magnification ?? 1
let size = Size3D(width: magnification, height: magnification, depth: magnification)
let rotation = self.second?.rotation ?? .identity
return (translation, size, rotation)
}
}これらのデータを元にAffineTransform3Dを生成します。今回はViewModifierの方でジェスチャーを受け取る都合上、ViewModifierのextensionに実装しております。
extension ViewModifier {
func manipulationGesture() -> some Gesture<AffineTransform3D> {
DragGesture()
.simultaneously(with: MagnifyGesture())
.simultaneously(with: RotateGesture3D())
.map { gesture in
let (translation, scale, rotation) = gesture.components()
return AffineTransform3D(
scale: scale,
rotation: rotation,
translation: translation
)
}
}
}ジェスチャーの値をモデルに反映させる
ジェスチャー終了時のAffineTransform3Dを保存
ジェスチャーしている手を離した後も、モデルにその状態を維持させます。そのためには現在のモデルの
AffineTransform3Dとは別に、ジェスチャー終了時のAffineTransform3Dをプロパティで持たせます。
ジェスチャー終了検知コールバック.onEndedの中で、終了時のAffineTransform3Dを更新します。
struct KeepManipulationModifierForModel3D: ViewModifier {
/// 現在のモデルのAffineTransform3Dデータ
@State private var manipulationTransform: AffineTransform3D = .identity
/// ジェスチャー終了時のモデルのAffineTransform3Dデータ
@State private var manipulationTransformOnEnded: AffineTransform3D = .identity
func body(content: Content) -> some View {
content
.scaleEffect(manipulationTransform.scale)
.rotation3DEffect(manipulationTransform.rotation ?? .identity)
.offset(x: manipulationTransform.translation.x,
y: manipulationTransform.translation.y)
.offset(z: manipulationTransform.translation.z)
.animation(.spring, value: manipulationTransform)
.gesture(
manipulationGesture()
.onChanged({ value in
/// 更新作業(後述)
})
.onEnded({ value in
/// ジェスチャー終了時に現在のデータを保存
manipulationTransformOnEnded = manipulationTransform
})
)
}
}ジェスチャーの値でモデルを更新
AffineTransform3Dの更新
位置、スケール、回転にデータを分解して更新し、新しいAffineTransform3Dを返します。
extension AffineTransform3D {
func updated(with value: AffineTransform3D) -> AffineTransform3D {
var newTransform: AffineTransform3D = .identity
/// Update Translation
newTransform.translation = self.translation + value.translation
/// Update Scale
let newScale = self.scale.scaled(by: value.scale)
newTransform.scale(by: newScale)
/// Update Rotation
if
let rotation = value.rotation,
let newRotation = self.rotation?.rotated(by: rotation)
{
newTransform.rotate(by: newRotation)
}
return newTransform
}
}struct KeepManipulationModifierForModel3D: ViewModifier {
@State private var manipulationTransform: AffineTransform3D = .identity
@State private var manipulationTransformOnEnded: AffineTransform3D = .identity
func body(content: Content) -> some View {
content
.scaleEffect(manipulationTransform.scale)
.rotation3DEffect(manipulationTransform.rotation ?? .identity)
.offset(x: manipulationTransform.translation.x,
y: manipulationTransform.translation.y)
.offset(z: manipulationTransform.translation.z)
.animation(.spring, value: manipulationTransform)
.gesture(
manipulationGesture()
.onChanged({ value in
/// 更新
manipulationTransform = manipulationTransformOnEnded.updated(with: value)
})
.onEnded({ value in
manipulationTransformOnEnded = manipulationTransform
})
)
}
}ViewにViewModifierを付与
作成したViewModifierをViewに付与します。
extension View {
func keepManipulationForModel3D() -> some View {
self.modifier(KeepManipulationModifierForModel3D())
}
}import SwiftUI
import RealityKit
struct ImmersiveView: View {
...
Attachment(id: "Model3D") {
Model3D(named: "Toy") { phase in
...
}
.frame(depth: nil, alignment: .center)
.frame(width: 300)
/// 追加
.keepManipulationForModel3D()
}
...
}これでモデルを複数のジェスチャーで操作することができます。また、ジェスチャー終了後はモデルはその状態を維持しています。
ジェスチャー終了時にモデルを元の状態に戻す
ジェスチャー終了時に元の状態に戻すにはどうすれば良いでしょうか。
例えば、モデルの表示場所が固定されており、プレビューのために一時的に操作したい、という仕様があるかもしれません。
その場合はモデルの状態を@GestureStateのプロパティとして保持することで、ジェスチャーでの操作中のみ反映させることができます。
GestureState
Declare a property as @GestureState, pass as a binding to it as a parameter to a gesture’s updating(_:body:) callback, and receive updates to it. A property that’s declared as @GestureState implicitly resets when the gesture becomes inactive, making it suitable for tracking transient state.
(翻訳) プロパティを@GestureState として宣言し、ジェスチャのupdating(_:body:)コールバックのパラメータとしてそのプロパティへのバインディングとして渡し、そのプロパティの更新を受け取ります。GestureStateとして宣言されたプロパティは、ジェスチャが非アクティブになると暗黙的にリセットされるため、一時的な状態を追跡するのに適しています。
引用:https://developer.apple.com/documentation/swiftui/gesturestate
Gestureプロトコルに準拠した構造体で、updatingメソッドを実装します。第一引数に@GestureStateで宣言したプロパティ、第二引数に更新内容のクロージャを定義します。
/// Model3Dにジェスチャーを付与するViewModifier
/// ジェスチャー終了時に状態を元に戻す
struct ManipulationModifierForModel3D: ViewModifier {
@GestureState private var manipulationTransform: AffineTransform3D = .identity
func body(content: Content) -> some View {
content
.scaleEffect(manipulationTransform.scale)
.rotation3DEffect(manipulationTransform.rotation ?? .identity)
.offset(x: manipulationTransform.translation.x,
y: manipulationTransform.translation.y)
.offset(z: manipulationTransform.translation.z)
.animation(.spring, value: manipulationTransform)
.gesture(
/// ジェスチャーのアップデート中のみデータの更新を行う
/// ジェスチャー終了時に自動で初期値に戻る
manipulationGesture().updating($manipulationTransform, body: { value, state, _ in
state = value
})
)
}
}
ViewModifierをViewに付与します。
extension View {
func manipulationForModel3D() -> some View {
self.modifier(ManipulationModifierForModel3D())
}
}import SwiftUI
import RealityKit
struct ImmersiveView: View {
...
Attachment(id: "Model3D") {
Model3D(named: "Toy") { phase in
...
}
.frame(depth: nil, alignment: .center)
.frame(width: 300)
/// 変更
.manipulationForModel3D()
}
...
}終わりに
複数のジェスチャーを統合して操作する方法を書きました。
Model3Dは、モデルをプロパティとして保持できないことや、マテリアルなどの値が変更できないなど、制約が多いです。そのため、個人的にはEntityを動かす方法の方が需要が高いと考えます。方が需要が高いかと思います。
Model3Dで問題ない場合は本記事で記載した内容をそのまま活用できます。
お役に立てたら幸いです。
参考
WWDC2023: Take SwiftUI to the next dimension
WWDC2024: Explore game input in visionOS








