1
9

More than 1 year has passed since last update.

Swift Playgrounds「地震計」Appプロジェクトを解説する

Last updated at Posted at 2022-01-07

この投稿は何?

iPadとMac向けアプリの「Swift Playgrounds4」に用意されている「地震計」Appプロジェクトを学ぶための解説です。

「地震計」Appプロジェクトの概要

このプロジェクトは、iPad内蔵のモーションセンサーを使って振動を検知する「Seismometer」アプリを作成します。
検出した振動データは、2つの形式(針と折れ線グラフ)で可視化します。

プレゼンテーション 4.002.jpeg

リソースファイル

このAppプロジェクトに含まれているリソースファイルを、名前順に挙げます。

  • DoubleExtension
  • GaugeBackground
  • GraphSeismometer
  • LineGraph
  • MotionDetecter
  • NeedleSeismometer
  • SeismometerApp
  • SeismometerBrowser

MotionDetecter.swift

iPhoneおよびiPadには、「デバイスの動作を検知する加速度センサー」や「物理的な向きを検知するジャイロセンサー」などのハードウェア機能が備わっています。
CoreMotionフレームワークを使って、これらのセンサーが取得したデータにアクセスすることができます。
このMotionDetecterクラスは、「モーション検出器」としての機能を提供します。

MotionDetecter.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 orientationNotification = UIDevice.orientationDidChangeNotification

    init(updateInterval: TimeInterval) {
        self.updateInterval = updateInterval
    }
    
    func start() {
        UIDevice.current.beginGeneratingDeviceOrientationNotifications()

        orientationObserver = NotificationCenter.default.addObserver(forName: orientationNotification, 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("Device motion not available")
        }
    }
    
    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: orientationNotification, 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型は「センサーからのモーションデータ」を取得するため機能を提供するオブジェクトです。

モーション検出器はその役割を果たすために、タイマーを使って周期的に「ピッチ、ロール、z軸加速度」のデータを計測します。周期間隔は、モーション検出器インスタンスの作成時に初期化されるupdateIntervalプロパティよって決定します。計測の間隔を短くするほど、よりリアルタイムにデータを表示できます。

ピッチはデバイスの上下方向の傾き、ロールは左右方向の傾きです。z軸加速度は垂直方向の加速度です。
これらのプロパティは@Published属性がマークされた公開値です。したがって、その値が変化すると、「依存するSwiftUIビュー」は表示が自動的に更新されます。
SwiftUIビューを公開値に依存させるには、コードでそのプロパティを参照します。このプロジェクトではNeedleSeismometerビューとGraphSeisimometerビューが、この公開値に依存しています。

onUpdateプロパティは、モーションデータを計測するたびに実行したい手続きの定義です。具体的な手続きは、MotionDetecter型インスタンスの初期化時に指定されます。

start()メソッドは、モーションデータの計測を開始するための手続きの定義です。
状況によっては、センサーデータを利用できない可能性があります。そのため、デバイスのモーションセンサーを利用する前は必ず、isDeviceMotionAvailableプロパティの真偽をチェックします。モーションセンサーが利用できる場合は、モーションマネージャのstartDeviceMotionUpdates()メソッドで計測を開始します。

タイマーを作成するには、scheduledTimer(withTimeInterval:repeats:block:)メソッドを呼び出します。この時、タイマーの「周期間隔、繰り返しの有無、実行したい手続きのブロック」を指定します。

updateMotionData()メソッドは、タイマーによって周期的に呼び出されます。センサーが計測した最新のデータを使って、公開値を更新します。
センサーデータはモーションマネージャのdeviceMotionプロパティに格納されていますが、常に値が存在している保証はありません。ここでは、if-let構文を使って安全にデータを取得しています。
デバイスモーションのデータはCMDeviceMotion型インスタンスです。そのattitudeプロパティに「ピッチ、ロール、ヨー」が、accelerationプロパティに加速度が格納されています。なお、垂直加速度の値は重力を基準とするため、上方向に加速している場合は負、下方向に加速している場合は正、静止している場合はゼロです。

