visionOS: Windows, Volumes, Immersive Spaces and RealityKit

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages.

Apple Vision Pro has no screen edges. Apps appear as windows floating in the room, as bounded 3D volumes, or as content all around the person. FieldNotes has run there since Getting Started with Apple Platforms, and Augmented Reality with ARKit and RealityKit built its immersive map, NotesMapSpace, with ARKit and RealityKit. This page covers the app model around that map: which kind of scene to use when, how the app moves between them, how people point at things without the app ever learning where they look, and how 2D SwiftUI and 3D RealityKit content are combined. It targets the section’s baseline of iOS, iPadOS, macOS, watchOS and visionOS 27, built with Xcode 27 and Swift 6.4, and keeps a deployment minimum of 26, so anything that exists only in the 27 releases is labelled "new in visionOS 27" and guarded with #available.

The page adds three things to FieldNotes, all in the visionOS build of the app target:

  • NoteOrnament, a row of capture and navigation buttons attached below the main window;

  • FieldSiteVolume, a volume that shows page 35’s FieldSiteTerrain from RealityKitContent with a pin for every located note, opened with openWindow(id: "field-site");

  • an immersionStyle for the existing ImmersiveSpace(id: "map"), so people can choose between seeing their room and a focus mode that dims it.

Spaces: Shared and Full

Every visionOS app runs in one of two spaces.

Space What it means FieldNotes

Shared Space

The default. Windows and volumes of several apps sit side by side in the room; people arrange them. An app sees no camera image, no room mesh and no hand data.

The main window with NoteOrnament, note windows and FieldSiteVolume

Full Space

An app opens an ImmersiveSpace. Other apps' windows are hidden (the app’s own windows stay), and the app can place content anywhere around the person. ARKit data such as planes, meshes and hands flows only here.

NotesMapSpace, which needs plane detection to find a table

Only one immersive space can be open at a time across all apps, so opening one can fail, and the app must handle that. An app moves between the two spaces with the SwiftUI environment actions shown below; people leave a Full Space with the Digital Crown at any time.

Figure: the immersion spectrum

The visionOS immersion spectrum in FieldNotes, from least to most immersive. In the Shared Space: a window (ContentView with NoteOrnament) and a volume (FieldSiteVolume with the volumetric window style). In the Full Space, an ImmersiveSpace: mixed (NotesMapSpace on a real table, room visible), progressive (the Digital Crown sets how much of the room is replaced) and full (no passthrough, not used by FieldNotes). Only one immersive space is open at a time across all apps, and ARKit data flows only in a Full Space

The spectrum is a design choice as much as an API: start with the least immersive scene that does the job, and let people step up. FieldNotes reads and writes notes in a window, looks at the field site as a model in a volume, and opens the map around the person only when asked.

The FieldNotes Scene Graph

The visionOS scenes live in the same FieldNotesApp as every other platform’s (see App Structure and Life Cycle (SwiftUI, UIKit, AppKit)). The note window group from iPadOS: Windows, Multitasking, Pointer and Apple Pencil is not platform-guarded, so visionOS gets it too. This excerpt shows the scenes and modifiers visionOS adds:

// FieldNotes/FieldNotesApp.swift (updated excerpt: the visionOS scenes)
import SwiftUI
import FieldNotesModel
import FieldNotesServices

@main
struct FieldNotesApp: App {
    @State private var store = AppServices.shared.store   // the single owner, see Background Execution
    @AppStorage("mapFocusMode", store: .fieldNotesShared)   // the App inherits no scene's default store
    private var mapFocusMode = false

