🎉

Vision Proアプリ開発記【3. Swift UIとUnityの連携編】

2025/03/11に公開

はじめに

こんにちは!ambrで4月からインターンをしている、こんしると申します。

前回に引き続き、「gogh: Spatial Focus Timer」の開発中壁にぶつかったポイントを、備忘録的にまとめたものになります。

https://apps.apple.com/jp/app/gogh-spatial-focus-timer/id6511229977

この記事では、Unityで制作したアプリにSwift UIを実装する手法について説明します。
大部分は以下の記事を参考にしています。まずはこちらをどうぞ。

https://zenn.dev/meson/articles/polyspatial-swiftui

シリーズの他の記事はこちらからどうぞ!

  • UnityでVision Proアプリを開発する環境構築

https://zenn.dev/ambr_inc/articles/4de36864839111

  • Unityを介したアバターモデルのVision Pro移植

https://zenn.dev/ambr_inc/articles/7c08bdba2d2d53

  • Swift UIとUnityの連携(この記事)

Swift UIを連携させる

PolySpatialにはUIのサンプルがあり、黒くて押せる物理ボタンや黒背景で青く動かせるUI Panelなどが備わっています。

PolySpatialUI

ただ、Apple純正のSwift UIのスタイリッシュなかっこよさはやはりPolySpatialのUIにはありません。

SwiftUI

というわけで、UnityのPolySpatialを用いつつもUIだけはSwift UIを使いたい、となった次第です。

基本的には冒頭に挙げた記事やPolySpatial内のSwift UIのsampleを参考にして作ればいいのですが、いざ自分で作ろうとしたときに色々つまづいてしまいました。
手順を追って説明します。

UIの作成

まず、Swift側でUIを作成します。
ボタンを配置し、ウィンドウを変更するようにするなど諸々の機能を実装します。
実装したらSwift上でそのままシミュレーションできます。(自動でシミュレーションが走ります。便利だ…)

公式のチュートリアルがかなりわかりやすくなっていて、初学者でもこれを見ると何となく作れるようになります(なりました)。

https://developer.apple.com/jp/xcode/swiftui/

余談ですが、Swift UIに初めて触れた感想として、UnityのグラフィカルなUI配置方法とは異なりコードを書いて配置する方法は、ドキュメントを見て学ぶ必要はありますが、簡単にそれっぽいデザインにしてくれるのがかなり心地よかったです。

ただ、表示されるWindowGroupのサイズを指定してもsimulator上だとなぜか反映されませんでした。

WindowGroup(id: "MainWindow") {
            ContentView()
       }.defaultSize(width: 400.0, height: 400.0)

のように指定しても、サイズが明らかにそれより大きいデフォルトのサイズになってしまいました。
Buildするとちゃんとしたサイズで表示されます。
実際のウィンドウのサイズを確かめたかったらBuildする必要があるようです。

自分の場合、複数のウィンドウをタブで切り替えられるようにするため、メインになるViewのContentView.swiftの中にTabView分岐をつくり、別に作ったサブのViewファイルであるTimerManager.swift / MusicManager.swift / AvatarManager.swiftに飛ぶようにしていました。

import Foundation
import SwiftUI
import UnityFramework

struct ContentView: View {
    @EnvironmentObject var appState: AppState
    
    var body: some View {
        TabView{
            TimeManager()
                .tabItem{
                    Image(systemName: "timer")
                    Text("Timer")
                }
            MusicManager()
                .tabItem{
                    Image(systemName: "music.note")
                    Text("Music")
                }
            AvatarManager()
                .tabItem{
                    Image(systemName: "figure")
                    Text("Avatar")
                }
        }.onAppear {
            callCSharpInputCallback("appeared", 0)
        }
    }
}

#Preview(windowStyle: .automatic) {
    ContentView()
}

onAppear内のcallCSharpInputCallbackはPolySpatial側に信号を送る箇所です。

ここで、ContentView内2行目の@Environmentの行は、Swift内の処理として保持しておきたいデータを保持できるようにするためのものです。
どうやらSwift UIのウィンドウを開き直すたびに処理が1から走り直すようで、開き直すたびにUIのToggleやSliderの位置が初期化されてしまう事態が発生してしまいます。
これを防ぐため、Viewファイルとは別に、保持したいデータを全て列挙した以下のようなsharedObjects.swiftを作成します。

import Foundation
import SwiftUI
import Combine

class AppState: ObservableObject {
    @Published var sliderValue: Int = 0
    @Published var toggleValue: Bool = true
}

そしてこのデータを使用したいViewでは、 上のContentViewのように

@EnvironmentObject var appState: AppState

の行を追加し、使用する箇所で

Toggle(isOn: $appState.toggleValue)

のようにappState内のtoggleValueを呼び出します。

必要なファイルを揃える