stop()メソッドは、モーションデータの計測を停止する手続きの定義です。
モーションマネージャのstopDeviceMotionUpdates()メソッドを呼び出して、データ計測を停止します。そして、タイマーを無効化します。
ここでは、通知センターに対して「デバイスの物理的な方向の変化」を検出するオブザーバ登録の解除も指示しています。
このクラスのデイニシャライザでstop()メソッドを呼び出すことで、確実に「モーションデータの計測」を停止しています。

NeedleSeismometer.swift

デバイスの振動に合わせて揺れる「針のメーター」を表示するSwiftUIビューです。
振動に合わせて針を左右に振るために、モーション検出器の垂直加速度(つまり、MotionDetecter型のzAccelerationプロパティ)の値を利用します。

NeedleSeismometer.swift
import SwiftUI

struct NeedleSeismometer: View {
    @EnvironmentObject var motionDetector: MotionDetector

    let needleAnchor = UnitPoint(x: 0.5, y: 1)
    let amplification = 2.0
    var rotationAngle: Angle {
        Angle(radians: -motionDetector.zAcceleration * amplification)
    }

    var body: some View {
        VStack {
            Spacer()
            
            ZStack(alignment: .bottom) {
                GaugeBackground(width: 250)
                Rectangle()
                    .foregroundColor(Color.accentColor)
                    .frame(width: 5, height: 190)
                    .rotationEffect(rotationAngle, anchor: needleAnchor)
                    .overlay {
                        VStack {
                            Spacer()
                            Circle()
                                .stroke(lineWidth: 3)
                                .fill()
                                .frame(width: 10, height: 10)
                                .foregroundColor(Color.accentColor)
                                .background(Color.white)
                                .offset(x: 0, y: 5)
                        }
                    }
            }

            Spacer()
            
            Text("\(motionDetector.zAcceleration.describeAsFixedLengthString())")
                .font(.system(.body, design: .monospaced))
                .fontWeight(.bold)

            Spacer()
            
            Text("Set your device on a flat surface to record vibrations using its motion sensors.")
                .padding()

            Spacer()
        }
    }
}

struct NeedleSeismometer_Previews: PreviewProvider {
    @StateObject static private var detector = MotionDetector(updateInterval: 0.01).started()
    
    static var previews: some View {
        NeedleSeismometer()
            .environmentObject(detector)
    }
}

motionDetecterプロパティは、「モーションの変化を検出」してコードで扱える形式で提供する検出器のインスタンスです。
アプリケーション環境に置かれているモデルデータにアクセスするため、このプロパティは宣言時にEnvironmentObject属性をマークしています。

このビューは、VStackコンテナに「左右に触れる針、センサーデータのRaw値(テキスト)、ユーザーへの指示文」を垂直に並べて構築します。
「左右に触れる針」はZStackビューを使って、メーター図形(メーターゲージとなる背景)に重ねて構築しています。
針自体は「細長い矩形のビュー」で、アプリのアクセントカラーで色付けされます。

針の振れ具合は、.rotationEffect(_:anchor:)モディファイアを使って実現します。.rotationEffect(_:anchor:)モディファイアのパラメータは「回転角、回転軸の位置」の2つです。
needleAnchorプロパティは、「針を示す矩形の回転位置」を調節するUnitPoint値です。UnitPoint型インスタンスは「xとyの平面座標」の値ですが、通常のPoint型と異なり「0~1」の範囲に収まります。したがって、UnitPoint値はサイズに関わらず、ビュー内の位置を定義する際に有用です。
amplificationプロパティは「検出した加速度」の表示感度を制御するための増幅値です。

「針の回転軸」を示す位置には、.overlayモディファイアを使って「小さい白円」を描画します。オーバーレイでは「親ビューの領域」と同じ範囲内でコンテンツを重ねることができます。

GraphSeismometer.swift

「センサーが検出したデバイスの振動」を折れ線グラフで描画するビューを定義します。
グラフのY軸は「センサーが検出した垂直方向の加速度」で、X軸は「経過時間」です。

GraphSeismometer.swift
import SwiftUI

struct GraphSeismometer: View {
    @EnvironmentObject private var detector: MotionDetector
    @State private var data = [Double]()
    let maxData = 1000

    @State private var sensitivity = 0.0
    let graphMaxValueMostSensitive = 0.01
    let graphMaxValueLeastSensitive = 1.0

