为 visionOS 应用添加 Rotation 旋转手势

探讨了如何在 visionOS 应用中,通过自定义的 dragRotation 修饰器为 3D 模型添加旋转手势。

为 visionOS 应用添加 Rotation 旋转手势

在 visionOS 应用开发中,交互性和沉浸感是提升用户体验的关键因素。通过为 3D 模型添加手势控制,用户可以自然地与虚拟对象互动,使应用更具吸引力。常见的手势交互包括缩放、平移和旋转,如何在应用中优雅地实现这种旋转手势?本文将详细介绍旋转手势,并提供一个易于实现且可复用的解决方案。

最终实现效果预览

我们将创建一个 dragRotation 修饰器组件,你可以将它应用到任何 SwiftUI 视图,或 RealityKit 渲染的 3D 模型上,使用户能够通过拖动手势旋转实体对象。以下是这个修饰器的实现效果:

用户可以左右旋转 3D 对象,也可以上下旋转。你也可以设置为,当用户尝试上下旋转时,组件会尝试自动复位。

创建 dragRotation 修饰器组件

手势识别涉及到计算和状态管理,实现代码量比较大。我们通过创建一个自定义的 dragRotation 修饰器组件,将手势操作封装起来,以便后续可以方便的使用它。

建议在 Xcode 项目中,创建一个 Gestures 文件夹,专用用于管理手势控制相关的组件。在这个文件夹中,创建 DragRotationModifier.swift 文件,完整代码如下(該组件参考自 Apple 提供的 HelloWorld 演示项目,其提供了优秀的用户体验):

import SwiftUI
import RealityKit

extension View {
    func dragRotation(
        yawLimit: Angle? = nil,
        pitchLimit: Angle? = nil,
        sensitivity: Double = 10,
        axRotateClockwise: Bool = false,
        axRotateCounterClockwise: Bool = false
    ) -> some View {
        self.modifier(
            DragRotationModifier(
                yawLimit: yawLimit,
                pitchLimit: pitchLimit,
                sensitivity: sensitivity,
                axRotateClockwise: axRotateClockwise,
                axRotateCounterClockwise: axRotateCounterClockwise
            )
        )
    }
}

private struct DragRotationModifier: ViewModifier {
    var yawLimit: Angle?
    var pitchLimit: Angle?
    var sensitivity: Double
    var axRotateClockwise: Bool
    var axRotateCounterClockwise: Bool

    @State private var baseYaw: Double = 0
    @State private var yaw: Double = 0
    @State private var basePitch: Double = 0
    @State private var pitch: Double = 0

    func body(content: Content) -> some View {
        content
            .rotation3DEffect(.radians(yaw == 0 ? 0.01 : yaw), axis: .y)
            .rotation3DEffect(.radians(pitch == 0 ? 0.01 : pitch), axis: .x)
            .gesture(DragGesture(minimumDistance: 0.0)
                .targetedToAnyEntity()
                .onChanged { value in
                    let location3D = value.convert(value.location3D, from: .local, to: .scene)
                    let startLocation3D = value.convert(value.startLocation3D, from: .local, to: .scene)
                    let delta = location3D - startLocation3D

                    withAnimation(.interactiveSpring) {
                        yaw = spin(displacement: Double(delta.x), base: baseYaw, limit: yawLimit)
                        pitch = spin(displacement: Double(delta.y), base: basePitch, limit: pitchLimit)
                    }
                }
                .onEnded { value in
                    let location3D = value.convert(value.location3D, from: .local, to: .scene)
                    let startLocation3D = value.convert(value.startLocation3D, from: .local, to: .scene)
                    let predictedEndLocation3D = value.convert(value.predictedEndLocation3D, from: .local, to: .scene)
                    let delta = location3D - startLocation3D
                    let predictedDelta = predictedEndLocation3D - location3D

                    withAnimation(.spring) {
                        yaw = finalSpin(
                            displacement: Double(delta.x),
                            predictedDisplacement: Double(predictedDelta.x),
                            base: baseYaw,
                            limit: yawLimit)
                        pitch = finalSpin(
                            displacement: Double(delta.y),
                            predictedDisplacement: Double(predictedDelta.y),
                            base: basePitch,
                            limit: pitchLimit)
                    }

                    baseYaw = yaw
                    basePitch = pitch
                }
            )
            .onChange(of: axRotateClockwise) {
                withAnimation(.spring) {
                    yaw -= (.pi / 6)
                    baseYaw = yaw
                }
            }
            .onChange(of: axRotateCounterClockwise) {
                withAnimation(.spring) {
                    yaw += (.pi / 6)
                    baseYaw = yaw
                }
            }
    }

    private func spin(
        displacement: Double,
        base: Double,
        limit: Angle?
    ) -> Double {
        if let limit {
            return atan(displacement * sensitivity) * (limit.degrees / 90)
        } else {
            return base + displacement * sensitivity
        }
    }

