はじめに

こんにちは!システム開発部のYです。

WWDC2025では、visionOS 26に追加されるSceneの新機能が発表されました。
本記事では、Window・Volume・Immersive Spaceに関する新しいAPIをまとめています。
詳細については、WWDCセッションをご参照ください。

Set the scene with SwiftUI in visionOS / SwiftUIによるvisionOSのシーンの設定

目次

Sceneのおさらい

visionOSには、Window・Volume・Immersive Spaceの3つのシーンタイプがあります。
これらを組み合わせることで、独自で魅力的な体験を創出することが可能です。

基本的な内容については、以下の記事にまとめていますので、ぜひご覧ください。
VisionOSの基本から操作方法、VisionOS向けのアプリ開発(UIKit)についてのまとめ

新APIの概要

Launching and locking

Scene restoration

visionOS 26では、WindowやVolume、さらに新しいウィジェットを特定の部屋に固定して保持できるようになりました。
固定されたWindowは使用された部屋と結び付けられ、後でその部屋に戻ると再び表示されます。
一般的に、ユーザーはすべてのWindowが固定され、システムによって復元されることを期待します。
そのため、ほとんどのシーンでは復元を優先してください。システムが自動的に復元を行います。

ただし、Windowによってはこの方法で保持するのが適切でない場合があります。
以下のような画面については、シーン復元を無効にすることを検討してください。

  • ウェルカム画面
  • ツールウィンドウ
  • ログインプロンプト

※イマーシブ空間は復元されないことに注意して下さい。

// Disabling restoration

WindowGroup("Tools", id: "tools") {
    ToolsView()
}
.restorationBehavior(.disabled)

restorationBehavior(.disabled)修飾子をWindowGroupに追加します。
これにより、復元及び固定がされなくなります。

// Disabling restoration

windowScene.destructionConditions = [
    .systemDisconnection
]

UIKitでは、新しいdestructionConditions APIの.systemDisconnectionプロパティを使用することで、UIシーンの復元を無効化できます。

defaultLaunchBehavior

// Specifying launch window

@AppStorage("isFirstLaunch") private var isFirstLaunch = true

var body: some Scene {
    WindowGroup("Stage Selection", id: "selection") {
        SelectionView()
    }

    WindowGroup("Welcome", id: "welcome") {
        WelcomeView()
            .onAppear {
                isFirstLaunch = false
            }
    }
    .defaultLaunchBehavior(isFirstLaunch ? .presented : .automatic)

    // ...
}

アプリの状態に応じて、起動時に表示するWindowをカスタマイズするには、defaultLaunchBehavior修飾子を使用します。
この例では、アプリの初回起動時にウェルカムウィンドウを優先的に表示するよう設定しています。

Info.plistのApplication Scene ManifestにあるPreferred Default Scene Session Roleプロパティは、設定内容と一致している必要があります。
例えば、Window Application Session Roleに設定した場合、アプリ起動時にはシステムが通常の WindowSceneのみを考慮します。
この場合、defaultLaunchBehaviorでVolumeSceneを優先しようとしても無視されます。

defaultLaunchBehavior(.suppressed)

イマーシブ空間を削除し、その後Windowを閉じた場合アプリを再起動すると、このWindowが再表示されます。
メインではないWindowが表示され、予期しない状態となります。
アプリを安全な状態で再開するには、メインのWindowから開始することが推奨されます。
これを実現するには、WindowにdefaultLaunchBehavior(.suppressed)修飾子を追加してください。

// "suppressed" behavior

WindowGroup("Tools", id: "tools") {
    ToolsView()
}
.restorationBehavior(.disabled)
.defaultLaunchBehavior(.suppressed)

これにより、ホームからアプリを再起動した際に、このWindowが再表示されないようシステムに指示できます。
※予期しない状態を避けるため、セカンダリーシーンにはこの設定を追加してください。

Unique Window

// Unique window

@AppStorage("isFirstLaunch") private var isFirstLaunch = true

var body: some Scene {
    // ...

    Window("Welcome", id: "welcome") {
        WelcomeView()
            .onAppear {
                isFirstLaunch = false
            }
    }
    .defaultLaunchBehavior(isFirstLaunch ? .presented : .automatic)

    WindowGroup("Main Stage", id: "main") {
        StageView()
    }

    // ...
}

Unique Window は、一度に1つの一意なインスタンスのみが存在できます。
ゲームウィンドウやビデオ通話など、重複を避けたい重要なインターフェイスには、この機能を利用してください。
宣言にはWindowAPIを使用します。

Volumetric enhancements

Surface snapping

Windowの背面を壁などの垂直面にスナップできます。
また、ボリュームの底面を床やテーブルなどの水平面にスナップすることも可能です。

アプリでは、ボリュームをテーブルにスナップして水平に固定できます。
スナップ状態を取得しロボットをここからテーブルに直接立たせます。