    var graphMaxValue: Double {
        graphMaxValueMostSensitive + (1 - sensitivity) * (graphMaxValueLeastSensitive - graphMaxValueMostSensitive)
    }

    var graphMinValue: Double {
        -graphMaxValue
    }

    var body: some View {
        VStack {
            Spacer()
            LineGraph(data: data, maxData: maxData, minValue: graphMinValue, maxValue: graphMaxValue)
                .clipped()
                .background(Color.accentColor.opacity(0.1))
                .cornerRadius(20)
                .padding()
                .aspectRatio(1, contentMode: .fit)
            
            Spacer()
            
            Text("Sensitivity")
                .font(.headline)
            
            Slider(value: $sensitivity, in: 0...1, minimumValueLabel: Text("Min"), maximumValueLabel: Text("Max")) {
                Text("Sensitivity")
            }
            .padding()
            
            Spacer()
            
            Text("Set your device on a flat surface to record vibrations using its motion sensors.")
                .padding()
            
            Spacer()
        }
        .onAppear {
            detector.onUpdate = {
                data.append(-detector.zAcceleration)
                if data.count > maxData {
                    data = Array(data.dropFirst())
                }
            }
        }
    }
}

struct GraphSeismometer_Previews: PreviewProvider {
    @StateObject static private var detector = MotionDetector(updateInterval: 0.01).started()

    static var previews: some View {
        GraphSeismometer()
            .environmentObject(detector)
    }
}

detected プロパティはモーション検出器のインスタンスです。モーション検出器のインスタンスはアプリ環境内に作成されているので、宣言時にEnvironmentObject属性をマークすることで「一貫性のあるデータモデル」としてアクセスできます。

dataプロパティはDouble型の配列で、地震計グラフの折れ線を描画する値になります。配列の値が変化するたびにグラフの描画を更新できるようにするため、State属性をマークします。
maxDataプロパティは、地震計グラフが表示するデータの最大個数です。折れ線を描画するために十分なデータ数に到達したら、古いデータから削除する必要があります。
sensitivityプロパティの値は、振動に対する折れ線グラフの感度です。感度を大きくすると、折れ線の山と谷も大きくなります。

graphMaxValueMostSensitivegraphMaxValueLeastSensitiveは、グラフ縦軸の範囲を示すプロパティです。sensitivityプロパティの値と協調して、グラフの表示に制御します。graphMaxValueMostSensitive値は常に、graphMaxValueLeastSensitive値より小さくなるように設定します。
graphMaxValueプロパティは「折れ線の上端」を算出する計算プロパティです。折れ線の下端はgraphMaxValue値の逆数です。

このSwiftUIビューの主役は「折れ線のパス」です。折れ線のパスはLineGraphビューとして別のファイルに定義されています。
LineGraphビューのイニシャライザは4つのパラメータ(振動データ、データの最大個数、最小値、最大値)を受け取ります。この折れ線グラフでは、最小と最大の範囲を超える値は表示できません。

このビューにおけるスライダーは「グラフの感度」を制御するためのUIです。
Sliderビューのイニシャライザが受け取る最初のパラメータは「State属性sensitivityプロパティのバインディング」です。これによって、ユーザーがスライダーを操作すると、sensitivityプロパティの値が自動的に更新されます。
残り3つのうち、in: 0...1はスライダーが調整できる値の範囲です。minimumValueLabel:maximumValueLabel:は最小値と最大値に表示されるラベルです。

onAppear()モディファイアでは、このSwiftUIビューが画面に表示された時の手続きを指定します。
この時に、モーション検出器のonUpdateプロパティに「周期的に実行したいコードのブロック」を設定しています。
具体的には、振動データの配列に検出した加速度(上方法を正、下方向を負にしたいので-1を乗算)を追加し、古くなった部分は削除する

SeismometerBrowser.swift

このファイルは、振動データを可視化する2つの方法(折れ線グラフ、針メーター)をリスト形式で選択できるSwiftUIビュー定義です。
つまり、「振動データを可視化する2つのビュー」のコンテナの役割を果たすナビゲーションの起点です。
モーション検出器のインスタンスも、このビューによって管理されます。

SeisimometerBrowser.swift
import SwiftUI

struct SeismometerBrowser: View {
    @StateObject private var detector = MotionDetector(updateInterval: 0.01)

