Apple Engine

Apple, iPhone, iOS, その周辺のことについて

SwiftUI でカスタムビューの SceneKit を表示する (Xcode 11 Beta 1)

以下の変更が行われた。
記事のままでは動かず、書き直すのが面倒なので、
リンク先のサンプルコードの ContentView.swift を参照。

 

Beta 4
  • BindableObject が ObservableObject に変更
  • BindableObject が didChange から willChange へ変更
  • List や ForEach で id パラメーターが必須に変更
Beta 5
  • ObservableObject の willChange が @Published のアノテーション記述へ変更

 

サンプルコード

github.com

 


 

基本的には SwiftUI Tutorials で MKMapView を実装しているのと同じ。

環境は Xcode 11.0 Beta 1 を使用しているので、今後動かなくなる可能性があるので注意。

 

今回つくるもの

f:id:x67x6fx74x6f:20190617041655p:plain
完成予想図

SceneKit の SCNView を上部、リストをその下に配置する。
そして、リストをタップすると SceneKit のシーンのカメラポジションを変更される。

ちなみに今回つくるのは iOS 版。
あと、ランドスケープ時の処理を特に入れてないので、横に傾けると描画がおかしくなるので注意。

 

プロジェクトを作成する

Xcode 11.0 の Beta 版を起動し、iOS のテンプレートから「Single View App」を選択し右下の「Next」ボタンを押し、Use SwiftUI のチェックボタンを押してプロジェクトを保存。

ContentView.swift の中身を編集していく。

とりあえず、今回は SceneKit と Combine を使用するので以下を書く。

import SceneKit
import Combine

 

また、今回は ContentView.swift へ全てコードを書いていため、
デバッグも ContentView 部分の記載しかない。

個別でプレビューやデバッグをしたい場合は、個々の View の設定をする。

 

カスタムビューの SCNView をつくる

カスタムビューの概要

カスタムビューを作成する場合は UIViewRepresentable を使用して SwiftUI をの body で使えるカスタムの View にする。
カスタムの View の振る舞いは以下の 4 つ

  • View の値やデリゲートを結びつけるコーディネーター設定
  • View の初期化処理
  • View の更新処理
  • View が破棄される際に行う View やコーディネーターのクリーンナップ処理

 

これに合わせて関数が用意されている。

  • makeCoordinator()
  • makeUIView(context:)
  • updateUIView(_:context:)
  • dismantleUIView(_:coordinator:)

 

今回は makeCoordinator と dismantleUIView 使用しないのでコードを書かないが、makeUIView と updateUIView は設定しないとビルドエラーになるので注意。

 

カスタムビューのコード書く

SceneView という構造体に UIViewRepresentable を準拠させ、makeUIView の関数で SCNView とその中身を書く。

makeUIView の中身はいつも通り SCNView を設定し、シーンをシーンファイルから読み込んでいる。

struct SceneView: UIViewRepresentable {
    
    func makeUIView(context: Context) -> SCNView {
        let view = SCNView(frame: .zero)
        let scene = SCNScene(named: "main.scn")

        view.allowsCameraControl = true
        view.scene = scene

        return view
    }

    func updateUIView(_ view: SCNView, context: Context) {
    }
}

 

起点のビューで先ほど作った SceneView を表示させる

ここでは、アプリ起動の際に最初に実行されるビューコントローラー UIHostingController のビューとなる ContentView の body に SceneView を表示させるよう設定する。

ただ SceneView を設定して高さを設定するだけ。

struct ContentView : View {
    var body: some View {
        
        // SceneKit
        SceneView()
            .frame(height: 300)

    }
}

 

これをプレビューか実行すると中央に SceneKit の内容が描画される。

 

下にリストを表示するコードを書く

はじめに TableView となる List のカスタムセル TableRow 作成する。
固定文字と数値を渡して文字を表示する。

struct TableRow: View {
    var number:Int

    var body: some View {
        VStack(alignment: .leading) {
            Text("Camera: \(number)")
                .font(.title)
            Text("description: !!!!!!!")
                .font(.subheadline)
        }
        .padding(8)
    }
}

 

VStack を使用し、View を縦並びに表示できるようにし、先ほどの SceneView と TableRow を使用した List のコードを書き表示させる。
先ほどの ContentView を以下のように修正する。