新しいSurfaceSnappingInfo APIを使用します。
このAPIは、Windowの一般的なスナップ状態を判定するためのシンプルなisSnappedプロパティを提供します。
さらに高度なユースケースでは、スナップ対象のスナップサーフェスが壁なのか床なのかといった種類をARKitで取得できます。
なお、この詳細情報を利用するには、ユーザーからの許可が必要です。

Application Wants Detailed Surface InfoキーをYESに設定し、
許可を求める際に表示する説明文をあわせて設定する必要があります。
これでコード実装に取り掛かる準備が整います。

// Surface snapping

@Environment(.surfaceSnappingInfo) private var snappingInfo
@State private var hidePlatform = false

var body: some View { 
    RealityView { /* ... */ }
    .onChange(of: snappingInfo) {
        if snappingInfo.isSnapped &&
            SurfaceSnappingInfo.authorizationStatus == .authorized
        {
            switch snappingInfo.classification {
                case .table:
                    hidePlatform = true
                default:
                    hidePlatform = false
            }
        }
    }
}

EnvironmentからsurfaceSnappingInfoを取得します。
onChange内で、シーンが現在スナップされているかを確認し、あわせてアクセス権限の有無も確認します。
その上で、snappingInfo.classificationを利用して、テーブルにスナップされた場合にはステージ下部を非表示にします。

Presentations

ボリュームにポップオーバーを追加できるようになりました。
これまでは、ポップオーバーはWindowのみでサポートされていましたが、visionOS 26からはPresentationComponentを使用することで、ボリューム内やアタッチメントから直接プレゼンテーションが可能になりました。
詳しくは以下をご参照ください
Better together: SwiftUI and RealityKit / SwiftUIとRealityKitの連係

すべての表示タイプが利用可能です。
メニュー、ツールチップ、ポップオーバー、シート、アラート、確認ダイアログ等です。

これらのプレゼンテーションは、すべて3Dコンテンツによって隠されても見えるように
特別な視覚処理が施されています。
デフォルトでは遮るコンテンツと、さりげなくブレンドします。
これはカスタマイズして、より目立つように突き抜けたり、逆にオクルードされたコンテンツの後ろに隠れたりする挙動も設定可能です。
subtle、prominent、none の各オプションを利用し、presentationBreakthroughEffectモディファイアを通じてプレゼンテーションに適用できます。
プレゼンテーション以外の要素には、breakthroughEffect修飾子を使用することで同様の効果を得られます。

Clipping margins

Volumeでは、新しい preferredWindowClippingMargins APIにより、シーン境界の外側にもコンテンツをレンダリングできるようになりました。

このコンテンツはインタラクティブではないため、視覚的な演出目的でのみ使用してください。
このような境界は、システムによって許可されていない可能性があります。
これに対応するには、windowClippingMargins環境変数を使用します。

// Clipping margins

@Environment(.windowClippingMargins) private var windowMargins
@PhysicalMetric(from: .meters) private var pointsPerMeter = 1

var body: some View {
    RealityView { content in
        // ...
        waterfall = createWaterfallEntity()
        content.add(waterfall)
    } update: { content in
        waterfall.scale.y = Float(min(
            windowMargins.bottom / pointsPerMeter,
            maxWaterfallHeight))
        // ...
    }
    .preferredWindowClippingMargins(.bottom, maxWaterfallHeight * pointsPerMeter)
}

preferredWindowClippingMargins APIを使用して、希望するクリッピングマージンを指定できます。
この例では、下部にマージンを設定しています。
PhysicalMetricから取得したpointsPerMeter係数を使用し、maxWaterfallHeightをメートル単位からポイントに変換します。
さらに、windowClippingMargins環境変数から許可されたマージンを読み取り、それに基づいて滝がマージン内に収まるようスケーリングします。

Immersive space

World recentering

ユーザーは、自分の空間を移動する際に Digital Crownを長押しすることで、周囲のアプリ体験を再センタリングできます。
アプリがARKitデータを使用している場合、保存済みの位置情報が無効になることがあります。

// World recenter

var body: some View {
    RealityView { content in
        // ...
    }
    .onWorldRecenter {
        recomputePositions()
    }
}

新しい onWorldRecenterモディファイアを使用すると、ワールドの再センタリングイベントを検知できます。
これにより、新しい座標系に基づいて位置を再計算し、保存する際に非常に役立ちます。

Immersion styles

progressive immersion style

Progressive Immersion Styleは、部分的に没入空間を提示しつつ、ユーザーを現実世界に留めておく優れた方法です。
イマーシブコンテンツはポータル内に表示され、Digital Crownを回すことでサイズを調整できます。
また、Progressive Immersion Styleでは、この没入範囲をカスタマイズすることが可能です。

visionOS 26 では、ポータルのアスペクト比もカスタマイズ可能になりました。
既存の横向きアスペクト比に加え、新たに縦向きのアスペクト比も利用できます。
iPhoneのゲームをApple Vision Proでプレイする場合や、多くの動きが含まれる体験には、
縦向きアスペクト比の使用を検討してください。

