この投稿は何?
iPadとMac向けアプリの「Swift Playgrounds4」に用意されている「地震計」Appプロジェクトを学ぶための解説です。
「地震計」Appプロジェクトの概要
このプロジェクトは、iPad内蔵のモーションセンサーを使って振動を検知する「Seismometer」アプリを作成します。
検出した振動データは、2つの形式(針と折れ線グラフ)で可視化します。
リソースファイル
このAppプロジェクトに含まれているリソースファイルを、名前順に挙げます。
- DoubleExtension
- GaugeBackground
- GraphSeismometer
- LineGraph
- MotionDetecter
- NeedleSeismometer
- SeismometerApp
- SeismometerBrowser
MotionDetecter.swift
iPhoneおよびiPadには、「デバイスの動作を検知する加速度センサー」や「物理的な向きを検知するジャイロセンサー」などのハードウェア機能が備わっています。
CoreMotionフレームワークを使って、これらのセンサーが取得したデータにアクセスすることができます。
このMotionDetecter
クラスは、「モーション検出器」としての機能を提供します。
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
プロパティ)の値を利用します。
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軸は「経過時間」です。
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
プロパティの値は、振動に対する折れ線グラフの感度です。感度を大きくすると、折れ線の山と谷も大きくなります。
graphMaxValueMostSensitive
とgraphMaxValueLeastSensitive
は、グラフ縦軸の範囲を示すプロパティです。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つのビュー」のコンテナの役割を果たすナビゲーションの起点です。
モーション検出器のインスタンスも、このビューによって管理されます。
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
属性はアプリ起動時に、プログラムが最初に実行時されるエントリーポイントを示しています。