はじめに

今回はモデルのジェスチャー操作を統合する方法について解説します。

モデルの移動、スケール、回転をジェスチャー操作で実現する際は、それぞれ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



ギャップロを運営しているアップフロンティア株式会社では、一緒に働いてくれる仲間を随時、募集しています。 興味がある!一緒に働いてみたい!という方は下記よりご応募お待ちしております。
採用情報をみる