// Progressive immersion style

@State private var selectedStyle: ImmersionStyle = .progressive

var body: some Scene {
    ImmersiveSpace(id: "space") {
        ImmersiveView()
    }
    .immersionStyle(
        selection: $selectedStyle,
        in: .progressive(aspectRatio: .portrait))
}

このアスペクト比は、没入範囲と同様にprogressiveStyleのパラメータで指定できます。

mixed immersion style

immersion styleをmixedに設定すると、没入空間のコンテンツは周囲に溶け込みます。
これがデフォルトのスタイルです。

visionOS 26では、イマーシブ空間のコンテンツがシステム環境と共存することが出来ます。

// Mixed immersion style

@State private var selectedStyle: ImmersionStyle = .progressive

var body: some Scene {
    ImmersiveSpace(id: "space") {
        ImmersiveView()
    }
    .immersionStyle(selection: $selectedStyle, in: .mixed)
    .immersiveEnvironmentBehavior(.coexist)
}

これを実現するには、immersiveEnvironmentBehavior修飾子で coexistを指定します。
没入空間において、ユーザーが現実世界の周囲を意識する必要がない場合に、この設定を使用してください。

Remote immersive spaces

visionOS 26とmacOS Tahoeから、新機能Remote Immersive Spacesが追加されました。
この機能を使用すると、CompositorLayerを介してMac上のアプリコードとリソースを利用し、MetalでコンテンツをレンダリングしてVision Proで没入体験として表示できます。

// Remote immersive space

// Presented on visionOS
RemoteImmersiveSpace(id: "preview-space") {
    CompositorLayer(configuration: config) { /* ... */ }
}

// Presented on macOS
WindowGroup("Main Stage", id: "main") {
    StageView()
}

これを実現するために、CompositorLayerを含むRemoteImmersiveSpaceを追加します。
これはvisionOS上で表示され、メインステージのような他のシーンはMac上で直接表示されます。

CompositorLayerとARKitSessionをリモートのVision Pro デバイスに適用する詳細については、以下をご参照ください。
What’s new in Metal rendering for immersive apps / イマーシブなアプリを作成するためのMetalレンダリングの新機能

Render with Metal

CompositorLayerはViewではないため、これまでViewを必要とするコンテキストでは使用できませんでした。

// 'CompositorLayer' is a 'CompositorContent'

struct ImmersiveContent: CompositorContent {
    @Environment(.scenePhase) private var scenePhase

    var body: some CompositorContent {
        CompositorLayer { renderer in
            // ...
        }
        .onImmersionChange { oldImmersion, newImmersion in
            // ...
        }
    }
}

visionOS 26 では、新たにCompositor Contentビルダー型が追加され、CompositorLayerでもSwiftUIの全機能が利用可能になりました。
SwiftUIのビューと同じように、環境変数にアクセスしたり、修飾子を追加したり、状態変数を使用したりできるようになりました。

Scene bridging

UIKitはボリュームやイマーシブ空間をサポートしていませんが、シーンブリッジングによってこれが可能になりました。
シーンブリッジングを使用することで、既存のUIKitアプリとイマーシブ空間を統合できます。

// Scene bridging

import UIKit
import SwiftUI

// Declare the scenes
class MyHostingSceneDelegate: NSObject, UIHostingSceneDelegate {
    static var rootScene: some Scene {
        WindowGroup(id: "my-volume") {
            ContentView()
        }
        .windowStyle(.volumetric)
    }
}

// Create a request for the scene
let requestWithId = UISceneSessionActivationRequest(
    hostingDelegateClass: MyHostingSceneDelegate.self, id: "my-volume")!

// Send a request
UIApplication.shared.activateSceneSession(for: requestWithId)

UIHostingSceneDelegateを継承したクラスを作成します。
おなじみのシーンボディ構文を使用して、rootSceneプロパティにSwiftUIシーンを宣言できます。
これにより、他のUIKitシーンと同様にUISceneSessionActivationRequestを作成して、このシーンをリクエスト可能になります。
シーンを宣言するホスティングデリゲートクラスと、開きたいシーンのIDを渡し、
activateSceneSessionでリクエストを送信します。

まとめ

visionOS 26に追加されるSceneの新機能についてまとめました。
個人的には、特にdefaultLaunchBehaviorやPresentationsが、既存アプリにすぐ適用したい機能だと感じました。

これまでメインではないWindowが表示され、予期しない状態に陥ることがあったため、defaultLaunchBehaviorは非常に便利だと思います。
また、以前はポップオーバーを表示したくてもサポート外だったため断念した経験がありましたが、visionOS 26でサポートされたことで、実装がより容易になります。

その他にも気になる新機能が多く、今後いろいろ試してみたいと思っています。
この記事が少しでも参考になれば幸いです。詳細はWWDCのセッションをご参照ください。

参考リンク



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