    private func finalSpin(
        displacement: Double,
        predictedDisplacement: Double,
        base: Double,
        limit: Angle?
    ) -> Double {
        guard limit == nil else { return 0 }

        let cap = .pi * 2.0 / sensitivity
        let delta = displacement + max(-cap, min(cap, predictedDisplacement))

        return base + delta * sensitivity
    }
}

上述代码定义一个 SwiftUI 扩展 dragRotation,它是一个自定义的修饰器组件,允许用户通过拖动手势旋转对象。这个组件可以限制旋转的角度(yawLimitpitchLimit),并根据用户的拖动动作调整旋转的灵敏度。

为 RealityView 添加 dragRotation

下面我像你演示,如何为 RealityKit 对象添加旋转控制,它非常的简单。

添加 .dragRotation() 修饰器

例如,我们使用 RealityView 加载一个模型:

import RealityKit
import RealityKitContent
import SwiftUI

struct DragEntity: View {

    var body: some View {
        RealityView { content in
            if let model = try? await Entity(
                named: "M4_Modular_Kit_Gun", in: realityKitContentBundle)
            {
                content.add(model)
                model.scale = SIMD3(repeating: 0.001)

            }
        }
        .dragRotation()
    }
}

#Preview {
    DragEntity()
}

.dragRotation()修改器添加到 RealityView,以启用它。

此时模型仍然不会对我们的手势做出响应,我们还需要为模型实体添加 InputTarget 和 Collision 组件,以便它能够检测到我们的手势输入。

理解 InputTarget 和 Collision 组件

在 RealityKit 中,一个 Entity 是一个基本的构建块,可以附加多个组件来扩展其功能。例如,为了让 Entity 能够渲染形状,我们可以将 .usdz 模型添加到 ModelComponent 组件。如果要实现空间音频,可以为 Entity 添加 SpatialAudio 组件。

同样的,为了让 Entity 能够正确地接收并响应用户输入,我们必须为模型添加 InputTarget 和 Collision 组件:

  • InputTargett 使实体成为可以接受输入的目标。
  • Collision 为实体提供了检测输入位置的必要边界。

InputTarget 组件说明

InputTargetComponent 是 RealityKit 中用于处理用户输入(例如点击、拖动、旋转等)的组件。当你将这个组件添加到实体时,你正在告诉 RealityKit,这个实体是可以接受输入的目标。

Collision 组件说明

generateCollisionShapes(recursive: true) 是用来为实体生成碰撞形状的。碰撞形状是虚拟的边界或体积,用于检测用户与实体的交互。例如,当你点击或拖动实体时,系统需要知道用户的输入是否与该实体发生了碰撞。

通过 Xcode 添加 InputTarget 和 Collision 组件

添加的方式有两种:

  • 直接通过 Xcode 编程方式添加
  • 通过 Reality Composer Pro 图形化方式添加

通过 Xcode 编程添加,我们只需添加两行代码。

model.components[InputTargetComponent.self] =
    InputTargetComponent(allowedInputTypes: .all)

通过将 InputTargetComponent 添加到实体,并设置 allowedInputTypes 为 .all,你允许该实体响应所有类型的用户输入(例如触摸、点击、拖动等)。如果没有这行代码,实体将不会响应任何用户输入,因为 RealityKit 不知道这个实体是一个可以处理输入的目标

model.components[InputTargetComponent.self] =
    InputTargetComponent(allowedInputTypes: .all)
model.generateCollisionShapes(recursive: true)

通过设置generateCollisionShapes生成碰撞形状使得 RealityKit 可以检测到用户输入的确切位置,并相应地更新模型的状态或动作。如果没有生成碰撞形状,即使实体能够接收输入(通过 InputTargetComponent),系统也无法正确检测到实体的边界,这意味着手势可能不会被正确处理或根本没有效果

完整代码如下:

import RealityKit
import RealityKitContent
import SwiftUI

struct DragEntity: View {

    var body: some View {
        RealityView { content in
            if let model = try? await Entity(
                named: "M4_Modular_Kit_Gun", in: realityKitContentBundle)
            {
                content.add(model)
                model.scale = SIMD3(repeating: 0.001)

                model.components[InputTargetComponent.self] =
                    InputTargetComponent(allowedInputTypes: .all)
                model.generateCollisionShapes(recursive: true)

            }
        }
        .dragRotation()
    }
}

#Preview {
    DragEntity()
}

此时,模型应该已经可以响应手势输入:

通过 Reality Composer Pro 添加 InputTarget 和 Collision 组件

在 Reality Composer Pro 中选中你的模型文件,点击右侧 Add Component:

搜索并添加「Input Target」和「Collision」组件并保存

回到 Xcode,即使我们注释掉添加 component 的代码,模型也能对我们的输入做出响应:

