【Swift】 ARKit × UIKit × SceneKit で AR 上に 3D オブジェクトのデジタルタイマーを作成する (Xcode 必須)

### 【はじめに】
ARKit × UIKit × SceneKit で デジタルタイマーを AR 上に作成してみました。
Xcode を用いた作成手順とソースコードを公開します。

##
### 完成形のイメージ (gif)
(ファイルサイズの関係でボタンの文字が見えにくいですがご容赦ください。)
![AR_Timer.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3525158/3266d2ee-119e-b03f-d1bd-1e3961f87fcd.gif)

:::note warn
動作確認には iPhone または iPad の実機が必要になります。
エミュレータ(仮想端末上)では一部機能が正しく動作しない点に注意してください。
:::

# 目次
[1. 機能説明](#1-機能説明)
[2. Xcode でプロジェクトを作成](#2-プロジェクトを作成)
[3. コード全文](#3-コード全文)
[4. 動作確認](#4-動作確認)
[5. コード解説](#5-コード解説)
[6. おわりに](#6-おわりに)

##
### 1.機能説明
・Reset ボタン(赤いボタン): タイマー値をリセットして 0 秒にする
・Start/Stop ボタン(青いボタン): タイマーをスタートする。既にタイマーが動作している場合に押すと、タイマーがストップする。
・Adjust ボタン: タイマーの3Dオブジェクトの位置を調整する。(画面中央に移動させる)

##
### 2. プロジェクトを作成
Xcode で SceneKit 用のプロジェクトを新規作成します。

:::note warn
手順と異なる方法で作成した場合、コードが動作しない可能性があります。
:::
##
手順 1 : Xcode を開き、Create New Project を選択。

![スクリーンショット 2023-12-26 0.15.38.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3525158/a8578d90-83fb-d881-2925-f76df605db3b.png)
##
手順 2 : Augmented Reality App を選択し、画面右下部の Next ボタンを押下。

![スクリーンショット 2023-12-25 21.12.55.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3525158/7af4ca24-5afd-3e87-7ebf-ad6462e90c73.png)
##
手順 3 : Product Name、Organization Identifier 欄にて、任意のプロジェクト名を入力。
Interface 欄は SwiftUI、
Language 欄は Swift、
Content Technology 欄は Scenekit をそれぞれ選択。
その後、画面右下部の Next ボタンを押下。

![スクリーンショット 2023-12-25 22.41.46.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3525158/a5a84b51-305a-8341-0f16-d1d905170445.png)
##
手順 4 : プロジェクトを作成するフォルダを選択して、画面右下部の Create ボタンを押下。

![スクリーンショット 2023-12-25 22.43.41.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3525158/e7f5d206-4c0f-0b6b-8800-9a2bd98eba53.png)

手順 5 : プロジェクト画面が開いたら画面左上部のプロジェクト名が表示されている箇所を選択する。
すると、下記のようなプロジェクト情報画面が表示される為、Minimum Deployments に表示されている iOS のバージョンが、動作確認に使用する端末のバージョン以下になるように変更する。
(端末のバージョンが iOS 16.0 の場合は、Minimum Deployments を 16.0 かそれ以下のバージョンに変更する。)

![スクリーンショット 2023-12-25 23.06.00.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3525158/e3119f49-ad96-0d7b-7379-6592d70d3832.png)

設定完了！ プロジェクトフォルダ内が下記画像と同じならOK。

:::note warn
足りないファイル等がある場合は手順と作成方法が同じか確認してみてください。
:::

![スクリーンショット 2023-12-26 0.42.53.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3525158/eba1940f-c774-eed0-69c8-a05cd499d926.png)

##
### 3. コード全文
**ViewController.swift** ファイルのコードを下記コードに変更してください。

:::note info
[Github でコードを確認する場合はこちらをクリック](https://github.com/Tomoshi3104/SceneKit_digital_timer.git)
:::

```swift: ViewController.swift
import UIKit
import SceneKit
import ARKit

// UIViewControllerを継承したクラス ViewController
class ViewController: UIViewController, ARSCNViewDelegate, ARSessionDelegate {
    // ARSCNViewのIBOutlet
    @IBOutlet var sceneView: ARSCNView!
    // AR上のタイマーのノード
    var timerNode: SCNNode?
    // タイマー開始時刻
    var startTime: Date?
    // タイマーが実行中かどうかを示すフラグ
    var isTimerRunning = false
    // タイマーの状態変化が発生したかどうかを示すフラグ
    var isStatusChanged = false
    // タイマーの値を保持するためのプロパティ
    var timerBaseValue: TimeInterval = 0
    
    // ボタンの追加とレイアウト制約の設定
    let startStopButton: UIButton = {
        let button = UIButton()
        button.setTitle("Start", for: .normal)
        button.setTitleColor(.white, for: .normal)
        button.backgroundColor = .blue
        button.layer.cornerRadius = 8
        //button.addTarget(ViewController.self, action: #selector(startStopButtonTapped), for: .touchUpInside) // クラッシュする
        button.addTarget(self, action: #selector(startStopButtonTapped), for: .touchUpInside)
        return button
    }()
    
    // リセットボタンの追加とレイアウト制約の設定
    let resetButton: UIButton = {
        let button = UIButton()
        button.setTitle("Reset", for: .normal)
        button.setTitleColor(.white, for: .normal)
        button.backgroundColor = .red
        button.layer.cornerRadius = 8
        button.addTarget(self, action: #selector(resetButtonTapped), for: .touchUpInside)
        return button
    }()
    
    // Adjustボタンの追加とレイアウト制約の設定
    let adjustButton: UIButton = {
        let button = UIButton()
        button.setTitle("Adjust", for: .normal)
        button.setTitleColor(.white, for: .normal)
        button.backgroundColor = .green
        button.layer.cornerRadius = 8
        button.addTarget(self, action: #selector(adjustButtonTapped), for: .touchUpInside)
        return button
    }()
    
    // UIViewControllerのライフサイクルメソッド viewDidLoad
    override func viewDidLoad() {
        super.viewDidLoad()
        // ARSessionのデリゲートをViewController自体に設定
        self.sceneView.session.delegate = self
        // ARSCNViewのデリゲートをViewController自体に設定
        sceneView.delegate = self
        // fpsやタイミング情報を表示
        sceneView.showsStatistics = true
        // 新しいシーンを作成
        let scene = SCNScene()
        sceneView.scene = scene
        // Start/Stopボタンをビューに追加
        view.addSubview(startStopButton)
        setupConstraints()
        // Resetボタンをビューに追加
        view.addSubview(resetButton)
        setupResetButtonConstraints()
        // Adjustボタンをビューに追加
        view.addSubview(adjustButton)
        setupAdjustButtonConstraints()
    }
    
    // UIViewControllerのライフサイクルメソッド viewWillAppear
    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        // ARセッションの設定
        let configuration = ARWorldTrackingConfiguration()
        // ARSCNViewのセッションを開始
        sceneView.session.run(configuration)
        // タイマーを配置
        placeTimer()
    }
    
    // UIViewControllerのライフサイクルメソッド viewWillDisappear
    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        // ARSCNViewのセッションを一時停止
        sceneView.session.pause()
    }
    
    // MARK: - ARSCNViewDelegate
    
    // ARSCNViewのデリゲートメソッド renderer -
    func renderer(_ renderer: SCNSceneRenderer, updateAtTime time: TimeInterval) {
        //print("rendering")
        if isTimerRunning { // タイマーが実行中の場合にのみ時間を更新する
            if let startTime = startTime {
                let elapsedTime: Double
                if timerBaseValue != 0 {
                    elapsedTime = Date().timeIntervalSince(startTime) + timerBaseValue
                    print(Date().timeIntervalSince(startTime), elapsedTime)
                } else {
                    print(Date().timeIntervalSince(startTime))
                    elapsedTime = Date().timeIntervalSince(startTime)
                }
                let StringTime = formatTime(seconds: elapsedTime)
                DispatchQueue.main.async {
                    self.updateTextNode(text: StringTime)
                }
            }
        } else {
            if isStatusChanged { // stop ボタン、または reset ボタン押下時の処理
                isStatusChanged = false
                if let startTime = startTime {
                    readTextNode()
                    updateTextNode(text: formatTime(seconds: timerBaseValue))
                } else {
                    DispatchQueue.main.async {
                        self.updateTextNode(text: "00:00.00")
                    }
                }
            }
        }
    }
        
    // MARK: - Timer Methods
    
    // Start / Stop ボタン押下時の処理
    @objc func startStopButtonTapped() {
        if isTimerRunning { // Stop 処理
            stopTimer()
            startStopButton.setTitle("Start", for: .normal)
        } else { // Start 処理
            startTimer()
            startStopButton.setTitle("Stop", for: .normal)
        }
    }
    
    // Reset ボタン押下時の処理
    @objc func resetButtonTapped() {
        resetTimer() // タイマーを停止
        //updateTextNode(text: "00:00.00") // テキストノードをリセット
        startStopButton.setTitle("Start", for: .normal) // Start/Stopボタンのテキストをリセット
    }
    
    // Adjust ボタン押下時の処理
    @objc func adjustButtonTapped() {
        adjustTimer()
    }
    
    // タイマーを描画するメソッド
    func placeTimer() {
        updateTextNode(text: "00:00.00")
    }
    
    // タイマーを開始するメソッド
    func startTimer() {
        if !isTimerRunning {
            startTime = Date()
            isTimerRunning = true
        }
    }
    
    // タイマーを停止するメソッド
    func stopTimer() {
        if isTimerRunning {
            isTimerRunning = false
            isStatusChanged = true
        }
    }
    
    // タイマーをリセットするメソッド
    func resetTimer() {
        startTime = nil
        timerBaseValue = 0
        isTimerRunning = false
        isStatusChanged = true
    }
    
    // MARK: - ARSessionDelegate
    
    // タイマーを画面中央に再配置するメソッド
    func adjustTimer() {
        //print(sceneView.pointOfView!)
        if let camera = sceneView.pointOfView {
            let position = SCNVector3(x: -0.2, y: -0.1, z: -0.5) // Set the position relative to the camera
            let convertedPosition = camera.convertPosition(position, to: nil)
            timerNode?.position = convertedPosition
            timerNode?.eulerAngles = camera.eulerAngles // Set the node's orientation to match the camera's orientation
        }
    }
    
    // MARK: - UI Updates
    
    // テキストノードを更新するメソッド
    func updateTextNode(text: String) {
        // テキストノードがまだ作成されていない場合は作成する
        if timerNode == nil {
            print("timerNode created.")
            timerNode = createTextNode(text: text)
            sceneView.scene.rootNode.addChildNode(timerNode!)
        } else {
            // テキストノードが既に存在する場合は、テキストを更新する
            if let textGeometry = timerNode?.geometry as? SCNText {
                textGeometry.string = text
            }
        }
    }
    
    // テキストノードに表示された経過時間を読み取るメソッド
    func readTextNode(){
        // テキストノードが既に存在する場合は経過時間を読み取る
        if timerNode != nil {
            if let textGeometry = timerNode?.geometry as? SCNText {
                let timerString: String = textGeometry.string as! String
                print("timerString: \(timerString)")
                // 経過時間を型変換して、timerBaseValue に代入する
                if let convertedTime = convertTime(timeString: timerString) {
                    timerBaseValue = convertedTime
                }
            }
        }
    }
    
    // テキストノードを作成するメソッド
    func createTextNode(text: String) -> SCNNode {
        let textGeometry = SCNText(string: text, extrusionDepth: 1.5)
        textGeometry.firstMaterial?.diffuse.contents = UIColor.red
        let textNode = SCNNode(geometry: textGeometry)
        textNode.scale = SCNVector3(0.01, 0.01, 0.01) // 適切なスケールを設定してください
        textNode.position = SCNVector3(-0.2, -0.2, -0.5) // 適切な位置を設定してください
        return textNode
    }
    
    // 経過時間をフォーマットするメソッド
    private func formatTime(seconds: TimeInterval) -> String {
        let minutes = Int(seconds) / 60
        let remainingSeconds = Int(seconds) % 60
        let milliseconds = Int((seconds.truncatingRemainder(dividingBy: 1)) * 100)
        return String(format: "%02d:%02d.%02d", minutes, remainingSeconds, milliseconds)
    }
    
    // 経過時間を TimeInterval 型に型変換するメソッド
    private func convertTime(timeString: String) -> TimeInterval? {
        let timeComponents = timeString.components(separatedBy: ":")
        // timeString の形式が想定通り、"xx:yy" の場合にのみ処理を実行する
        if timeComponents.count == 2,
           let minutes = Double(timeComponents[0]),
           let secondsAndMillis = Double(timeComponents[1]) {
            let totalSeconds = (minutes * 60) + secondsAndMillis
            return TimeInterval(totalSeconds)
        }
        return nil
    }
    
    // レイアウト制約の設定
    // Resetボタンのレイアウト制約の設定
    func setupResetButtonConstraints() {
        resetButton.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            resetButton.centerXAnchor.constraint(equalTo: view.centerXAnchor, constant: -180), // 画面の中央線から-180の位置に配置
            resetButton.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -50), // 下から-16の位置に配置
            resetButton.widthAnchor.constraint(equalToConstant: 100),
            resetButton.heightAnchor.constraint(equalToConstant: 40)
        ])
    }
    
    // Startボタンのレイアウト制約の設定
    func setupConstraints() {
        startStopButton.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            startStopButton.centerXAnchor.constraint(equalTo: view.centerXAnchor, constant: 0), // 画面の中央に配置
            startStopButton.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -50), // 下から-16の位置に配置
            startStopButton.widthAnchor.constraint(equalToConstant: 100),
            startStopButton.heightAnchor.constraint(equalToConstant: 40)
        ])
    }
    
    // Adjustボタンのレイアウト制約の設定
    func setupAdjustButtonConstraints() {
        adjustButton.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            adjustButton.centerXAnchor.constraint(equalTo: view.centerXAnchor, constant: 180), // 画面の中央線から+180の位置に配置
            adjustButton.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -50), // 下から-16の位置に配置
            adjustButton.widthAnchor.constraint(equalToConstant: 100),
            adjustButton.heightAnchor.constraint(equalToConstant: 40)
        ])
    }
}
```
##
### 4. 動作確認
動作確認に使用する端末と Mac を接続した後、画像右上部の箇所を選択し、端末を選択してください。

動作確認に使用する端末が選択されている状態で、画面左上部の ▶️ ボタンを押下することで、アプリケーションのビルドが完了して動作確認ができます。

(端末が表示されない、ビルドするとエラーが出る、初めてでよくわからない等あれば検索してみてください)

![スクリーンショット 2023-12-26 1.00.42.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3525158/2a066a6f-c436-41ff-47ce-8fafeab043c9.png)

##
### 正常に動作確認できたら成功！
カメラを動かしたり、ボタンを押したりして動作を確認してみてください。
![AR_Timer.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3525158/3266d2ee-119e-b03f-d1bd-1e3961f87fcd.gif)

##
### 5. コード解説
**※随時更新します。解説が必要な箇所があればコメントしてください。**

##
### 6. おわりに
不明点等あればお気軽に質問ください

:::note info
[Github でコードを確認する場合はこちらをクリック](https://github.com/Tomoshi3104/SceneKit_digital_timer.git)
:::