为 visionOS 应用添加 Rotation 旋转手势
探讨了如何在 visionOS 应用中,通过自定义的 dragRotation 修饰器为 3D 模型添加旋转手势。
在 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
,它是一个自定义的修饰器组件,允许用户通过拖动手势旋转对象。这个组件可以限制旋转的角度(yawLimit
和 pitchLimit
),并根据用户的拖动动作调整旋转的灵敏度。
为 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()
修饰器支持 yawLimit 和 pitchLimit(旋转限制)参数,这些参数用于设置旋转角度的限制,分别控制模型在 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()
}