🪟

[visionOS] マルチウィンドウでアプリケーションの表現を拡張する

2024/09/23に公開

はじめに

iPhone・iPadアプリにおいて、マルチウィンドウ機能を活用しようとしても、画面サイズの制約によりその利用には限界がありました。
しかし、visionOSでは周囲の空間全体が表現の領域となるため、複数ウィンドウを互いに干渉することなく同時に配置することが容易となり、macOSを超える表現力を実現しています。
これはRealityKitを用いたXR機能に匹敵する、空間コンピューティングの大きな強みだと考えています。

この記事では、(tatsubeeが知る限りで)マルチウィンドウを活用した魅力的なWindowアプリケーションを作成するための基礎を解説します。

※ この記事はあくまでvisionOS向けのものであり、iPadOSやmacOSにおけるマルチウィンドウで同じ挙動を保証するものではありませんので、ご注意ください。

参考: visionOSでできるマルチウィンドウの表現

本記事を読むことで、このsafariアプリのようにマルチウィンドウを扱えるようになります

新しいWindowを開く方法

visionOSでは、新しいWindowを開くための関数が2つ、Environmentで提供されています。

  • openWindow
  • pushWindow(visionOS 2以降)

さらに、NSUserActivityを活用してWindowを開く方法もあります。この方法では、ドラッグ&ドロップによって任意の場所にWindowを開くこと等のことが可能です。

openWindow・pushWindowの使い方

新しいWindowを開くためのコードを解説します。まずは開く対象のWindowを用意しましょう。

@main
struct ExampleApp: App {
    struct SceneValue: Condable, Hashable {
        let anything: String
    }

    var body: some Scene {
        WindowGroup {
            FirstView()
        }

        WindowGroup(id: "WindowID") {
            TargetViewWithID()
        }

        WindowGroup(for: SceneValue.self) { value in 
            TargetViewWithValue(value: value)
        }
    }
}

上の例では、3つのWindowGroupを用意しています。
1つ目は最初のアプリ起動時に開かれるWindowです。(ここで最初のアプリ起動時と表現した理由は後述します)
2つ目・3つ目はopenWindowpushWindowによって開かれるWindowです。WindowGroupに文字列のidか型のfor、またはその両方を渡すことができ、これらがWindowを開くための識別子となります。

次に、新しいWindowを開くためのViewを用意します。

struct FirstView: View {
    @Environment(\.openWindow) private var openWindow

    var body: some View {
        VStack {
            Button {
                openWindow(id: "WindowID")
                // or openWindow(value: SceneValue(anything: ""))
                // or openWindow(
                //        id: "WindowID",
                //        value: SceneValue(anything: "")
                //    )
            } label: {
                Text("Open window")
            }
        }
    }
}

openWindowの引数にSceneの識別子を渡すことで、一致したSceneを開くことができます。
上記のコードでは、openWindowpushWindowに置き換えることも可能です。挙動こそ異なりますが使い勝手は全く同じで良いですね。

openWindow・pushWindowの違い

openWindowpushWindowの違いは大きく分けて2つ、「新しいWindowの開かれ方」と「閉じられ方」にあります。

開かれ方について
openWindowではその名の通り、既に開かれているWindowとは別に新しいWindowが開きます。

一方でpushWindowでは、iOSの画面遷移のように、元のWindowの上に新しいWindowが開き、元のWindowは非表示になります。

閉じられ方について
openWindowで開かれたWindowは、独立したWindowとして動作するため、単にそのWindowを閉じるだけで終了します。

一方でpushWindowでは、開かれたWindowを閉じると元のWindowが再び表示されます。

ここでpushWindowのTips!
dismissWindowを使ってあらかじめ元のWindowを閉じておくことで、新しいWindowを閉じた際に元のWindowが再表示されないようにできます。この操作は、新しいWindowのViewのonAppear時などで行うのが効果的です。
ただし、onAppear直後にすぐdismissWindowを実行すると、ユーザーがWindowの閉じられる動作を目にし、違和感を感じる可能性があります。そのため、少しだけ待ってからdismissWindowを実行すると、より自然な体験になります。

NSUserActivityを使ったWindowの開き方

NSUserActivityを使ってドラッグ&ドロップでWindowを開く方法を解説します。
まずは、ドラッグ&ドロップが可能なViewを用意しましょう。

struct FirstView: View {
    var body: some View {
        VStack {
            Text("Drag & drop this")
                .hoverEffect()
                .onDrag {
                    let userActivity = NSUserActivity(
                        activityType: "dev.shoryu.MultiWindowExample.openWindow"
                    )
                    userActivity.targetContentIdentifier = "targetContentIdentifier"
                    return NSItemProvider(object: userActivity)
                }
        }
    }
}

次に、開くWindowを用意します。

@main
struct ExampleApp: App {
    var body: some Scene {
        WindowGroup {
            FirstView()
        }

        WindowGroup {
            TargetView()
        }
        .handlesExternalEvents(matching: ["targetContentIdentifier"])
    }
}