如果你在 Xcode 中选中模型,并选择查看源代码,会发现 Reality Composer Pro 也还是为 Entity 添加了这两个组件:

为 dragRotation 添加旋转限制

.dragRotation() 修饰器支持 yawLimitpitchLimit(旋转限制)参数,这些参数用于设置旋转角度的限制,分别控制模型在 Y 轴(水平)和 X 轴(垂直)上的旋转范围。传入的值是一个 Angle 类型,当用户拖动模型超过该限制时,模型的旋转角度将不会继续增加。

当你希望用户只能在一定范围内查看模型,而不是无限制地旋转模型时,设置这些限制是非常有用的。例如,展示一个汽车模型时,你可能希望用户只能查看车身的正面和两侧,而不希望看到底部。

添加纵向限制:

RealityView { content in
            if let model = try? await Entity(
                named: "M4", in: realityKitContentBundle)
            {
                content.add(model)
                //                model.scale = SIMD3(repeating: 0.001)

                model.components[InputTargetComponent.self] =
                    InputTargetComponent(allowedInputTypes: .all)
                model.generateCollisionShapes(recursive: true)

            }
        }
        .dragRotation(pitchLimit: .degrees(90))
    }

添加纵向及横向限制:

RealityView { content in
            if let model = try? await Entity(
                named: "M4", in: realityKitContentBundle)
            {
                content.add(model)
                //                model.scale = SIMD3(repeating: 0.001)

                model.components[InputTargetComponent.self] =
                    InputTargetComponent(allowedInputTypes: .all)
                model.generateCollisionShapes(recursive: true)

            }
        }
        .dragRotation(yawLimit: .degrees(90), pitchLimit: .degrees(90))
    }

控制旋转灵敏度

.dragRotation() 修饰器支持 sensitivity(灵敏度),这个参数控制旋转的灵敏度,即用户拖动多少距离会导致模型旋转多少角度。传入的值是一个 Double 类型,值越高,旋转越敏感;值越低,旋转越缓慢。

  • 低灵敏度:sensitivity 的值较低,例如在 1 到 5 之间。
  • 中等灵敏度:sensitivity 值在 5 到 20 之间。
  • 高灵敏度:sensitivity 的值较高,例如 20 以上。

在需要更精细控制旋转时,增大灵敏度可以帮助用户更容易旋转模型。而在需要更大范围旋转时,可以降低灵敏度,防止模型因为微小的手势而快速旋转。

RealityView { content in
            if let model = try? await Entity(
                named: "M4", in: realityKitContentBundle)
            {
                content.add(model)
                //                model.scale = SIMD3(repeating: 0.001)

                model.components[InputTargetComponent.self] =
                    InputTargetComponent(allowedInputTypes: .all)
                model.generateCollisionShapes(recursive: true)

            }
        }
        .dragRotation(pitchLimit: .degrees(90), sensitivity: 1.0)

添加自动旋转控制

.dragRotation() 修饰器支持 axRotateClockwise 和 axRotateCounterClockwise 两个布尔类型的参数,分别用于控制模型顺时针和逆时针的轴向旋转。这两个参数可以用于实现自动旋转效果,通过外部事件(如按钮点击或其他触发条件)控制模型的旋转方向。

  • axRotateClockwise 使模型顺时针旋转
  • axRotateCounterClockwise 则让模型逆时针旋转。

例如,在 UI 上提供按钮,用户点击按钮后,模型可以自动顺时针或逆时针旋转一定角度。这对于展示模型的不同角度或自动播放旋转动画非常有帮助。

//
//  DragEarth 2.swift
//  AnySee
//
//  Created by Ivens Liao on 2024/8/9.
//

import RealityKit
import RealityKitContent
import SwiftUI

struct DragEntity: View {
    @State private var rotateClockwise = false
    @State private var rotateCounterClockwise = false

    var body: some View {
        ZStack {
            RealityView { content in
                if let model = try? await Entity(
                    named: "M4", in: realityKitContentBundle)
                {
                    content.add(model)
                    model.components[InputTargetComponent.self] =
                        InputTargetComponent(allowedInputTypes: .all)
                    model.generateCollisionShapes(recursive: true)
                }
            }
            .dragRotation(
                axRotateClockwise: rotateClockwise,
                axRotateCounterClockwise: rotateCounterClockwise
            )

            VStack {
                Spacer()
                HStack {
                    Button(action: {
                        rotateCounterClockwise.toggle()
                    }) {
                        Image(systemName: "arrowshape.turn.up.left")
                        //                            .padding()
                    }
                    Button(action: {
                        rotateClockwise.toggle()
                    }) {
                        Image(systemName: "arrowshape.turn.up.right")
                            .padding()
                    }
                }

            }
        }
    }
}

#Preview {
    DragEntity()
}