Vision Proアプリ開発記【3. Swift UIとUnityの連携編】
はじめに
こんにちは!ambrで4月からインターンをしている、こんしると申します。
前回に引き続き、「gogh: Spatial Focus Timer」の開発中壁にぶつかったポイントを、備忘録的にまとめたものになります。
この記事では、Unityで制作したアプリにSwift UIを実装する手法について説明します。
大部分は以下の記事を参考にしています。まずはこちらをどうぞ。
シリーズの他の記事はこちらからどうぞ!
- UnityでVision Proアプリを開発する環境構築
- Unityを介したアバターモデルのVision Pro移植
- Swift UIとUnityの連携(この記事)
Swift UIを連携させる
PolySpatialにはUIのサンプルがあり、黒くて押せる物理ボタンや黒背景で青く動かせるUI Panelなどが備わっています。
ただ、Apple純正のSwift UIのスタイリッシュなかっこよさはやはりPolySpatialのUIにはありません。
というわけで、UnityのPolySpatialを用いつつもUIだけはSwift UIを使いたい、となった次第です。
基本的には冒頭に挙げた記事やPolySpatial内のSwift UIのsampleを参考にして作ればいいのですが、いざ自分で作ろうとしたときに色々つまづいてしまいました。
手順を追って説明します。
UIの作成
まず、Swift側でUIを作成します。
ボタンを配置し、ウィンドウを変更するようにするなど諸々の機能を実装します。
実装したらSwift上でそのままシミュレーションできます。(自動でシミュレーションが走ります。便利だ…)
公式のチュートリアルがかなりわかりやすくなっていて、初学者でもこれを見ると何となく作れるようになります(なりました)。
余談ですが、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.swift
とSwiftUIBridge.cs
、そしてSwiftUIBridgeが呼び出す他のC#スクリプトを入れました。
swiftファイルのSelect platforms for pluginを設定
swiftファイルに関しては、書き出す際にVision Pro側で認識されるために、Inspector内のSelect platforms for pluginで「VisionOS」をチェックします。
これをしないとSwiftに認識されないので注意。なぜかどこにも書かれておらず焦りました……
Buildして確認
以上をこなした上でUnityでBuildし、出力された.xcodeproj
ファイルをSwiftでBuildすると動作するはずです。
おわりに
長くなってしまいましたがUnityとSwift UI連携の話、そしてApple Vision Pro開発記でした。
まだまだ新しいデバイスゆえドキュメントもほとんどないので、苦しんだ過去をこのように供養させていただきました。
少しでもお役に立てれば幸いです。
もっと開発コミュニティがにぎやかになって、開発しやすくなったらうれしいな~と思っています。
あと僕のような学生Vision Pro開発者も増えるといいな~と思っています。
ありがとうございました。
Discussion