Swift Playgrounds「水準器」Appプロジェクトを解説する

# この投稿はなに？
iPadとMac向けアプリの「Swift Playgrounds4」に用意されている「水準器」Appプロジェクトを学ぶための解説です。

# 「水準器」Appプロジェクトの概要
このプロジェクトで開発しているのは、水平を計測するために「iPadのモーションセンサーを使って取得したデータ」をグラフィカルに表示する「BubbleLevel」アプリです。現実世界における気泡水平器のような役割を果たします。

下の画像は、Appプロジェクトを開いた直後の画面です。
![IMG_0511.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70476/f26b2edd-c11f-405d-4ed8-f4b13f2696a9.jpeg)

# Core Motionフレームワーク
iPhoneやiPadなどのデバイスには「加速度を検知するモーションセンサー」および「デバイスが向いている方向を検知するジャイロセンサー」が搭載されています。これらのハードウェア機能にアクセスするためのAPIのパッケージがCore Motionフレームワークです。加速度は「前後、左右、上下」の3方向に分解して計測されます。デバイスの傾きは「ピッチ、ロール、ヨー」の3軸を基準に計測されます。

デバイス画面を天井に向けた状態を基準として、前後の傾斜をピッチ、左右の傾斜をロール、中心点を軸にした回転をヨーと言います。
以降、この投稿内では加速度やデバイス方向を区別せずにモーションと呼びます。