    var body: some View {
        NavigationView {
            List {
                NavigationLink(destination: NeedleSeismometer()) {
                    HStack() {
                        Image(systemName: "gauge")
                            .foregroundColor(Color.accentColor)
                            .padding()
                            .font(.title2)
                        
                        VStack(alignment: .leading, spacing: 8) {
                            Text("Needle")
                                .font(.headline)
                            Text("A needle that responds to the device’s vibration.")
                                .font(.caption)
                        }
                        .padding(.trailing)
                    }
                }.padding([.top, .bottom])

                NavigationLink(destination: GraphSeismometer()) {
                    HStack() {
                        Image(systemName: "waveform.path.ecg.rectangle")
                            .foregroundColor(Color.accentColor)
                            .padding()
                            .font(.title2)
                        
                        VStack(alignment: .leading, spacing: 8) {
                            Text("Graph")
                                .font(.headline)
                            Text("Watch the device’s vibrations charted on a graph. Adjust the sensitivity using a slider.")
                                .font(.caption)
                        }
                        .padding(.trailing)
                    }
                }.padding([.top, .bottom])
            }
            .listStyle(.plain)
            .navigationTitle(Text("Seismometer"))
        }
        .navigationViewStyle(.stack)
        .environmentObject(detector)
        .onAppear() {
            detector.start()
        }
        .onDisappear {
            detector.stop()
        }
    }
}

struct SeismometerBrowser_Previews: PreviewProvider {
    static var previews: some View {
        SeismometerBrowser()
    }
}

モーション検出器のインスタンスを示すdetecterプロパティは、アプリ全体で一貫したデータアクセスを行うためにStateObject属性をマークして宣言しています。そして、.environmentObject()モディファイアにこのインスタンスを指定して、アプリの環境オブジェクトにします。こうすることで、すべての子ビューからdetecterプロパティにアクセスできますが、オーナーはこのSeismometerBrowserビューです。

ナビゲーションコンテナの.onAppear()モディファイアで「モーション検出器に計測開始の指示」を、onDisappear()モディファイア
で「計測停止の指示」を実行します。「グラフやメーター画面」もナビゲーションコンテナ内に表示されるので、モーション検出器は正しく動作します。

DoubleExtension.swift

このファイルは、Double型に独自機能を追加するエクステンションの定義です。

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)
                )
        )
    }
}

describeAsFixedLengthString(_:_:)メソッドは、Double値を固定桁数の文字列に変換する機能を提供します。固定する桁数を特に指定しない場合は、「整数部分が2桁と少数点数部分が2桁」の文字列に変換します。

一般的に、アプリケーション画面に表示される数値データは文字列ですが、さまざまな数値に対して「それを、どのような書式の文字列にするか」を指定する必要があります。そのために使用するformatted()メソッドは、数値型データに用意されている標準機能です。
formatted()メソッドには「書式のスタイル」を指定しますが、ここでは.numberで「単位のない純粋な数」としています。

.number書式にはモディファイアを指定できます。
.signモディファイアは「符号の有無」を設定します。
integerAndFractionLength(_:_:)モディファイアは「数値の桁数」を設定します。

GaugeBackground.swift

「左右に振れる針」で振動データを可視化する画面の背景となるメーターのビューです。

import SwiftUI

extension Angle: Identifiable {
    public var id: Double {
        radians
    }
}

struct GaugeBackground: View {
    let width: Double
    let minAngle = Angle(degrees: -90)
    let maxAngle = Angle(degrees: 90)
    let tickCount = 17
    
    var tickLength: Double {
        width * 0.05
    }
    
    var gaugeTickAngles: [Angle] {
        let tickDegrees = (maxAngle.degrees - minAngle.degrees) / (Double(tickCount) - 1)
        var angles = [Angle]()
        
        for tick in 1..<tickCount - 1 {
            angles.append(Angle(degrees: 90 - (Double(tick) * tickDegrees)))
        }
        
        return angles
    }
    
