WWDC Quick Look 💓 By SwiftGGTeam
Set the scene with SwiftUI in visionOS

Set the scene with SwiftUI in visionOS

元の動画を見る

ハイライト

Miguel が、ロボットによる演劇 app(BOTanist と Shakespeare のステージの組み合わせ)を題材に、visionOS 26 の新しい scene API を一気通貫で紹介します。


主要内容

visionOS 26 以前は、ユーザーが Vision Pro を装着するたびに、ウィンドウを毎回配置し直す必要がありました。ツールパネルをソファの左側にドラッグし、メインステージをデスクの上に置き、ヘッドセットを外して再装着すると、すべて初期状態に戻る——開発者はユーザーが同じ作業を繰り返すのをただ眺めるしかなく、この過程に介入できる API は存在しませんでした。さらに困ったことに、app に没入空間がある場合、Digital Crown を長押しして座標系をリセットすると、すべてのコンテンツが誤った位置にずれてしまっていました。

visionOS 26 では、ウィンドウ、Volume、widget が部屋に「ロック」できる仮想オブジェクトに変わりました。ユーザーがリビングに入ると、前回テーブルにロックしたステージが自動的に出現し、書斎に行けば、壁にロックしたツールパネルが元の位置に戻ります。Miguel のデモ app では、BOTanist のロボットがユーザーの自宅のテーブル上に直接立ち、Robo and Juliet を演じます。ステージ下の仮想プラットフォームは、テーブルにスナップした際に自動的に非表示になります。このシーン永続化機能は、新たに追加された defaultLaunchBehaviorSurfaceSnappingInfoonWorldRecenterRemoteImmersiveSpaceUIHostingSceneDelegate という 5 つの API グループと組み合わさり、app 起動からサーフェスへのスナップ、没入空間との共存、Mac からのリモートレンダリング、UIKit と SwiftUI のブリッジまで、ライフサイクル全体をカバーします。


詳細

復元の無効化: 一時的なウィンドウはロックすべきでない04:10

システムはデフォルトですべてのウィンドウをロック対象として扱います。しかし、ウェルカムページや、app の状態に依存するツールパネル、一度きりのログインプロンプトのようなシーンは永続化すべきではありません。そうしないと、ユーザーが部屋に戻ってきたときに、ぽつんとツールバーだけが置かれていてメインステージが見つからない、という事態になりかねません。

// Disabling restoration

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

ポイント:

  • .restorationBehavior(.disabled) で、この WindowGroup がロックと復元の対象外であることを明示的に宣言します。
  • デフォルト値は復元するため、一時的なシーンにのみ付加します。
  • UIKit での同等の書き方は、windowScene.destructionConditions.systemDisconnection を追加することです。

起動ウィンドウの指定: 初回起動時にウェルカムページを表示する05:02

// 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)
}

ポイント:

  • defaultLaunchBehavior(.presented) は、このウィンドウを起動キューの先頭に押し上げます。
  • .automatic はシステムにデフォルトのルールで判断させるもので、@AppStorage を読み込んだ後の以降の起動でよく使われます。
  • 起動ウィンドウの role は Info.plist の Preferred Default Scene Session Role と一致している必要があり、そうでないとシステムはこの modifier を無視します。

.suppressed をセカンダリウィンドウと組み合わせる06:39

// "suppressed" behavior

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

ポイント:

  • .suppressed.restorationBehavior(.disabled) よりさらに踏み込んだ挙動で、ホームから app を再起動した際にも、このウィンドウは表示されません。
  • ツールパネルや補助コントローラーのように、自発的に表示されるべきでないウィンドウに適しています。
  • 公式の推奨: すべてのセカンダリシーンに .suppressed を付け、ユーザーがツールバーだけが残った気まずい状態に陥るのを防ぎましょう。

Surface snapping: テーブルにスナップしたら台座を隠す10:24

// 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
            }
        }
    }
}

ポイント:

  • surfaceSnappingInfo は新しい environment value で、スナップ状態が変化すると SwiftUI に通知します。
  • isSnapped は権限不要で取得できる情報なので、そのまま利用できます。
  • classification(テーブル、床、壁などの識別)には ARKit 権限が必要です。Info.plist に Application Wants Detailed Surface Info=YESPrivacy World-Sensing Usage-Description を追加してください。
  • 初めて authorizationStatus にアクセスすると、自動的に権限リクエストのダイアログが表示されます。

Clipping Margins: 滝を Volume の境界からはみ出させる14:41

// 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(.bottom, ...) で、Volume の下側にいくらかのポイント分の追加レンダリング領域を申請します。
  • システムから返される windowClippingMargins は、実際に許可されたマージンです(空間やシステムの制約により申請値より小さくなる場合があります)。
  • 境界の外側の領域は視覚的な装飾専用で、インタラクションには反応しません。
  • @PhysicalMetric でメートルをポイントに変換し、RealityKit エンティティのサイズが物理的に整合するようにします。

onWorldRecenter: Crown 長押し後に位置を再計算する16:44

// World recenter

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

ポイント:

  • ユーザーが Digital Crown を長押しすると、座標系のリセットがトリガーされます。
  • これまで RealityKit エンティティのワールド座標が瞬時にずれてしまい、app 側で再配置する必要がありました。
  • この modifier が通知を受け取る唯一の窓口です。

Mixed immersion とシステム環境との共存18:37

// Mixed immersion style