最後に、このNSUserActivityを使用するために、Info.plistにNSUserActivityTypesを記入しましょう。「あれ?うまくドラッグ&ドロップで開けないな」と思った場合、この設定を忘れているか、typoしていることが多いです。本当に忘れがち。

これでドラッグ&ドロップで新しいWindowを開けるようになりました!

もう一点注意点として、シミュレータで確認するとき、Xcodeのバージョンによってはドラッグ&ドロップでWindowを開けないことがあります。もしシミュレータでうまく動作しない場合は、実機で試してみると良いかもです。

マルチウィンドウの注意点(超重要)

visionOSでは基本的に「アプリをキルする」という概念が存在しません。すべてのWindowを閉じてもアプリは終了せず、バックグラウンドに移行します。
そのため、再度アプリを開いた際には1からスタートするのではなく、前回の操作状態からアプリを再開することになります。この際、最後に閉じたWindowが開かれます
openWindow・pushWindowの使い方の章で「最初のアプリ起動時」という表現を使った理由は、まさにこの挙動にあります。

最後に閉じたWindowがアプリのメインとなる機能を持っていない場合、ユーザー体験を損なってしまいます。

対処法は2つ。
1つ目は、開かれるすべてのWindowにメイン機能を持たせる方法です。(Safariアプリがこれにあたります。)
2つ目は、.handlesExternalEvents(preferring: [], allowing: [])モディファイアを、メイン機能を持たないWindowに付与する方法です。
handlesExternalEvents(preferring:allowing:)はそのViewが開かれているとき、そのViewを持つWindowが処理できる外部イベントを制御し、処理できない場合、新しいWindowを作成します。
この性質を活用することで、アプリ再起動時に適切なWindowを表示させることが可能です。

@main
struct ExampleApp: App {
    var body: some Scene {
        WindowGroup {
            FirstView()
        }

        WindowGroup {
            TargetView()
                .handlesExternalEvents(preferring: [], allowing: [])
        }
        .handlesExternalEvents(matching: ["targetContentIdentifier"])
    }
}

Windowサイズの設定・変更方法

静的なWindowサイズの指定

特定のWindowに対して、あらかじめ決められたサイズを指定したい場合、以下の2つのモディファイアを利用して実現できます。

defaultSize(width:height:)ではその名の通り、Windowを開いたときの初期サイズを指定するモディファイアです。
これはあくまで最初のサイズを指定するものであるため、ユーザーは自由にWindowのサイズを変更(拡大・縮小・比率変更)することができます。

struct MultiWindowSampleApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .defaultSize(width: 1000, height: 1000)
    }
}

windowResizability(_:)では、WindowのサイズをViewのサイズに依存させることができます。
.contentSizeを指定することで、WindowのサイズはViewのサイズと一致し、ユーザーによるサイズ変更ができなくなります。
.contentMinSizeを指定すると、Windowの最小サイズがViewのサイズに設定され、ユーザーによる拡大は可能ですが、縮小してもViewのサイズより小さくすることはできません。

struct MultiWindowSampleApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .windowResizability(.contentSize)
    }
}

動的なサイズの変更

動的にWindowのサイズを変更するには次の2つの方法があります。しかしどちらも一長一短があるため、用途に応じて注意が必要です。

windowResizability(_:)を使うことで、静的なWindowサイズの指定で説明したように、WindowサイズをViewのサイズに合わせることができます。ViewのサイズをStateとして管理し、それを変動させることで、Windowサイズを動的に変更することが可能です。
この時、'frame(width:height)'を使用すると、前述のようにユーザーがWindowサイズを変更できなくなります。frame(minWidth:idealWidth:maxWidth:minHeight:idealHeight:maxHeight:alignment:)を使用することもできますが、サイズ変動時には、minWidth・minHeightで指定したサイズで表示されます。そのため拡大はできるものの縮小が制限されるという欠点があります。
このように、windowResizability(_:)+.frameを使った変更方法では柔軟性には欠けますが、比較的簡単にサイズ変更を実現できる方法です。

requestGeometryUpdate(_:errorHandler:)を使う方法では、Windowサイズを指定のサイズに変更させるだけなので拡大縮小は制限されず、windowResizability(_:)に比べ柔軟性が高いです。
しかし、そもそもこの関数を使うためには対象のUIWindowSceneを把握している必要がある上、SwiftUIには自身を乗せているUIWindowSceneを知る簡単な方法がおそらくありません。どうして...

最後に

マルチウィンドウを使いこなすことで、visionOSらしい良いアプリが作れるはずです。
とはいえ何でも新しくWindowを開くと煩雑になってしまいユーザー体験を損ないます。iOSと同じようにalertやsheet等も使えるので適切に使っていきましょう!

おまけTips

  • Windowの位置はprogrammaticに変更したり、固定することはできない。残念。
  • Windowサイズの最大値は(2700.0, 1360.0)
  • visionOSではFaceTime中にSharePlayでアプリの画面を共有することができるが、共有できるWindowは1つだけ。
  • SFSafariViewControllerはvisionOSアプリ上では表示できず、Safariアプリが開いてしまう。最初からopenURLで開いちゃおう。

サンプルコード

https://github.com/Shoryu-Y/MultiWindowSample/tree/main

Discussion