struct ContentView : View {
    var body: some View {
        VStack(alignment: .leading) {

            // SceneKit
            SceneView()
                .frame(height: 300)
            
            // TableView
            List(0...2){ i in
                TableRow(number: i)
            }

        }
    }
}

 

これをプレビューか実行すると SceneKit の内容の下にリスト(TableView)が描画される。

 

リストをタップしてシーンのカメラ位置を変更する

今回は Combine フレームワークを使用して BindableObject を作成する。

こちらを使用するとビューの階層に関係無く、値の入出力を紐付け、 どこかで変更が起こった場合はその変化を伝播させる。
今までは通知を使用していたものなどをこちらで代用できる。

ちなみに今回は双方向かつ即更新されるが、一方のみや時間をおいて更新したり、本来は細かい設定ができる。

 

BindableObject を作成する

カメラの位置を Combine を使用し CameraInfo.cameraNumber の UInt 値で保持する。
didChange で値の変更 PassthroughSubject でそのまま渡し、
cameraNumber の didSet で、didChange の send で変更を保存する。

以下、BindableObject で CameraInfo を設定する

final class CameraInfo: BindableObject  {
    let didChange = PassthroughSubject<CameraInfo, Never>()

    var cameraNumber:UInt = 0 {
        didSet {
            didChange.send(self)
        }
    }
}

 

SceneView に値をバインディングさせカメラ位置を変更させるコードを書く

@Binding を使用してカスタムビューに BindableObject と紐づけ、BindableObject の値が変更されるとカメラ位置が変更される。

@Binding のコードを追加して、バインドされた値が変更された時、 updateUIView でカメラ位置を変更するアニメーションを行う。

カメラはシーンファイルに「Camera0」「Camera1」「Camera2」と設定されており、childNode から探し、移動するアニメーションを設定する。

以下、SceneView を変更する。

struct SceneView: UIViewRepresentable {
    @Binding var cameraNumber:UInt

    func makeUIView(context: Context) -> SCNView {
        let view = SCNView(frame: .zero)
        let scene = SCNScene(named: "main.scn")

        view.allowsCameraControl = true
        view.scene = scene

        return view
    }

    func updateUIView(_ view: SCNView, context: Context) {
        let camera = view.scene?.rootNode.childNode(withName: "Camera\( cameraNumber)", recursively: true)!

        SCNTransaction.begin()
        SCNTransaction.animationDuration = 1.0

        view.pointOfView = camera

        SCNTransaction.commit()

    }
}

 

ContentView に BindableObject の設定をする

@EnvironmentObject で BindableObject を使用できるようにして、 SceneView には BindableObject の変更を渡し、List の中身をボタンにする。

それをタップすると BindableObject の中身を変更するようにする。

バインドする際には $ を先頭につけ Binding<UInt> として渡し、リスト内のボタンではアクションのブロックになっているため参照する際には self をつける。

struct ContentView : View {
    @EnvironmentObject var cameraInfo: CameraInfo

    var body: some View {
        VStack(alignment: .leading) {

            // SceneKit
            SceneView(cameraNumber: $cameraInfo.cameraNumber)
                .frame(height: 300)

            // TableView
            List(0...2){ i in
                Button(action: {
                    self.cameraInfo.cameraNumber = UInt(i)
                }) {
                    TableRow(number: i)
                }

            }

        }
    }
}

 

仕上げ

このまま、プレビューや実行をしてもエラーが起きる。

ContentView で「@EnvironmentObject var cameraInfo: CameraInfo」を設定しているため、呼び出す際には EnvironmentObject となる BindableObject (CameraInfo) を設定する必要がある。

SceneDelegate.swift や ContentView.swift の「ContentView()」の箇所を以下のように変更する。

ContentView().environmentObject(CameraInfo())

 

面倒であれば Command + Shift + F でプロジェクト内検索を行い「ContentView()」を「ContentView().environmentObject(CameraInfo())」に書き換えても良いだろう。

 

サンプルコード

github.com

 

まとめ

割と簡単にカスタムのビューの追加とバインディングできたと思われる。

今回は画面が遷移しないので Combine を使用する必要性が薄く、@State で代用できるのだがバインディングが簡単にできるということを示してみた。