UIを作成したら、Swift UIとPolySpatialを連携させるのに必要な部分を作成します。
「Swift UIの表示を司るswiftスクリプト」「Unityからの信号を受け取りUnityに信号を送るSwift側のswiftスクリプト」「Swiftからの信号を受け取りSwiftに信号を送るUnity側のC#スクリプト」 の3つが必要です。

swift側

まず、先ほど作ったウィンドウの表示用に以下のようなファイルsampleInjectedScene.swiftを作成します。
ただし、ファイル名の末尾は必ずInjectedSceneで終えなければならないという制約があるので注意。

import Foundation
import SwiftUI

struct sampleInjectedScene {
    @SceneBuilder
    static var scene: some Scene {
        WindowGroup(id: "MainWindow") {
            ContentView().environmentObject(AppState())
        }.defaultSize(width: 400.0, height: 400.0)

        WindowGroup(id: "SimpleText") {
            Text("Hello World")
        }
    }
}

続いて、メインでUnity側と連絡を取り合うSwiftUIInputManager.swiftを作成します。

import Foundation
import SwiftUI

typealias InputCallbackDelegateType = @convention(c) (UnsafePointer<CChar>) -> Void

var sInputCallbackDelegate: InputCallbackDelegateType? = nil

public func callCSharpInputCallback(_ str: String, _ count: Int)
{
    print("Input Function Called: \(str) , \(count)")
    guard let callback = sInputCallbackDelegate else {
        print("Failed to let callback")
        return
    }
    // `String`と`Int`をJSON形式にまとめる
    let dataDict: [String: Any] = ["command": str, "count": count]
    guard let jsonData = try? JSONSerialization.data(withJSONObject: dataDict),
          let jsonString = String(data: jsonData, encoding: .utf8)
    else {
        print("Failed to serialize JSON")
        return
    }
    print("Sending JSON: \(jsonString)")
    // JSON文字列をC言語の文字列として送信
    jsonString.withCString {
        callback($0)
    }  
}

@_cdecl("SetNativeInputCallback")
func setNativeInputCallback(_ delegate: InputCallbackDelegateType)
{
    print("Setting native callback")
    sInputCallbackDelegate = delegate
    if sInputCallbackDelegate != nil {
        print("Callback has been set successfully")
    } else {
        print("Failed to set callback")
    }
}

@_cdecl("OpenSwiftUIInputWindow")
func openSwiftUIInputWindow(_ cname: UnsafePointer<CChar>)
{
    let openWindow = EnvironmentValues().openWindow
    let name = String(cString: cname)
    print("############ OPEN WINDOW \(name)")
    openWindow(id: name)
}

@_cdecl("CloseSwiftUIInputWindow")
func closeSwiftUIInputWindow(_ cname: UnsafePointer<CChar>)
{
    let dismissWindow = EnvironmentValues().dismissWindow
    let name = String(cString: cname)
    print("############ CLOSE WINDOW \(name)")
    dismissWindow(id: name)
}

ContentViewの説明にあったcallCSharpInputCallbackは、処理名のstringと変数としてのintをこのSwiftUIInputManagerに受け渡し、Unity側に送信するためのものでした。

callCSharpInputCallback("sampleFunc", 2)

のように関数名を表すstringと変数のIntをいれて使います。

Swift UIでToggleで値を変更する時など、関数を呼ぶだけではなくIntなどの変数の情報を送りたいときがあります。
PolySpatialが提供するsampleや冒頭で紹介した記事ではこの関数で送る情報は処理名のstringのみでしたが、上記では処理名のstringと変数のintをdictでセットにした上でJSON文字列に変換したものをC#側に送ることで、関数名のみならず変数のintもUnity側に送ることができます。
とChatGPTが教えてくれました。ありがとうChatGPT......

そのほか、@_cdecl("OpenSwiftUIInputWindow")の行や@_cdecl("CloseSwiftUIInputWindow")の行は、Unity側からSwiftのウィンドウの表示/非表示を切り替える指示を受け取るものです。
この部分に関してはsampleそのままでいいと思われます。

Unity側

Swiftと連絡をとるUnity側のスクリプトSwiftUIBridge.csを作ります。

using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using AOT;
using PolySpatial.Samples;
using UnityEngine;
using Newtonsoft.Json;  // JSON解析に使用するためにNewtonsoft.Jsonライブラリを追加

namespace Samples.PolySpatial.SwiftUI.Scripts
{
    // This is a driver MonoBehaviour that connects to SwiftUISamplePlugin.swift via
    // C# DllImport. See SwiftUISamplePlugin.swift for more information

    public class SwiftUIBridge : MonoBehaviour
    {
        [SerializeField]
        SpatialUIButton m_Button;

        [SerializeField]
        List<GameObject> m_ObjectsToSpawn;

        [SerializeField]
        Transform m_SpawnPosition;

        bool m_SwiftUIWindowOpen = false;

        void OnEnable()
        {
            m_Button.WasPressed += WasPressed;
            Debug.Log("Setting native callback");
            SetNativeInputCallback(CallbackFromNative);
            Debug.Log("Native callback should be set now");
        }