    var body: some View {
        ZStack {
            Path { path in
                path.addArc(center: CGPoint(x: width / 2, y: width / 2),
                            radius: width / 2,
                            startAngle: Angle(degrees: minAngle.degrees + 90),
                            endAngle: Angle(degrees: maxAngle.degrees + 90),
                            clockwise: true)
            }
            .fill()
            .foregroundColor(Color.accentColor.opacity(0.15))
            
            ForEach(gaugeTickAngles) { angle in
                Rectangle()
                    .frame(width: 1, height: tickLength)
                    .offset(y: -width / 2 + tickLength)
                    .rotationEffect(angle, anchor: UnitPoint(x: 0.5, y: 1))
                    .offset(y: width / 4 - tickLength / 2)
            }
        }
        .frame(width: width, height: width / 2)
    }
}

struct GaugeBackground_Previews: PreviewProvider {
    static var previews: some View {
        GaugeBackground(width: 300)
    }
}

GaugeBackground構造体

このビューは初期化時に「横幅を示すDouble値」を受け取ります。

addArc(center:radius:startAngle:endAngle:clockwise:)メソッドは、「指定された半径と角度」を使って「始点と終点」を計算します。そして、それらの点間で描いた円弧の曲線を現在のパスに追加します。clockwiseパラメータは、円弧の作成方向を決定します。

Angle型のエクステンション

角度を「識別可能な固有番号として扱う」ために、idプロパティを定義します。

LineGraph.swift

「振動データをグラフ表示する画面」で利用される「折れ線」部分を定義するSwiftUIビューです。

import SwiftUI

struct LineGraph: View {
    let data: [Double]
    let maxData: Int
    
    let minValue: Double
    let maxValue: Double
    
    let gridSpacing = 250

    @State private var timestep = 0

    func yGraphPosition(_ dataItem: Double, in size: CGSize) -> Double {
        let proportion = (dataItem - minValue) / (maxValue - minValue)
        let yValue: Double = size.height - proportion * size.height
        return yValue
    }

    func xGraphPosition(_ index: Int, in size: CGSize) -> Double {
        let increment = size.width / Double(maxData)
        let base = Double(maxData - data.count) * increment
        return base + Double(index) * increment
    }
    
    var body: some View {
        Canvas { context, size in
            var lines = Path()

            let increment = size.width / Double(maxData)

            let phase = -1 * timestep % gridSpacing
            var x = Double(phase)
            repeat {
                lines.move(to: CGPoint(x: x * increment, y: 0))
                lines.addLine(to: CGPoint(x: x * increment, y: size.height))
                x += Double(gridSpacing)
            } while x <= Double(maxData)
                        
            var y = size.height / 2
            repeat {
                lines.move(to: CGPoint(x: 0, y: y))
                lines.addLine(to: CGPoint(x: size.width, y: y))
                y += increment * Double(gridSpacing)
            } while y <= size.height
            
            y = size.height / 2
            repeat {
                lines.move(to: CGPoint(x: 0, y: y))
                lines.addLine(to: CGPoint(x: size.width, y: y))
                y -= increment * Double(gridSpacing)
            } while y >= 0

            context.stroke(lines, with: .color(.black.opacity(0.25)))

            guard !data.isEmpty else { return }

            var path = Path()

            path.move(to: CGPoint(x: self.xGraphPosition(0, in: size), y: yGraphPosition(data[0], in: size)))

            for (index, dataPoint) in data.dropFirst().enumerated() {
                path.addLine(to: CGPoint(x: self.xGraphPosition(index, in: size), y: self.yGraphPosition(dataPoint, in: size)))
            }

            context.stroke(path, with: .color(.accentColor))
        }
        .onChange(of: data) { _ in
            timestep += 1
        }
    }
}

初期化時に4つのパラメータ(振動データの配列、データの最大個数、最大値、最小値)を受け取ります。
@State属性でマークされたtimestepプロパティが更新されるたびに、UI表示は自動的に更新されます。

Canvasコンテナについて、詳しい情報はAppleの開発者向けドキュメントを確認してください。

yGraphPositionメソッド
xGraphPositionメソッド

SeismometerApp.swift

このファイルは、アプリ自体を示す構造体の定義です。

import SwiftUI

@main
struct SeismometerApp: App {
    var body: some Scene {
        WindowGroup {
            SeismometerBrowser()
        }
    }
}

@main属性はアプリ起動時に、プログラムが最初に実行時されるエントリーポイントを示しています。

1
9
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
9