    var body: some Scene {
        WindowGroup {
            ContentView()
                #if os(visionOS)
                .ornament(attachmentAnchor: .scene(.bottom)) {
                    NoteOrnament()
                }
                #endif
                .environment(store)            // after .ornament, so the ornament gets the store too
        }
        .defaultAppStorage(.fieldNotesShared)  // as on Preferences, Files and Document-Based Apps

        WindowGroup(id: "note", for: Note.ID.self) { $noteID in   // from the iPadOS page
            NoteWindow(noteID: noteID)
                .environment(store)
        }
        .defaultAppStorage(.fieldNotesShared)

        #if os(visionOS)
        WindowGroup(id: "field-site") {
            FieldSiteVolume()
                .environment(store)
        }
        .defaultAppStorage(.fieldNotesShared)
        .windowStyle(.volumetric)
        .defaultSize(width: 0.6, height: 0.35, depth: 0.6, in: .meters)
        .defaultWindowPlacement { _, context in
            if let main = context.windows.first {
                return WindowPlacement(.trailing(main))   // next to the main window
            }
            return WindowPlacement()
        }

        ImmersiveSpace(id: "map") {
            NotesMapSpace()
                .environment(store)
        }
        .defaultAppStorage(.fieldNotesShared)
        .immersionStyle(selection: mapImmersion, in: .mixed, .progressive(0.2...1.0, initialAmount: 0.5))
        #endif
    }

    #if os(visionOS)
    /// Mixed shows the room; progressive ("focus mode") replaces part of it, the amount set by the Digital Crown.
    private var mapImmersion: Binding<any ImmersionStyle> {
        Binding(
            get: { mapFocusMode ? .progressive(0.2...1.0, initialAmount: 0.5) : .mixed },
            set: { mapFocusMode = $0 is ProgressiveImmersionStyle }
        )
    }
    #endif
}

Every scene that shows notes gets .environment(store), because each scene has its own environment, and every scene keeps .defaultAppStorage(.fieldNotesShared) for the same reason. The App sits above the scenes and inherits neither, so its mapFocusMode names the App Group suite with store: .fieldNotesShared; the Focus Mode toggle in OpenMapSpaceButton writes that same key in that same suite, and the immersion style follows it. The visionOS 26 SDK renames the closure label of ImmersiveSpace(id:content:) to init(id:makeContent:); trailing-closure syntax, as above, is unaffected. Opening several scenes at once requires UIApplicationSupportsMultipleScenes set to true in the scene manifest, which the app already has for iPad.

Figure: FieldNotes' visionOS scene graph

