| name | realitykit-visionos |
| description | Use when building visionOS apps or using RealityKit — entity-component-system architecture, custom Systems, scene subscriptions, immersive spaces, hand tracking, and multi-window scenes. |
RealityKit & visionOS Reference
Entity Hierarchy
import RealityKit
let rootEntity = Entity()
rootEntity.name = "SceneRoot"
let childEntity = ModelEntity(
mesh: .generateSphere(radius: 0.1),
materials: [SimpleMaterial(color: .blue, isMetallic: true)]
)
childEntity.name = "Sphere"
childEntity.position = [0, 1.5, -2]
rootEntity.addChild(childEntity)
for child in rootEntity.children {
print(child.name)
}
if let found = rootEntity.findEntity(named: "Sphere") {
found.isEnabled = false
}
Component Pattern
Standard Component
struct HighlightComponent: Component, Codable, Hashable {
var color: SIMD3<Float> = [1, 1, 0]
var intensity: Float = 1.0
var isActive: Bool = false
}
Transient Component (Non-Serialized)
struct RuntimeStateComponent: TransientComponent {
var lastUpdateTime: TimeInterval = 0
var velocityEstimate: SIMD3<Float> = .zero
weak var delegate: (any EntityDelegate)?
}
Registering Components
HighlightComponent.registerComponent()
RuntimeStateComponent.registerComponent()
System Pattern
final class HighlightSystem: System {
private static let query = EntityQuery(where: .has(HighlightComponent.self))
private var trackedEntities: Set<Entity> = []
required init(scene: RealityKit.Scene) {
_ = scene.subscribe(to: ComponentEvents.DidActivate.self, componentType: HighlightComponent.self) { [weak self] event in
self?.trackedEntities.insert(event.entity)
}
_ = scene.subscribe(to: ComponentEvents.WillDeactivate.self, componentType: HighlightComponent.self) { [weak self] event in
self?.trackedEntities.remove(event.entity)
}
}
func update(context: SceneUpdateContext) {
for entity in context.entities(matching: Self.query, updatingSystemWhen: .rendering) {
guard var highlight = entity.components[HighlightComponent.self] else { continue }
highlight.intensity *= 0.99
entity.components[HighlightComponent.self] = highlight
}
}
}
HighlightSystem.registerSystem()
Ticking Layers
func update(context: SceneUpdateContext) {
for entity in context.entities(matching: Self.query, updatingSystemWhen: .rendering) {
}
}
Scene Subscriptions
Component Events
let activateSub = scene.subscribe(
to: ComponentEvents.DidActivate.self,
componentType: MyComponent.self
) { event in
let entity = event.entity
}
let deactivateSub = scene.subscribe(
to: ComponentEvents.WillDeactivate.self,
componentType: MyComponent.self
) { event in
}
let changeSub = scene.subscribe(
to: ComponentEvents.DidChange.self,
componentType: MyComponent.self
) { event in
}
Scene Events
let updateSub = scene.subscribe(to: SceneEvents.Update.self) { event in
let deltaTime = event.deltaTime
}
let anchorSub = scene.subscribe(to: SceneEvents.AnchoredStateChanged.self) { event in
}
Component Change Publisher (Combine)
extension Entity {
func componentChangePublisher<C: Component>(
_ componentType: C.Type
) -> AnyPublisher<C, Never> {
scene?.publisher(for: ComponentEvents.DidChange.self, on: self, componentType: C.self)
.compactMap { [weak self] _ in
self?.components[C.self]
}
.eraseToAnyPublisher()
?? Empty().eraseToAnyPublisher()
}
}
entity.componentChangePublisher(HighlightComponent.self)
.sink { updatedComponent in
print("Highlight changed: \(updatedComponent.intensity)")
}
.store(in: &cancellables)
RealityView Integration
import SwiftUI
import RealityKit
struct ImmersiveContentView: View {
@State private var rootEntity = Entity()
var body: some View {
RealityView { content in
let sphere = ModelEntity(
mesh: .generateSphere(radius: 0.1),
materials: [SimpleMaterial(color: .blue, isMetallic: true)]
)
sphere.position = [0, 1.5, -2]
rootEntity.addChild(sphere)
content.add(rootEntity)
} update: { content in
}
.gesture(tapGesture)
}
var tapGesture: some Gesture {
SpatialTapGesture()
.targetedToAnyEntity()
.onEnded { value in
let tappedEntity = value.entity
}
}
}
Lifecycle Management
struct SceneView: View {
@State private var rootEntity = Entity()
@State private var subscriptions: [EventSubscription] = []
var body: some View {
RealityView { content in
content.add(rootEntity)
let sub = content.subscribe(to: SceneEvents.Update.self) { event in
}
subscriptions.append(sub)
}
.onDisappear {
subscriptions.removeAll()
rootEntity.children.removeAll()
}
}
}
Entity Component Operations
if let highlight = entity.components[HighlightComponent.self] {
print(highlight.intensity)
}
entity.components[HighlightComponent.self] = HighlightComponent(intensity: 0.5)
entity.components[HighlightComponent.self]?.intensity = 0.5
let hasHighlight = entity.components.has(HighlightComponent.self)
entity.components.remove(HighlightComponent.self)
entity.components.set([
HighlightComponent(intensity: 1.0),
OpacityComponent(opacity: 0.8)
])
AnchorEntity Patterns
let headAnchor = AnchorEntity(.head)
headAnchor.position = [0, 0, -1]
content.add(headAnchor)
let floorAnchor = AnchorEntity(.plane(.horizontal, classification: .floor, minimumBounds: [0.5, 0.5]))
content.add(floorAnchor)
let wallAnchor = AnchorEntity(.plane(.vertical, classification: .wall, minimumBounds: [0.3, 0.3]))
content.add(wallAnchor)
let handAnchor = AnchorEntity(.hand(.left, location: .palm))
content.add(handAnchor)
visionOS Scene Types
ImmersiveSpace
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
ImmersiveSpace(id: "mixedSpace") {
ImmersiveContentView()
}
.immersionStyle(selection: .constant(.mixed), in: .mixed)
ImmersiveSpace(id: "fullSpace") {
FullImmersionView()
}
.immersionStyle(selection: .constant(.full), in: .full)
ImmersiveSpace(id: "progressiveSpace") {
ProgressiveView()
}
.immersionStyle(selection: .constant(.progressive), in: .progressive)
}
}
Opening/Dismissing Spaces
@Environment(\.openImmersiveSpace) private var openImmersiveSpace
@Environment(\.dismissImmersiveSpace) private var dismissImmersiveSpace
func enterImmersive() async {
let result = await openImmersiveSpace(id: "mixedSpace")
switch result {
case .opened: break
case .userCancelled: break
case .error(let error): print("Error: \(error)")
@unknown default: break
}
}
func exitImmersive() async {
await dismissImmersiveSpace()
}
Multi-Window Architecture
@main
struct MultiWindowApp: App {
var body: some Scene {
WindowGroup {
MainView()
}
.defaultSize(width: 800, height: 600)
WindowGroup(id: "detail", for: UUID.self) { $itemId in
DetailView(itemId: itemId)
}
.defaultSize(width: 400, height: 300)
WindowGroup(id: "settings") {
SettingsView()
}
.defaultSize(width: 500, height: 400)
}
}
Window Placement
@Environment(\.openWindow) private var openWindow
func openDetailWindow(itemId: UUID) {
openWindow(id: "detail", value: itemId)
}
Placement Callbacks
WindowGroup(id: "detail") {
DetailView()
}
.defaultWindowPlacement { content, context in
if let mainWindow = context.windows.first(where: { $0.id == "main" }) {
return WindowPlacement(.trailing(mainWindow))
}
return WindowPlacement(.none)
}
Hand Tracking
import ARKit
actor HandTrackingProvider {
private let session = ARKitSession()
private let handTracking = HandTrackingProvider()
func startTracking() async throws {
try await session.run([handTracking])
}
func latestAnchors() async -> (left: HandAnchor?, right: HandAnchor?) {
let anchors = handTracking.latestAnchors
return (left: anchors.leftHand, right: anchors.rightHand)
}
}
Joint Hierarchy
func fingerTipPosition(hand: HandAnchor, finger: HandSkeleton.JointName.NameCodingKey) -> SIMD3<Float>? {
guard let skeleton = hand.handSkeleton else { return nil }
let joint = skeleton.joint(.indexFingerTip)
guard joint.isTracked else { return nil }
let jointTransform = hand.originFromAnchorTransform * joint.anchorFromJointTransform
return SIMD3<Float>(jointTransform.columns.3.x, jointTransform.columns.3.y, jointTransform.columns.3.z)
}
Gesture Confidence
func pinchDistance(hand: HandAnchor) -> Float? {
guard let skeleton = hand.handSkeleton else { return nil }
let thumbTip = skeleton.joint(.thumbTip)
let indexTip = skeleton.joint(.indexFingerTip)
guard thumbTip.isTracked, indexTip.isTracked else { return nil }
let thumbPos = thumbTip.anchorFromJointTransform.columns.3
let indexPos = indexTip.anchorFromJointTransform.columns.3
return simd_distance(
SIMD3(thumbPos.x, thumbPos.y, thumbPos.z),
SIMD3(indexPos.x, indexPos.y, indexPos.z)
)
}
let pinchThreshold: Float = 0.02
Palm Velocity Tracking
struct PalmTracker {
private var previousPosition: SIMD3<Float>?
private var previousTime: TimeInterval?
mutating func update(hand: HandAnchor, time: TimeInterval) -> SIMD3<Float>? {
guard let skeleton = hand.handSkeleton else { return nil }
let wrist = skeleton.joint(.wrist)
guard wrist.isTracked else { return nil }
let position = SIMD3<Float>(
hand.originFromAnchorTransform.columns.3.x,
hand.originFromAnchorTransform.columns.3.y,
hand.originFromAnchorTransform.columns.3.z
)
defer {
previousPosition = position
previousTime = time
}
guard let prev = previousPosition, let prevTime = previousTime else { return nil }
let dt = Float(time - prevTime)
guard dt > 0 else { return nil }
return (position - prev) / dt
}
}
@Observable vs @ObservableObject
Comparison
| Feature | @Observable (iOS 17+) | ObservableObject |
|---|
| Import | import Observation | import Combine |
| Declaration | @Observable class | class: ObservableObject |
| Properties | Plain properties (auto-tracked) | @Published var required |
| View usage | @State or just pass reference | @StateObject / @ObservedObject |
| Tracking | Per-property (fine-grained) | Whole-object (any @Published change) |
| Actor | Works with any isolation | Typically @MainActor |
| Performance | Better — only re-renders on accessed property changes | Worse — re-renders on any @Published change |
@Observable (Preferred for iOS 17+)
import Observation
@Observable
final class FeatureModel {
var items: [Item] = []
var isLoading = false
var errorMessage: String?
func loadItems() async throws {
isLoading = true
defer { isLoading = false }
items = try await service.fetchItems()
}
}
struct FeatureView: View {
@State private var model = FeatureModel()
var body: some View {
List(model.items) { item in
Text(item.name)
}
.overlay {
if model.isLoading { ProgressView() }
}
}
}
ObservableObject (Pre-iOS 17 or Combine Integration)
@MainActor
final class FeatureViewModel: ObservableObject {
@Published var items: [Item] = []
@Published var isLoading = false
func loadItems() async throws {
isLoading = true
defer { isLoading = false }
items = try await service.fetchItems()
}
}
struct FeatureView: View {
@StateObject private var viewModel = FeatureViewModel()
var body: some View {
List(viewModel.items) { item in
Text(item.name)
}
}
}
Migration Guidance
- New projects targeting iOS 17+: Use
@Observable
- Existing projects with Combine pipelines: Keep
ObservableObject, migrate gradually
- Shared ViewModels observed by many views:
@Observable gives significant performance gains
- Need
objectWillChange publisher: Stay with ObservableObject
- visionOS apps: Prefer
@Observable — it's the standard pattern