🥽

visionOSで簡単なアプリを1つ作ろう

2023/12/16に公開

初めに

この記事は、フラー株式会社 Advent Calendar2023の17日目の記事となっています。
16日目はnosseさんによるフロントエンド未経験者が仕事をするためにやった1ヶ月の勉強でした!


本日は、12/17日です。ちなみに1217199番目の素数で199も素数です、とてもいい数字ですね。
今年はAppleからvisionProの発表があり、僕にとっては最高の一年となりました。皆さんもそうですよね?
ちなみに発表からしばらく経ちましたが、皆さんは既にvisionOSでの開発に取り組んでいますでしょうか?

この記事は、visionOSに興味はあるものの、まだ手をつけていない方を対象に、visionOSでひとまず簡単なアプリを作ってみようという趣旨で作った記事となります。

是非最後までお楽しみいただければ幸いです!

今回の記事を全て見終わると作れるもの

今回の記事を全て見終わると、下記のような、少しだけリッチな天気予報アプリが自分で作れるようになります。
https://youtu.be/S0u_UFAdGaM
それでは、visionOSについてをゆるく学びつつ、このアプリを一緒に作っていきましょう!

visionOS

visionOSでこれからアプリを作っていく前に、visionOSに関して3つ重要な構成要素が含まれており、完全に理解する必要があります。
WindowsVolumesSpacesです。

Windows

visionOSアプリでは、1つまたは複数のウインドウを作成できます。ウインドウはSwiftUIで作成し、従来のビューやコントロールを含めることができます。3Dコンテンツを追加して体験に奥行きを与えることもできます。

Windowsを簡易的に説明すると、SwiftUIでの従来のViewを表示させることができる要素です。
Appleの公式で公開されているHello Worldを参考にしながら説明すると、アプリ起動時表示されている長方形のものがWindowsであり、その中でViewを構築しています。

また、Windowsは可変可能で、.defaultSizeを使用することにより、サイズを変更できるようになります。

struct ExampleApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .defaultSize(CGSize(width: 500, height: 500))
    }
}

Windowsは、ユーザーの操作で可変可能となります。

これは、.windowResizabilityに対して、defaultautomaticが与えらているからです。
ユーザー操作での可変を制限するには、contentSize、またはcontentMinSizeを使用することにより可変できる大きさを調節させます。

以上より、WindowsViewを構成する中で重要な要素であり、内部でViewを作成することが出来、画面自体の大きさを可変することが出来る要素となります。

Volumes

3Dボリュームを使用してアプリに立体感を与えましょう。ボリュームとは、RealityKitやUnityを使用して3Dコンテンツを表示できるSwiftUIのシーンのことです。ボリュームは、共有スペースやアプリのフルスペースにおいて、どの角度からでも見ることができる体験を作り出します。

Volumesを簡易的に説明すると、3Dコンテンツを表示することができる3D空間の箱です。Voluemesを使用することにより、3DコンテンツをWindowsの中などに表示することが出来ます。
下記画像では、Windowsの中に、月の3Dモデルが表示されているかと思われます。この3DモデルがVolumesとなります。

Voluemsを表示させるには、Model3Dを使います。または、後述するRealityKitを用いた実装で表示することもできます。

struct ExampleView: View {
    var body: some View {
        Model3D(named: "Example")
    }
}

Model3Dは、URLから直接コンテンツを表示させることもできます。

struct ExampleView: View {
    var body: some View {
         Model3D(url: URL(string: "https://example.com/robot.usdz")!)
    }
}

以上より、Volumesは3Dコンテンツを表示し、ユーザーに対して立体的なViewを提供する要素となります。

Spaces

デフォルトでは、アプリは共有スペースで起動されます。Macのデスクトップ表示のように複数のアプリが並んで配置されます。アプリはウインドウやボリュームを使用してコンテンツを表示し、ユーザーはそれらの要素を好きな場所に移動できます。よりイマーシブ感のある体験を提供するために、アプリ専用のフルスペースを開き、そのアプリのコンテンツだけを表示することもできます。フルスペースでは、ウインドウやボリュームを使用して自在に3Dコンテンツを作成し、別の世界へのポータルを出現させたり、ユーザーに環境へのフルイマーシブな体験を提供したりできます。

Spacesを簡易的に説明すると、フルイマーシブな体験を提供する要素です。
Hello Worldアプリで、The Solar SystemView Outer Spaceを押下すると下記のような画面が表示されます。
そしてこの空間を表現しているのがSpacesとなります。

Spacesの実装に関しては、ImmersiveSpaceで表現する形となっており、本章の実践で紹介しますのでお楽しみにしていてください、

以上より、Spacesはユーザーに対して没入感のある画像や動画を提供する要素となります。

RealityKit