flowchart TD App["FieldNotesApp
@State store = AppServices.shared.store"] subgraph Shared["Shared Space"] Main["WindowGroup
ContentView + NoteOrnament"] NoteWin["WindowGroup(id: note, for: Note.ID)
NoteWindow"] Volume["WindowGroup(id: field-site)
.windowStyle(.volumetric)
FieldSiteVolume"] end subgraph Full["Full Space"] Space["ImmersiveSpace(id: map)
NotesMapSpace
.mixed or .progressive"] end App --> Main App --> NoteWin App --> Volume App --> Space Main -->|"NoteOrnament: openWindow(id: field-site)"| Volume Main -->|"NoteOrnament: openImmersiveSpace(id: map)"| Space Main -->|"openWindow(id: note, value: note.id)"| NoteWin Volume -->|"tap a pin, then Open Note"| NoteWin Space -.->|"dismissImmersiveSpace()
or the Digital Crown"| Main Content[("RealityKitContent
FieldSiteTerrain")] -.-> Volume Content -.-> Space

Windows and Ornaments

A visionOS window is a pane of glass. SwiftUI draws the glass background, the window bar below it for moving, and the resize handles at its corners; the app draws only its content. ContentView's NavigationSplitView works unchanged, with a sidebar, list and detail. Windows can be resized by people between limits that the content sets, and the app can suggest an initial size with defaultSize(width:height:), but it can’t move or resize a window after it appears.

An ornament is a floating control bar attached to a window, outside its bounds, typically along the bottom edge. It follows the window when people move it. FieldNotes uses one for the actions that start something new: capture a note, open the field site and show the map around the person. NoteOrnament is attached in the scene above with ornament(attachmentAnchor:contentAlignment:ornament:):

// FieldNotes/Vision/NoteOrnament.swift
#if os(visionOS)
import SwiftUI

struct NoteOrnament: View {
    @Environment(\.openWindow) private var openWindow
    @State private var isComposing = false

    var body: some View {
        HStack(spacing: 16) {
            CaptureButton(action: { isComposing = true })
            Button("Field Site", systemImage: "mountain.2") {
                openWindow(id: "field-site")
            }
            OpenMapSpaceButton()
        }
        .labelStyle(.iconOnly)
        .padding(12)
        .glassBackgroundEffect()
        .sheet(isPresented: $isComposing) {
            NoteComposer(notebookID: nil)        // reads NoteStore from the environment
        }
    }
}
#endif

CaptureButton(action:) is part 1’s button; on visionOS it already uses glassBackgroundEffect(in:) instead of glassEffect(_:in:), which doesn’t exist there. The ornament’s own glass pill comes from glassBackgroundEffect(). .scene(.bottom) anchors the ornament to the bottom centre of the window. Keep an ornament to a few frequently used actions; everything else belongs in the window’s toolbar or content.

Volumes: FieldSiteVolume

A volume is a window with depth: a bounded box that people can walk around, with content that can be seen from any side. It still lives in the Shared Space, next to other apps. A WindowGroup becomes a volume with .windowStyle(.volumetric), and its default size gets a third dimension with defaultSize(width:height:depth:in:), in real-world units, as declared above: 60 cm wide and deep, 35 cm tall. defaultWindowPlacement asks visionOS to place it trailing the main window instead of in front of the person.

FieldSiteVolume loads the terrain that page 35 authored in Reality Composer Pro, adds page 35’s pins through NotePinFactory and NotesMapLayout, keeps those pins in step with the store as notes are added, moved or deleted, and shows the selected note in a SwiftUI card attached above its pin:

// FieldNotes/Vision/FieldSiteVolume.swift
#if os(visionOS)
import SwiftUI
import RealityKit
import RealityKitContent
import OSLog
import FieldNotesModel
import FieldNotesServices

struct FieldSiteVolume: View {
    @Environment(NoteStore.self) private var store
    @Environment(\.openWindow) private var openWindow
    @State private var selectedNoteID: Note.ID?

    private static let log = Logger(subsystem: "com.example.fieldnotes", category: "ui")

    var body: some View {
        RealityView { content, attachments in
            let site = Entity()
            site.name = "FieldSite"
            do {
                site.addChild(try await Entity(named: "FieldSiteTerrain", in: realityKitContentBundle))
            } catch {
                Self.log.error("FieldSiteTerrain failed to load: \(error.localizedDescription)")  // pins still show
            }
            Self.reconcilePins(in: site, with: store.notes)
            site.position.y = -0.15                              // rest on the volume's floor
            content.add(site)
        } update: { content, attachments in
            guard let site = content.entities.first(where: { $0.name == "FieldSite" }) else { return }
            Self.reconcilePins(in: site, with: store.notes)     // reading store.notes reruns update on every change
            guard let card = attachments.entity(for: "note-card") else { return }
            if let noteID = selectedNoteID, let pin = site.findEntity(named: noteID.uuidString) {
                site.addChild(card)
                card.position = pin.position + [0, 0.09, 0]
                card.isEnabled = true
            } else {
                card.isEnabled = false
            }
        } attachments: {
            Attachment(id: "note-card") {
                if let noteID = selectedNoteID, let note = store.notes.first(where: { $0.id == noteID }) {
                    FieldSiteNoteCard(note: note) {
                        openWindow(id: "note", value: note.id)
                    }
                }
            }
        }
        .gesture(
            SpatialTapGesture()
                .targetedToAnyEntity()
                .onEnded { value in
                    guard let pin = value.entity.components[NotePinComponent.self],
                          store.notes.contains(where: { $0.id == pin.noteID }) else { return }  // deleted meanwhile
                    selectedNoteID = selectedNoteID == pin.noteID ? nil : pin.noteID
                }
        )
        .onChange(of: store.notes.map(\.id)) {
            // The selected note was deleted, here or in another window: drop the card.
            if let id = selectedNoteID, !store.notes.contains(where: { $0.id == id }) {
                selectedNoteID = nil
            }
        }
        .volumeBaseplateVisibility(.visible)
    }

    /// Makes the site's pins match the located notes, keyed by note ID: removes the pins of deleted (or no longer
    /// located) notes, rebuilds a pin whose pinned state or title changed, adds pins for new notes and moves the rest, since
    /// the layout's origin is the first located note. Only x and z are set: NotePinSystem owns y, for the bobbing.
    private static func reconcilePins(in site: Entity, with notes: [Note]) {
        let layout = NotesMapLayout.positions(for: notes, tableSize: 0.5)
        let wanted = Dictionary(uniqueKeysWithValues: layout.map { ($0.note.id, $0) })
        var kept: [Note.ID: Entity] = [:]
        for child in Array(site.children) {                  // a copy, because the loop removes children
            guard let pin = child.components[NotePinComponent.self] else { continue }   // the terrain
            if let entry = wanted[pin.noteID], entry.note.isPinned == pin.isPinned,
               labelText(of: child) == entry.note.title {
                kept[pin.noteID] = child
            } else {
                child.removeFromParent()
            }
        }
        for (id, entry) in wanted {
            if let pin = kept[id] {
                pin.position.x = entry.position.x
                pin.position.z = entry.position.z
            } else {
                let pin = NotePinFactory.makePin(for: entry.note, at: entry.position)
                pin.components.set(HoverEffectComponent())  // highlights the pin the person looks at
                site.addChild(pin)
            }
        }
    }

    /// The title a pin shows, read back from its label's TextComponent (see NotePinFactory).
    private static func labelText(of pin: Entity) -> String? {
        guard let text = pin.children.lazy.compactMap({ $0.components[TextComponent.self]?.text }).first else {
            return nil
        }
        return String(text.characters)
    }
}

/// The SwiftUI card shown above the selected pin.
struct FieldSiteNoteCard: View {
    let note: Note
    let open: () -> Void

    var body: some View {
        VStack(alignment: .leading, spacing: 6) {
            Text(note.title).font(.headline)
            Text(note.body).font(.caption).lineLimit(3)
            Button("Open Note", systemImage: "arrow.up.forward.app", action: open)
        }
        .padding()
        .frame(width: 260)
        .glassBackgroundEffect()
        .hoverEffect(.highlight)
    }
}
#endif

A few details matter here:

  • make runs once; update runs when state it reads changes. The tap handler only sets selectedNoteID; the update closure reads it, so RealityKit moves the card. Entities are never stored in SwiftUI state, as the augmented-reality page recommends.

  • Pins follow the store. Creating pins only in make would freeze them at the notes that existed when the volume opened. Both closures call reconcilePins(in:with:) instead. The view reads store.notes in update and in onChange(of:), so SwiftUI reruns update when the notes change: a note added in the main window gets a pin, and a note deleted anywhere loses its pin, and a renamed or re-pinned note gets a fresh pin. Pins are matched by NotePinComponent.noteID, never by position in an array. The tap handler ignores a pin whose note is already gone, and onChange(of:) clears the card when the selected note is deleted.

  • The attachment is a real entity. attachments.entity(for:) returns a ViewAttachmentEntity that can be parented, positioned and hidden like any other entity. Parenting it to the site keeps it on the terrain when the person moves the volume.

  • The baseplate is the subtle glow at the bottom of a volume that shows its footprint; volumeBaseplateVisibility makes it visible even when the terrain covers the floor, so people see the volume’s bounds.

  • Loading errors are logged, not fatal. A missing asset is a packaging error that shows up in development; the pins still appear.

NotePinFactory and NotesMapLayout are the helpers from Augmented Reality with ARKit and RealityKit, and so are NotePinComponent and NotePinSystem, which make the pins bob; the system is registered once at launch, so it runs in the volume too.

Immersive Spaces

Opening and dismissing

openImmersiveSpace and dismissImmersiveSpace are environment actions. Both are async: opening returns an OpenImmersiveSpaceAction.Result because the system can refuse (another app already has a space open) or the person can cancel. OpenMapSpaceButton from the life-cycle page grows into a toggle that handles all three results and offers the focus mode:

// FieldNotes/Vision/OpenMapSpaceButton.swift (updated)
#if os(visionOS)
import SwiftUI
import FieldNotesServices   // UserDefaults.fieldNotesShared

struct OpenMapSpaceButton: View {
    @Environment(\.openImmersiveSpace) private var openImmersiveSpace
    @Environment(\.dismissImmersiveSpace) private var dismissImmersiveSpace
    @AppStorage("mapFocusMode", store: .fieldNotesShared)   // the suite FieldNotesApp reads
    private var mapFocusMode = false
    @State private var isOpen = false
    @State private var failure: String?

    var body: some View {
        Button(isOpen ? "Hide Notes Around Me" : "Show Notes Around Me",
               systemImage: isOpen ? "map.fill" : "map") {
            Task { await toggle() }
        }
        .contextMenu {
            Toggle("Focus Mode", systemImage: "circle.lefthalf.filled", isOn: $mapFocusMode)
        }
        .alert("Map Unavailable", isPresented: Binding(get: { failure != nil }, set: { if !$0 { failure = nil } })) {
            Button("OK") {}
        } message: {
            Text(failure ?? "")
        }
    }

    private func toggle() async {
        if isOpen {
            await dismissImmersiveSpace()
            isOpen = false
            return
        }
        switch await openImmersiveSpace(id: "map") {
        case .opened:
            isOpen = true
        case .userCancelled:
            break
        case .error:
            isOpen = false
            failure = "Close the immersive experience of the other app, then try again."
        @unknown default:
            break
        }
    }
}
#endif

When people leave the space with the Digital Crown instead of the button, isOpen stays true until the next tap, which then just dismisses nothing and flips it back. An app that must track the space exactly records it from the space’s own view, with onAppear and onDisappear on NotesMapSpace.

Immersion styles

An immersive space has one of three immersion styles:

Style What people see Typical use

.mixed

The room, through passthrough, with the app’s content placed in it

Objects anchored to real surfaces: NotesMapSpace on a table

.progressive

A portion of the view replaced by the app’s content; the Digital Crown widens or narrows it within the range the app allows

A focus mode that fades the room around the map

.full

Only the app’s content; passthrough is off

A fully virtual place, such as an environment around the person

immersionStyle(selection:in:) lists the styles a space supports and binds the current one, as FieldNotesApp does above. .progressive(0.2…​1.0, initialAmount: 0.5) lets the Digital Crown vary the replaced portion between 20 and 100 per cent, starting at half. FieldNotes doesn’t offer .full: the map is placed on a real table found by plane detection, and hiding the room entirely would hide the table the map rests on.

The design guidance is to let people choose immersion, start small, and never surprise them with a sudden full environment. Augmented Reality with ARKit and RealityKit shows the ARKit session that runs inside NotesMapSpace, and why it needs the Full Space.

Input: Look and Tap

People use Apple Vision Pro with their eyes and hands. They look at an element to target it and tap their finger and thumb together to activate it (indirect input), or reach out and touch content that is within arm’s length (direct input). SwiftUI controls handle both with no code.

The important rule is gaze privacy: visionOS doesn’t tell an app where people are looking before they tap. The highlight a control shows while it is looked at is drawn by the system, outside the app’s process; the app only learns about the tap, and where it landed. Design follows from that: an app can’t show a tooltip on gaze or prefetch what people look at, and it must space targets so they are easy to look at: the Human Interface Guidelines ask for centres at least 60 points apart, or a margin of at least 16 points around each item.

Hover effects

Custom views get the same system-drawn highlight with hoverEffect(_:isEnabled:), as FieldSiteNoteCard does with .highlight; .lift raises a view slightly instead. For RealityKit content, HoverEffectComponent on an entity that also has an InputTargetComponent and a CollisionComponent does the same, which is why FieldSiteVolume adds it to each pin:

// A note row in ContentView's list on visionOS: the whole row highlights when looked at.
NoteRow(note: note)
    .contentShape(.hoverEffect, .rect(cornerRadius: 12))
    .hoverEffect(.highlight)

NoteRow stands for whatever row view the list uses. The content shape limits the highlight to the row’s rounded rectangle.

Gestures on entities

SwiftUI gestures attached to a RealityView hit its entities when they are made entity-targeted with targetedToAnyEntity() (or targetedToEntity(_:) for one entity). The gesture’s value then carries the entity that was hit. FieldSiteVolume uses a SpatialTapGesture to select a pin, the same pattern NotesMapSpace uses on page 35. Drag, magnify and rotate gestures work the same way, and are how people move content in a volume.

3D Content: RealityView and Model3D

visionOS offers two ways to show 3D content in SwiftUI:

View What it gives Use when

Model3D

One model file (USDZ or a Reality Composer Pro scene), loaded asynchronously and laid out like an image

A model is decoration or a preview, with no interaction per part

RealityView

A full RealityKit scene: entities, components, systems, gestures, attachments

Content is built in code, animated, or interactive, like FieldSiteVolume

Model3D is the 3D counterpart of AsyncImage. The note detail on visionOS shows a small preview of the field site next to a note that has a location, with a placeholder while the model loads:

// FieldNotes/Vision/FieldSitePreview.swift
#if os(visionOS)
import SwiftUI
import RealityKit
import RealityKitContent

struct FieldSitePreview: View {
    var body: some View {
        Model3D(named: "FieldSiteTerrain", bundle: realityKitContentBundle) { model in
            model
                .resizable()
                .scaledToFit()
        } placeholder: {
            ProgressView()
        }
        .frame(width: 220, height: 160)
        .frame(depth: 60)
    }
}
#endif

RealityView with attachments, as in FieldSiteVolume, is the way to put SwiftUI inside a 3D scene. Since visionOS 26 there is a second way that doesn’t need the attachments: closure: ViewAttachmentComponent(rootView:) attaches a SwiftUI view to any entity directly, which suits content created in a loop:

// Inside FieldSiteVolume's make closure: a small title tag above every pinned note, visionOS 26 and later
for (note, position) in NotesMapLayout.positions(for: store.notes.filter(\.isPinned), tableSize: 0.5) {
    let tag = Entity()
    tag.components.set(ViewAttachmentComponent(rootView: Text(note.title).font(.caption).padding(6)
        .glassBackgroundEffect()))
    tag.position = position + [0, 0.12, 0]
    site.addChild(tag)
}

The entity-component-system model behind all of this (NotePinComponent, NotePinSystem), ARKit on visionOS, Reality Composer Pro and RealityKit’s 27 additions are on Augmented Reality with ARKit and RealityKit; this page doesn’t repeat them.

Bringing iPad and iPhone Apps to visionOS

There are two routes for an existing iPad or iPhone app:

  • Compatible ("Designed for iPad") app. The unmodified iOS build runs in a window on Apple Vision Pro. The App Store offers compatible apps on visionOS automatically, and the developer can opt out in App Store Connect. Features that need missing hardware are unavailable: Core Motion, the barometer and magnetometer, location services other than the standard service, HealthKit data, and photo or video capture from device cameras.

  • Native visionOS app. Add Apple Vision under the target’s Supported Destinations and recompile against the visionOS SDK. The app gets the visionOS look (glass, ornaments, hover effects) and can add volumes and immersive spaces. Code for frameworks that don’t exist in the visionOS SDK moves behind #if os(visionOS) or into files that aren’t in the visionOS build.

FieldNotes is native: it was a multiplatform target from the start (see One Codebase for iOS, iPadOS, macOS, watchOS and visionOS). Its platform guards follow the pattern Apple recommends. For example, the editor’s photo button offers the camera on iPhone and iPad, and only the photo library on Apple Vision Pro, where apps have no camera access:

// FieldNotes/Views/NoteEditor.swift (excerpt: the toolbar's attach menu)
Menu("Attach", systemImage: "paperclip") {
    Button("Photo Library", systemImage: "photo.on.rectangle") { showPhotoPicker = true }
    #if os(iOS)
    Button("Take Photo", systemImage: "camera") { showCamera = true }   // CameraCaptureModel, iPhone and iPad only
    #endif
}

The other guards already in FieldNotes are the same kind: glassEffect versus glassBackgroundEffect in CaptureButton, ActivityKit, HealthKit and Core NFC behind #if os(iOS), and ARKit world tracking, which on visionOS is replaced by ARKitSession and its providers. Once the app builds, the visionOS simulator runs windows, volumes and immersive spaces; ARKit data providers and real hand input need a physical Apple Vision Pro.

New in visionOS 27

The WWDC26 session Build next-generation experiences with visionOS 27 sums up the release. The parts that matter most for an app like FieldNotes:

  • Accessory widgets on visionOS. accessoryCircular and accessoryRectangular are now listed for visionOS 27, so small glanceable widgets can join the system-family widgets people already pin to walls.

  • RealityKit and Reality Composer Pro 3. Physical space lighting, cloth simulation, Gaussian splats, level of detail and more, covered on Augmented Reality with ARKit and RealityKit.

  • Spatial Preview, a new framework on macOS 27 and visionOS 27 that extends 3D content, spatial photos and immersive video from a Mac app to Apple Vision Pro, and Foveated Streaming (visionOS 26.4 and later), which streams OpenXR content from a PC.

  • Object tracking with high-frame-rate updates, an ARKit API that brings the same tracking to iOS, and support for custom spatial accessories.

  • System updates people will notice: wider, curved Safari windows, a reworked Control Center, and High Quality Capture of apps in 4K from the device.

The accessory families are the one that changes FieldNotes' code. Page 27’s PinnedNotesWidget would list them for visionOS only when the device runs 27, since the deployment minimum is 26:

// FieldNotesWidgets/PinnedNotes/PinnedNotesWidget.swift (sketch: the visionOS families)
#if os(visionOS)
static var visionFamilies: [WidgetFamily] {
    var families: [WidgetFamily] = [.systemSmall, .systemMedium, .systemLarge, .systemExtraLargePortrait]
    if #available(visionOS 27, *) {
        families += [.accessoryCircular, .accessoryRectangular]
    }
    return families
}
#endif