        void OnDisable()
        {
            Debug.Log("Unsetting native callback");
            SetNativeInputCallback(null);
            CloseSwiftUIWindow("MainWindow");
        }

        void WasPressed(string buttonText, MeshRenderer meshrenderer)
        {
            if (m_SwiftUIWindowOpen)
            {
                CloseSwiftUIWindow("MainWindow");
                m_SwiftUIWindowOpen = false;
            }
            else
            {
                OpenSwiftUIWindow("MainWindow");
                m_SwiftUIWindowOpen = true;
            }
        }

        [UnmanagedFunctionPointer(CallingConvention.Cdecl)]
        delegate void CallbackDelegate(IntPtr str);

        [AOT.MonoPInvokeCallback(typeof(CallbackDelegate))]
        private static void CallbackFromNative(IntPtr str)
        {
            string jsonString = Marshal.PtrToStringAnsi(str);
            Debug.Log($"Received JSON: {jsonString}");
            if (jsonString != null)
            {
                try
                {
                    // JSON文字列を辞書に変換
                    var dataDict = JsonConvert.DeserializeObject<Dictionary<string, object>>(jsonString);
                    // データを抽出
                    string command = dataDict["command"] as string;
                    int count = Convert.ToInt32(dataDict["count"]);
                    Debug.Log($"Received command: {command}, count: {count}");
                    HandleCommand(command, count);
                }
                catch (Exception e)
                {
                    Debug.LogError($"Failed to parse JSON: {e}");
                }
            }
            else
            {
                Debug.LogError("Received null or invalid string from native callback.");
            }
        }

        private static void HandleCommand(string command, int count)
        {
            Debug.Log($"Handling command: {command} with count: {count}");
            var self = FindObjectOfType<SwiftUIBridge>();
                if (self != null)
                {
                    if (command == "closed")
                    {
                        self.m_SwiftUIWindowOpen = false;
                        return;
                    }
                    if (command == "sampleFunc")
                    {
                        self.SampleFunc(count);
                    }

                }
                else
                {
                    Debug.LogError("SwiftUIBridge instance not found.");
                }
        }

        void SampleFunc(int value)
       {
             // ここに処理
       }


        #if UNITY_VISIONOS && !UNITY_EDITOR
        [DllImport("__Internal")]
        static extern void SetNativeInputCallback(CallbackDelegate callback);

        [DllImport("__Internal")]
        static extern void OpenSwiftUIWindow(string name);

        [DllImport("__Internal")]
        static extern void CloseSwiftUIWindow(string name);

        #else
        static void SetNativeInputCallback(CallbackDelegate callback) {}
        static void OpenSwiftUIWindow(string name) {}
        static void CloseSwiftUIWindow(string name) {}
        #endif

    }
}

PolySpatialのsampleからは、Swift側からJSONデータを受け取る箇所だけ変更があります。
受け取ったJSONを辞書型に直した後、stringとintに分けています。
分かれた後のデータはそれぞれstringとintとして扱うことができ、SampleFunc(count)のように関数に変数を代入できます。

あとはC#で好きなように関数を書くだけです。

ところで、今回の実装には含まれなかったため試していませんが、逆にUnity側からSwift側にstringを送る方法があれば、同様にJSONを用いて任意のデータを渡すことができると思われます。

Unity内にファイルを入れる

これらのファイルをUnity内に入れますが、以下の二点だけ注意が必要です。

ファイル階層を適切に設定

作成したswiftファイルのうち、Unity側との連携を行うSwiftUIInputManager.swift以外のファイルは、SwiftUIInputManager.swiftと同階層に「SwiftAppSupport」という名前のフォルダを作成し、そこに入れる必要があります。
名前と階層に指定があるので注意が必要です。

自分はScriptsフォルダー内にSwiftUIInputManager.swiftSwiftUIBridge.cs、そしてSwiftUIBridgeが呼び出す他のC#スクリプトを入れました。

SwiftUIBridge

swiftファイルのSelect platforms for pluginを設定

swiftファイルに関しては、書き出す際にVision Pro側で認識されるために、Inspector内のSelect platforms for pluginで「VisionOS」をチェックします。

Inspector / Select platforms for plugin

これをしないとSwiftに認識されないので注意。なぜかどこにも書かれておらず焦りました……

Buildして確認

以上をこなした上でUnityでBuildし、出力された.xcodeprojファイルをSwiftでBuildすると動作するはずです。

おわりに

長くなってしまいましたがUnityとSwift UI連携の話、そしてApple Vision Pro開発記でした。
まだまだ新しいデバイスゆえドキュメントもほとんどないので、苦しんだ過去をこのように供養させていただきました。

少しでもお役に立てれば幸いです。

もっと開発コミュニティがにぎやかになって、開発しやすくなったらうれしいな~と思っています。
あと僕のような学生Vision Pro開発者も増えるといいな~と思っています。

ありがとうございました。

ambr Tech Blog

Discussion