RealityKit は、visionOS アプリの作成や、iOS、macOS、tvOS 用の拡張現実 (AR) アプリの作成に使用できる、高性能 3D シミュレーションおよびレンダリング機能を提供します。RealityKit は、ARKitを利用して仮想オブジェクトを現実世界にシームレスに統合するAR ファースト 3D フレームワークです。

RealityKitは、記述されている通り、visionOSやARアプリの開発を手助けしてくれるフレームワークです。今回ImmersiveSpaceを実装する際に使用します。

RealityKitの重要な概念として、Entityと呼ばれるものがあります。
Entityは3D空間内のオブジェクトや要素を表す基本的な構成要素です。

Entityは視覚的な表現や動作を持つことができ、例えば、3Dモデル、ライト、カメラ、アニメーションなどがEntityとして表現されます。

RealityKitは、visionOSと合わせて高度なグラフィック体験を使用する際に必ず使う要素なので、まだ使用したことがない方は下記を一度参照してみてください。
https://developer.apple.com/jp/augmented-reality/realitykit/

実践

それでは、冒頭で説明した天気予報アプリを作っていきましょう。

プロジェクト作成

  1. 初めに、Xcodeを開きNew Projectで新しいプロジェクトを作成してください。

  2. 次に、Choose a template for your new projectvisionOSAppを選択し、Nextを選択してください。
    この画面でvisionOSAppが存在しない方は、visionOSSDKをダウンロードしていない可能性がありますので、公式サイトからダウンロードしてください。
    https://developer.apple.com/jp/visionos/
    また、Xcodeのバージョンが対応していない可能性もありますので、Xcode15 beta2以上を使用してください。

  3. Initial SceneWindowSceneから選択できます。今回はdefaultWindowを選択しましょう。

  4. Immersive Space Rendererで、NoneRealityKitMetalから選択できます。今回はdefaultNoneを選択して、Nextを押下してください。

以上で新規プロジェクト作成は完成です。

visionOSアプリの実装

それではvisionOSアプリの実装に入っていきます。
今回、実装の説明を設けて記述してありますが、visionOS関連のことだけ説明し、SwiftUIや他技術のことに関しては省略しておりますので悪しからず。

Spacesの実装

プロジェクトの作成でアプリを作成したので、現在は、App.swifContentView.swiftがアプリ内に存在するかと思います。

それでは、今回天気予報のアプリを作成するため、天気のモデルを作成しましょう。
Weather.swiftファイルを作成し、下記を追加してください。完了したら、Assets.xcassets内でのImage setで下記モデル名の画像を作成してください。

Weaher.swift
enum Weather: String {
    case rain
    case snow
    case sunny
}

次に、ImmersiveView.swiftというファイルを作成し、下記を追加してください。

ImmersiveView.swift
import Combine
import SwiftUI
import RealityKit

struct ImmersiveView: View {
    // MARK: - Properties
    @Binding
    private(set) var weatherName: Weather
    @State
    var cancellable: AnyCancellable?

    // MARK: - View
    var body: some View {
        RealityView { content in
            let entity = Entity()
            cancellable = TextureResource.loadAsync(named: weatherName.rawValue).sink { error in
                print(error)
            } receiveValue: { resource in
                var material = UnlitMaterial()
                material.color = .init(texture: .init(resource))
                entity.components.set(ModelComponent(
                    mesh: .generateSphere(radius: 1000),
                    materials: [material]
                ))
                entity.scale *= .init(x: -1, y: 1, z: 1)
            }
            content.add(entity)
        }
    }
}

実装の内容について説明です。

RealityViewは、XRコンテンツを描画するための親コンテナとなります。

        RealityView { content in }

RealityKitの章で説明した通り、3Dオブジェクトを表現するためにEntityを作成し、TextureResource.loadAsyncで非同期のテクスチャを読み込むためのCombine操作を開始します。
成功した際に、テクスチャを使用し、Entityに適応させます。今回は、球体(UnlitMaterial)を生成し、その表面にテクスチャを適用させています。

            let entity = Entity()
            cancellable = TextureResource.loadAsync(named: weatherName.rawValue).sink { error in
                print(error)
            } receiveValue: { resource in
                var material = UnlitMaterial()
                material.color = .init(texture: .init(resource))
                entity.components.set(ModelComponent(
                    mesh: .generateSphere(radius: 1000),
                    materials: [material]
                ))
                entity.scale *= .init(x: -1, y: 1, z: 1)
            }

最後に、作成したエンティティをXRコンテンツにaddします。

            content.add(entity)

次にApp.swiftを下記のように修正してください。

App.swift
import SwiftUI

@main
struct ParticleWeatherApp: App {
    // MARK: - Properties
    @State
    var weatherName: Weather = .snow
    @Environment(\.openImmersiveSpace)
    var openImmersiveSpace
    @Environment(\.dismissImmersiveSpace)
    var dismissImmersiveSpace