The visionOS 27 release notes list the resolved issues and SwiftUI changes shared with the other platforms, such as the new @State macro implementation.

Availability and Requirements

Platform availability

API or feature iOS iPadOS macOS watchOS visionOS

WindowGroup, openWindow

Yes (one window on iPhone)

Yes

Yes

Yes (one window)

Yes

ornament(…​), glassBackgroundEffect

Not available

Not available

Not available

Not available

Yes

.windowStyle(.volumetric), defaultSize(width:height:depth:in:), volumeBaseplateVisibility

Not available

Not available

Not available

Not available

Yes

defaultWindowPlacement(_:)

Not available

Not available

Yes (15)

Not available

Yes (2)

ImmersiveSpace, .mixed

Not available

Not available

Not available

Not available

Yes

.progressive, .full, openImmersiveSpace, dismissImmersiveSpace

Not available

Not available

Yes (26, RemoteImmersiveSpace shown on Vision Pro)

Not available

Yes

hoverEffect(_:isEnabled:)

Yes (17)

Yes (17)

Not available

Not available

Yes

HoverEffectComponent, targetedToAnyEntity(), RealityView

Yes

Yes

Yes

Not available

Yes

RealityView attachments (init(make:update:attachments:))

Not available

Not available

Not available

Not available

Yes