@State private var selectedStyle: ImmersionStyle = .progressive

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

ポイント:

  • .coexist を指定すると、mixed immersive space を macOS スタイルのシステム没入環境内でも表示できます。ユーザーは Mt. Hood の環境を散策しながら、あなたの仮想コンテンツを楽しむことができます。
  • デフォルト値は .suppressed で、システム環境に入ると app のコンテンツは非表示になります。

RemoteImmersiveSpace: Mac から immersive を Vision Pro に送り出す20:14

// Remote immersive space

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

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

ポイント:

  • macOS app は自身のウィンドウ内でビジネスロジックを動かしつつ、CompositorLayer でレンダリングしたコンテンツをストリーミングで Vision Pro に送ります。
  • RemoteDeviceIdentifierARKitSession に渡してリモートデバイスに接続します。
  • コンテンツ制作ツールに最適です: エディタは Mac 上、プレビューはヘッドセット内、という構成が組めます。

Scene bridging: UIKit app から SwiftUI の Volume を呼び出す23:00

// Scene bridging

import UIKit
import SwiftUI

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

let requestWithId = UISceneSessionActivationRequest(
    hostingDelegateClass: MyHostingSceneDelegate.self, id: "my-volume")!

UIApplication.shared.activateSceneSession(for: requestWithId)

ポイント:

  • UIHostingSceneDelegate を使うと、UIKit app は本体コードを書き換えることなく、必要に応じて SwiftUI で書かれた Volume や ImmersiveSpace を呼び出せます。
  • Safari の Spatial Browsing は、まさにこの API を使って実装されています。
  • AppKit には対応する NSHostingSceneRepresentation 系の API が用意されています。

重要ポイント

1. visionOS app に対して「復元の棚卸し」を実施する

なぜやる価値があるか: visionOS 26 はデフォルトですべてのウィンドウをロック復元の対象とします。古い app をアップデートすると、「ユーザーが部屋に戻るとツールパネルしか見えない」といった体験の劣化が発生します。

着手の手順: すべての WindowGroup を洗い出し、役割別に分類しましょう。メインシーンはデフォルトのまま、ウェルカムページやログインページには .restorationBehavior(.disabled) を付加し、ツールパネルや補助コントローラーにはさらに .defaultLaunchBehavior(.suppressed) を加えます。

2. surface snapping で「環境を意識した装飾」を作る

なぜやる価値があるか: ユーザーが Volume をテーブルに吸着させたとき、台座、影、環境光などはすべて実際のサーフェスに応答すべきです。これが仮想コンテンツを「本物らしく」見せる重要な一歩です。

着手の手順: まずは権限不要の isSnapped だけを使って Bool 値を切り替え(吸着時に台座を隠す)から始めます。効果が良ければ World-Sensing 権限を申請し、classification でテーブル/床/壁を区別して異なるディテールを描画しましょう。Info.plist に Application Wants Detailed Surface InfoPrivacy World-Sensing Usage-Description を追加するのを忘れずに。

3. Clipping Margins で Volume の枠を視覚的に破る

なぜやる価値があるか: デフォルトの Volume は固いハードな境界です。滝、炎、煙のような装飾要素はひとたび切り取られると、いかにも作り物に見えてしまいます。preferredWindowClippingMargins を使えば境界の外に少しはみ出してレンダリングでき、Volume を突き抜けたかのような視覚効果を演出できます。

着手の手順: Volume の縁に立つ装飾エンティティを 1 つ選び、そのコンテナに .preferredWindowClippingMargins(.bottom, 0.3 * pointsPerMeter) を付加します。次に @Environment(\.windowClippingMargins) で実際に与えられたマージンを取得し、エンティティのスケーリングに使いましょう。注意点として、置けるのは装飾要素のみで、インタラクション要素は配置できません。

4. UIKit app は Scene Bridging で段階的に移行する

なぜやる価値があるか: UIKit app 全体を SwiftUI に書き直すのは現実的ではありませんが、ユーザーは Volume や没入体験を期待しています。Scene Bridging を使えば本体コードを保ったまま、必要に応じて SwiftUI のサブシーンを呼び出せます。

着手の手順: 独立した 3D 詳細ページから着手するのが良いでしょう。UIHostingSceneDelegate を書き、内部で WindowGroup + .windowStyle(.volumetric) を使って SwiftUI View をラップします。ユーザーが詳細ボタンをタップしたら UIApplication.shared.activateSceneSession(for:) で呼び出します。まず 1 つを動かしてから、ほかのシーンもブリッジするか判断しましょう。

5. Mac のツール系 app に RemoteImmersiveSpace のプレビューモードを追加する

なぜやる価値があるか: 3D エディタ、CAD、映像ポストプロダクションなどのツールにおける最大の悩みは「2D 画面で 3D コンテンツを調整すること」です。RemoteImmersiveSpace を使うと、ユーザーは Vision Pro を装着するだけで WYSIWYG のプレビューを直接確認でき、メインの編集インターフェイスは Mac 上に保たれます。

着手の手順: まずは Mac app に “Preview on Vision Pro” ボタンを追加し、最小構成の CompositorLayer レンダリングを呼び出します。続いて RemoteDeviceIdentifier を使って、現在編集中のエンティティを送り出します。最初は片方向ストリームでよく、双方向のインタラクションは後回しにしましょう。


関連セッション

コメント

GitHub Issues · utterances