    // MARK: - View
    var body: some Scene {
        WindowGroup {
            ContentView()
                .task {
                    await openImmersiveSpace(id: "ImmersiveSpace")
                }
        }
        .defaultSize(width: 1.5, height: 0.5, depth: 0, in: .meters)

        ImmersiveSpace(id: "ImmersiveSpace") {
            ImmersiveView(weatherName: $weatherName)
        }
        .immersionStyle(selection: .constant(.full), in: .full)
    }
}

実装の内容について説明です。

今回定義しているプロパティに関して、環境変数のopenImmersiveSpacedismissImmersiveSpaceを定義しています。
この2点は、ImmersiveSpaceを開閉するための処理を提供している重要な概念です。
https://developer.apple.com/documentation/swiftui/environmentvalues/openimmersivespace
https://developer.apple.com/documentation/swiftui/environmentvalues/dismissimmersivespace

    @Environment(\.openImmersiveSpace)
    var openImmersiveSpace
    @Environment(\.dismissImmersiveSpace)
    var dismissImmersiveSpace

ContentViewが初めて表示された際に実行される非同期タスクの.taskawait openImmersiveSpace(id: "ImmersiveSpace")を実行し、ImmersiveSpaceを表示させるようにしています。
そして、.defaultSize(width: 1.5, height: 0.5, depth: 0, in: .meters)を使用し、初回表示するWindowsの大きさを設定しています。
https://developer.apple.com/documentation/swiftui/scene/defaultsize(width:height:depth:in:)

        WindowGroup {
            ContentView()
                .task {
                    await openImmersiveSpace(id: "ImmersiveSpace")
                }
        }
        .defaultSize(width: 1.5, height: 0.5, depth: 0, in: .meters)

ImmersiveSpace(id: "ImmersiveSpace")を宣言し、今回表示させるImmersiveViewのコンテンツを表示させています。
そして、.immersionStyle(selection: .constant(.full), in: .full)で、コンテンツのスタイルを.fullに設定しています。
https://developer.apple.com/documentation/swiftui/immersionstyle

        ImmersiveSpace(id: "ImmersiveSpace") {
            ImmersiveView(weatherName: $weatherName)
        }
        .immersionStyle(selection: .constant(.full), in: .full)

現状のもので一度Buildしてみてください。下記のような画面が表示されていると思います!
これでSpacesの実装が一通り完成しました。

Windowsの実装

続いて、天気予報のViewを表現するため、WeatherForecast.swiftを作成し、天気予報のモデルを作成してください。

WeatherForecast.swift
import Foundation

struct WeatherForecast {
    // MARK: - Properties
    var id = UUID().uuidString
    let time: String
    let temperature: String
    let weather: Weather
}

WeatherForecast.swiftを作成したら、いよいよContentViewの修正です。
下記のようにContentViewを修正してください。

ContentView
import RealityKit
import RealityKitContent
import SpriteKit
import SwiftUI

struct ContentView: View {
    // MARK: - Properties
    @Binding
    private(set) var weather: Weather
    private let weatherForecasts: [WeatherForecast] = ["{データを適当に複数追加}"]

    // MARK: - View
    var body: some View {
        GeometryReader { geo in
            ZStack {
                if let weatherParticle = weatherParticle(size: geo.size, weather: weather) {
                    SpriteView(scene: weatherParticle)
                }
                VStack {
                    weatherInformation()
                    forecastScrollView()
                }
                .padding()
                .position(x: geo.size.width / 2, y: geo.size.height / 2)
            }
        }
    }

    private func weatherInformation() -> some View {
        HStack {
            VStack {
                Text("12月14日(木)")
                    .font(.system(size: 60))
            }
            .padding()

            VStack {
                Text("現在地")
                Text("10°")
                HStack {
                    Text("最\n高")
                    Text("10°")
                    Text("最\n低")
                    Text("2°")
                }
                .font(.system(size: 25))
                Text(weather.rawValue)
                    .font(.system(size: 35))
            }
            .padding()
        }
        .shadow(radius: 5)
        .font(.system(size: 50))
        .bold()
    }

    private func forecastScrollView() -> some View {
        ScrollView(.horizontal) {
            HStack {
                ForEach(weatherForecasts, id: \.id) { weatherForecast in
                    Button(action: {
                        weather = weatherForecast.weather
                    }, label: {
                        weatherCell(weatherForecast: weatherForecast)
                    })
                    .padding()
                }
            }
        }
    }