Core Motionフレームワークについて、詳しくは[Apple Developer Documentaion](https://developer.apple.com/documentation/coremotion)を確認してください。

# リソースのファイル
このプロジェクトには6つのSwiftコードファイルが含まれています。
![IMG_0512.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70476/2b9688bd-0167-7770-645b-72bc893e7a53.jpeg)

以下、アルファベット順に挙げます。

* BubbleLevel
* BubbleLevelApp
* DoubleExtension
* LevelView
* MotionDetector
* OrientationDataView

ファイルごとの役割を「アプリの外観」と「モーションデータの取得・可視化」に分けることが出来ます。

## MotionDetector.swift
これは、主に「モーションデータを取得する」ためのクラスを定義したファイルです。他に、モーションデータを正確に処理するために必要な補助的な機能も実装しています。

```swift:
import CoreMotion
import UIKit

class MotionDetector: ObservableObject {
    private let motionManager = CMMotionManager()

    private var timer = Timer()
    private var updateInterval: TimeInterval

    @Published var pitch: Double = 0
    @Published var roll: Double = 0
    @Published var zAcceleration: Double = 0

    var onUpdate: (() -> Void) = {}

    private var currentOrientation: UIDeviceOrientation = .landscapeLeft
    private var orientationObserver: NSObjectProtocol? = nil
    let notification = UIDevice.orientationDidChangeNotification

    init(updateInterval: TimeInterval) {
        self.updateInterval = updateInterval
    }
    
    func start() {
        UIDevice.current.beginGeneratingDeviceOrientationNotifications()
        orientationObserver = NotificationCenter.default.addObserver(forName: notification, object: nil, queue: .main) { [weak self] _ in
            switch UIDevice.current.orientation {
            case .faceUp, .faceDown, .unknown:
                break
            default:
                self?.currentOrientation = UIDevice.current.orientation
            }
        }
        
        if motionManager.isDeviceMotionAvailable {
            motionManager.startDeviceMotionUpdates()

            timer = Timer.scheduledTimer(withTimeInterval: updateInterval, repeats: true) { _ in
                self.updateMotionData()
            }
        } else {
            print("Motion data isn't available on this device.'")
        }
    }
    
    func updateMotionData() {
        if let data = motionManager.deviceMotion {
            (roll, pitch) = currentOrientation.adjustedRollAndPitch(data.attitude)
            zAcceleration = data.userAcceleration.z
 
           onUpdate()
        }
    }

    func stop() {
        motionManager.stopDeviceMotionUpdates()
        timer.invalidate()
        if let orientationObserver = orientationObserver {
            NotificationCenter.default.removeObserver(orientationObserver, name: notification, object: nil)
        }
        orientationObserver = nil
    }

    deinit {
        stop()
    }
}

extension MotionDetector {
    func started() -> MotionDetector {
        start()
        return self
    }
}

extension UIDeviceOrientation {
    func adjustedRollAndPitch(_ attitude: CMAttitude) -> (roll: Double, pitch: Double) {
        switch self {
        case .unknown, .faceUp, .faceDown:
            return (attitude.roll, -attitude.pitch)
        case .landscapeLeft:
            return (attitude.pitch, -attitude.roll)
        case .portrait:
            return (attitude.roll, attitude.pitch)
        case .portraitUpsideDown:
            return (-attitude.roll, -attitude.pitch)
        case .landscapeRight:
            return (-attitude.pitch, attitude.roll)
        @unknown default:
            return (attitude.roll, attitude.pitch)
        }
    }
}
```

### MotionDetecterクラス
`CMMotionManager`型のインスタンスは、モーションセンサーからのデータを取得するために使用します。

この`MotionDetecter`クラスは、ピッチとロールおよびz軸加速度を周期的に計測するために、タイマーを使用します。それが`Timer`型インスタンスのtimerプロパティです。
ピッチとロールおよびz軸加速度は、`@Published`属性でマークされた公開プロパティです。公開プロパティの値が変化すると、自動的にその値に依存するSwiftUIビューの描画が更新されます。

`onUpdate`プロパティは、モーションデータに変化が起こるたびに実行したい手続きを保持します。こうすることで、`MotionDetecter`クラスの再利用性が向上します。

`start()`メソッドでは、モーションセンサーが取得するデータの更新を開始します。
デバイスのモーションセンサーは状況によっては使用できない場合があります。そのため、モーションデータの更新を開始する直前では必ずモーションマネージャの`isDeviceMotionAvailable`プロパティが`true`であることをチェックしてください。モーションデータの更新を開始するには、モーションマネージャの`startDeviceMotionUpdate()`メソッドを呼び出します。

モーションデータの更新を開始した後、一定周期で計測するためにタイマーのインスタンスを作成します。タイマーによって実行される一連の手続きは`updateMotionData()`メソッドとして、クロージャ式で呼び出されます。
`updateMotionData()`メソッドでは、センサーから取得したデータで公開プロパティを更新します。したがって、この時点で公開プロパティに依存するSwiftUIビューの描画も更新されます。計測されたモーションデータは、モーションマネージャの`deviceMotion`プロパティの値です。ただし、状況によってその値が存在しない場合があるので、`if-let`構文で`nil`チェックをしておくのが賢明です。
ここでモーションデータが存在した場合、その`attitude`プロパティに各種ジャイロデータ（ピッチ、ロール、ヨー）が格納されています。また、`userAcceleration`プロパティには各種加速度データ（x方向、y方向、z方向）が格納されています。加速度の値は重力方向を基準とするため、上方向に加速した場合は負になり、下方向に加速した場合は正になります。なお、静止している場合は0です。
そして、最後に`onUpdate()`メソッドを呼び出します。`onUpdate`はプロパティですが、保持した手続きを実行するには関数を呼び出す場合と同様に括弧`()`を記述します。

`stop()`メソッドは、モーションデータの計測を完了するための手続きです。モーションマネージャの`stopDeviceMotionUpdates()`メソッドを呼び出して、データの更新を停止させた後、タイマーを無効にしています。この`stop()`メソッドをデイニシャライザで呼び出すことにより、`MotionDetecter`型のインスタンスがメモリ解放される直前に確実に、モーションデータの計測を停止させることが出来ます。

## BubbleLevelApp.swift
このアプリ自体を定義したファイルです。

```swift:
import SwiftUI

@main
struct BubbleLevelApp: App {
    @StateObject private var motionDetector = MotionDetector(updateInterval: 0.01)

    var body: some Scene {
        WindowGroup {
            LevelView()
                .environmentObject(motionDetector)
        }
    }
}
```

この構造体では、アプリのビュー階層全体にわたって`MotionDetecter`クラスのデータを一貫性を維持しながら扱えるようにするための手続きに注目してください。
まず、`@StateObject`属性をマークした変数として、`motionDetecter`プロパティを定義します。そして、この`motionDetecter`プロパティを「階層の最上位となる`LevelView()`ビューの環境オブジェクト」に指定します。

## BubbleLevel.swift
これは、大きな円形の中を移動する「小さな球体」を描画するビューを定義したファイルです。
デバイスが水平な時、気泡円を示す「小さな球体」は中心に位置します。

```swift:
import SwiftUI

struct BubbleLevel: View {
    @EnvironmentObject var detector: MotionDetector

    let range = Double.pi
    let levelSize: CGFloat = 300

    var bubbleXPosition: CGFloat {
        let zeroBasedRoll = detector.roll + range / 2
        let rollAsFraction = zeroBasedRoll / range
        return rollAsFraction * levelSize
    }

    var bubbleYPosition: CGFloat {
        let zeroBasedPitch = detector.pitch + range / 2
        let pitchAsFraction = zeroBasedPitch / range
        return pitchAsFraction * levelSize
    }

    var verticalLine: some View {
        Rectangle()
            .frame(width: 0.5, height: 40)
    }

    var horizontalLine: some View {
        Rectangle()
            .frame(width: 40, height: 0.5)
    }

    var body: some View {
        Circle()
            .foregroundStyle(Color.secondary.opacity(0.25))
            .frame(width: levelSize, height: levelSize)
            .overlay(
                ZStack {

                    Circle()
                        .foregroundColor(.accentColor)
                        .frame(width: 50, height: 50)
                        .position(x: bubbleXPosition,
                                  y: bubbleYPosition)
                    
                    Circle()
                        .stroke(lineWidth: 0.5)
                        .frame(width: 20, height: 20)
                    verticalLine
                    horizontalLine

                    verticalLine
                        .position(x: levelSize / 2, y: 0)
                    verticalLine
                        .position(x: levelSize / 2, y: levelSize)
                    horizontalLine
                        .position(x: 0, y: levelSize / 2)
                    horizontalLine
                        .position(x: levelSize, y: levelSize / 2)
                }
            )
    }
}

struct BubbleLevel_Previews: PreviewProvider {
    @StateObject static var motionDetector = MotionDetector(updateInterval: 0.01).started()

    static var previews: some View {
        BubbleLevel()
            .environmentObject(motionDetector)
    }
}
```

`detecter`プロパティはモーションセンサーが計測したデータを提供する`MotionDetecter`型のインスタンスです。
この`EnvironmentObject`属性がマークされた監視対象の`detecter`プロパティの値にアクセスすることで、モーションデータの変化に応じてビューのUIを自動的に更新することが出来ます。

`range`プロパティは、モーションディテクタが伝える計測値の範囲です。値が負になると左に、正になると右に傾いていることを示します。
`levelSize`プロパティは、水準器を示す「大きな円形」の幅と高さです。水準器は正方形に内接する円を想定します。
これらはコードの中で変更されないので、定数として定義しておきます。

`bubbleXPosition`プロパティは、気泡円の水平方向における位置を算出します。
まず、ロール値をゼロから「円の中央」に位置するように調整します。
次に、調整した値を「左に最大まで傾いたら0.0、水平なら0.5、右に最大まで傾いたら1.0」となるような割合を計算します。
最後に、割合に水準器のサイズを適用して、気泡円の位置を決定します。

同様に、`bubbleYPosition`プロパティは、気泡円の垂直方向における位置を算出します。
これらは計算プロパティとして実装することで、ビュー本体のコード量を削減しています。

`verticalLine`プロパティは、水準器を描画する際に使用できる「縦方向の中心線」を示す`Rectangle`ビューです。実際には極めて細長いので、短いラインのように見えます。同様に`horizontalLine`プロパティは、「横方向の中心線」のビューです。

ビューのボディには3つの`Circle`ビューがあります。
最初の`Circle`ビューが、グレーの大きな円です。残りの2つは、`.overlay`モディファイアによって重ねるように配置されます。

`.overlay`モディファイアでは、`ZStack`コンテナで残り2つの`Circle`ビューを配置しますが、これらの座標位置は「グレーの`Circle`ビュー」が基準になります。
水準器の中心を示す`Circle`ビューには、2本のラインを十字に引いています。
さらに、水準器外枠の上下左右にも、それぞれラインを引いています。

<img width="400" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70476/889de614-b76d-91c2-27c6-01970965973e.jpeg">

## DoubleExtention.swift
これは、`Double`型の定義を拡張するエクステンションです。
`describeAsFixedLengthString()`メソッドは、数値を「指定した固定桁数」の文字列にして返します。パラメータを指定しない場合、デフォルトで「整数部分が2桁、少数点数部分が2桁」の数字にします。

```swift:
import Foundation

extension Double {
    func describeAsFixedLengthString(integerDigits: Int = 2, fractionDigits: Int = 2) -> String {
        self.formatted(
            .number
                .sign(strategy: .always())
                .precision(
                    .integerAndFractionLength(integer: integerDigits, fraction: fractionDigits)
                )
        )
    }
}
```

`formatted()`メソッドは、`Date`型、`Int`型、`Double`型に定義されている標準機能です。アプリケーションで数値を表示するには、文字列に変換する必要があります。その書式を設定する際に、`formatted()`メソッドのパラメータでスタイルを指定します。
`.number`スタイルは数を純粋な数字として表示します。ここでは`.sign`モディファイアを使用して正の符号を常に表示し、`.precision`モディファイアで桁数を指定しています。

## LevelView.swift
水準器と計測データの値で構成された「アプリの主なUI」となるビューです。

```swift:
import SwiftUI

struct LevelView: View {
    @EnvironmentObject var motionDetector: MotionDetector

    var body: some View {
        VStack {
            BubbleLevel()
            OrientationDataView()
                .padding(.top, 80)
        }
        .onAppear {
            motionDetector.start()
        }
        .onDisappear {
            motionDetector.stop()
        }
    }
}

struct LevelView_Previews: PreviewProvider {
    @StateObject static var motionDetector = MotionDetector(updateInterval: 0.01).started()
    
    static var previews: some View {
        LevelView()
            .environmentObject(motionDetector)
    }
}
                                         
```

このビューでは、一貫性のあるデータソースである環境オブジェクトの`MotionDetecter`型インスタンスにアクセスするために、`@EnvironmentObject`属性の変数プロパティを宣言しています。

このビューが画面に表示されると同時に、モーションセンサーは計測を開始します。そして、画面が非表示になるタイミングで計測は停止します。これらの制御は、ビューの`.onAppear`モディファイアおよび`.onDisappear`モディファイアを使って実装しています。

<img width="400" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70476/4d0232bd-42ba-6378-85e5-2c0fd481d4d9.jpeg">


## OrientationDataView.swift
これは、モーションデータを文字列として画面に表示するビューを定義したファイルです。
センサーが検出するデータのうち、ロールは左右方向の傾きで、ピッチは前後方向の傾きです。

```swift:
import SwiftUI

struct OrientationDataView: View {
    @EnvironmentObject var detector: MotionDetector

    var rollString: String {
        detector.roll.describeAsFixedLengthString()
    }

    var pitchString: String {
        detector.pitch.describeAsFixedLengthString()
    }

    var body: some View {
        VStack {
            Text("Horizontal: " + rollString)
                .font(.system(.body, design: .monospaced))
            Text("Vertical: " + pitchString)
                .font(.system(.body, design: .monospaced))
        }
    }
}

struct OrientationDataView_Previews: PreviewProvider {
    @StateObject static private var motionDetector = MotionDetector(updateInterval: 0.01).started()
    
    static var previews: some View {
        OrientationDataView()
            .environmentObject(motionDetector)
    }
}
```

`detecter`プロパティは、デバイスのセンサーが計測したモーションデータを提供します。
`detecter`の`MotionDetecter`クラスは`ObservableObject`プロトコルに準拠した監視対象オブジェクトなので、その公開プロパティを使用するために`EnvironmentObject`属性をマークします。こうすることで、計測値が変化すると、このSwiftUIビューのテキスト表示が自動的に更新されます。

計測された傾きの数値データは、固定桁数にフォーマットされた`String`型の計算プロパティとして実装されています。フォーマットするために呼び出される`describeAsFixedLenghtString()`関数は、`Double`型のエクステンションとして`DoubleExtension.swift`に定義されています。

ビューのボディでは、`Text`ビューの`.font`モディファイアに注目してください。デフォルト設定のプロポーショナルなフォントでは数値次第でビューの横幅が変化してしまうので、等幅に設定を上書きしています。

<img width="400" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70476/2fb96782-b13e-50c7-c3b0-59b746b9e2a9.jpeg">