ViewAttachmentComponent(rootView:)

Not available

Not available

Not available

Not available

New in 26

Model3D

Not available

Not available

Not available

Not available

Yes

Accessory widget families

Yes

Yes

Not available

Yes

New in 27 (circular, rectangular)

Spatial Preview

Not available

Not available

New in 27

Not available

New in 27

The iOS and macOS columns for RealityView, HoverEffectComponent and targetedToAnyEntity() refer to iOS 18, iPadOS 18 and macOS 15 and later, as documented; FieldNotes uses them on those platforms only on the AR page.

Requirements

  • The visionOS simulator runs every scene on this page; ARKit data providers in NotesMapSpace need a physical Apple Vision Pro, as do comfort and legibility checks at real scale.

  • NotesMapSpace needs the NSWorldSensingUsageDescription and NSHandsTrackingUsageDescription keys (see the AR page).

  • Several simultaneous scenes need UIApplicationSupportsMultipleScenes set to true.

  • Installing on a device needs a paid developer account or a free account’s short-lived provisioning, as for any device (see Membership, Signing, Capabilities and Info.plist).

Learning More

Apple’s Hello World sample walks through a window, a volume and a full immersive space in one small app, and is the best companion to this page. The Human Interface Guidelines' visionOS overview explains how people expect spatial apps to behave.

The watch, at the other end of the size range, is on watchOS: App Structure, Connectivity, Workouts and the Smart Stack; profiling RealityKit scenes is on Performance and Instruments.

References