    private func weatherCell(weatherForecast: WeatherForecast) -> some View {
        VStack {
            Text(weatherForecast.time)
                .font(.system(size: 25))

            Model3D(named: modelFileName(for: weatherForecast.weather)) { model in
                model
                    .resizable()
                    .aspectRatio(contentMode: .fit)
                    .frame(width: 50, height: 50)
                    .padding()
            } placeholder: {
                ProgressView()
            }

            Text(weatherForecast.temperature)
                .font(.system(size: 25))
        }
        .shadow(radius: 5)
        .bold()
        .frame(width: 200, height: 200)
    }

    private func modelFileName(for weather: Weather) -> String {
        return weather == .sunny ? "Sun" : weather == .rain ? "Rain" : "Snow"
    }

    private func weatherParticle(size: CGSize, weather: Weather) -> SKScene? {
        guard weather != .sunny else { return nil }

        let fileName: String = weather == .rain ? "RainParticle" : "SnowParticle"
        let backgroundNode = SKSpriteNode(imageNamed: weather.rawValue)
        backgroundNode.position = CGPoint(x: size.width / 2, y: size.height / 2)
        let emitterNode = SKEmitterNode(fileNamed: fileName)!
        let scene = SKScene(size: size)
        scene.addChild(backgroundNode)
        scene.addChild(emitterNode)
        scene.anchorPoint = .init(x: 0.5, y: 1)
        return scene
    }
}

今回、RainSnowSunの3つの3Dモデルをアプリで使用しています。
3Dモデルに関しては、Reality Composer Proで作成してください。
それか、作成に手をつけたくないとのことでしたら、sketchfabというサイトで無料のusdzの3Dモデルをダウンロードできるので、各々で用意してください。
https://sketchfab.com/search?type=models

Particleについてですが、今回、SnowParticleRainParticleを使用しています。
New Fileから2つのParticleを作成してください。

それでは、実装の内容について説明です。

Model3DVolumes章で説明した通り、3Dモデルを非同期的に表示するViewです。
今回はinit(named:bundle:content:placeholder:)を使用し、読み込み時に、ProgressView()を表示させるように実装しています。

また、クロージャーで戻り値modelを宣言し、サイズやアスペクト比率を調節しています。
https://developer.apple.com/documentation/realitykit/model3d/init(named:bundle:content:placeholder:)

            Model3D(named: modelFileName(for: weatherForecast.weather)) { model in
                model
                    .resizable()
                    .aspectRatio(contentMode: .fit)
                    .frame(width: 50, height: 50)
                    .padding()
            } placeholder: {
                ProgressView()
            }

weatherParticle(_:)で、Viewの背景を季節に合わせたものに設定するようにしており、Model3Dの引数にmodelFileNameを経由し、表現するようにしています。

private func weatherParticle(size: CGSize, weather: Weather) -> SKScene? {}
Model3D(named: modelFileName(for: weatherForecast.weather))

最後にweatherNameが変わったときにImmersiveSpaceを変更したいので、App.swiftを下記のように修正してください。

App.swift
import SwiftUI

@main
struct ParticleWeatherApp: App {
    // MARK: - Properties
    @State
    var weatherName: Weather = .snow
    @Environment(\.openImmersiveSpace)
    var openImmersiveSpace
    @Environment(\.dismissImmersiveSpace)
    var dismissImmersiveSpace

    // MARK: - View
    var body: some Scene {
        WindowGroup {
            ContentView(weather: $weatherName)
                .task {
                    await openImmersiveSpace(id: "ImmersiveSpace")
                }
                .onChange(of: weatherName) { _, newValue in
                    Task {
                        await dismissImmersiveSpace()
                        await openImmersiveSpace(id: "ImmersiveSpace")
                    }
                }
        }
        .defaultSize(width: 1.5, height: 0.5, depth: 0, in: .meters)

        ImmersiveSpace(id: "ImmersiveSpace") {
            ImmersiveView(weatherName: $weatherName)
        }
        .immersionStyle(selection: .constant(.full), in: .full)
    }
}

以上で冒頭で説明した通りの少しだけリッチな天気予報アプリが完成しているかと思います!
いかがでしただしょうか?
簡単にvisionOSでアプリを1つ作れたのではないでしょうか!?!?

終わりに

今回作成したもので、ImmersiveSpaceの背景を変更する際に、一度閉じて再度開く処理をしていると思います。現状(2023/12/17)、おそらくSpacesを簡易的に更新する処理が追加されていないので不細工な処理になっています。Appleさん、対応して!

ちなみに弊社CTOが、会社用にvisionProを1台は必ず買うと言われていたので、僕は楽しみにしつつ当記事を書いてました。
購入された会社のvisionProを使うのが最近の僕の夢ですので、楽しみしてますね弊社!!!!!!!
そんな素敵な会社を宣伝しておきます。
https://recruit.fuller-inc.com/

ということで、今回のvisionOSで簡単なアプリを1つ作ろうの記事を終わりとさせていただきます。

次回はcanacelさんのPointFreeのサブスクに登録してみた話です!

Discussion