この投稿はなに?
iPadとMac向けアプリの「Swift Playgrounds4」に用意されている「水準器」Appプロジェクトを学ぶための解説です。
「水準器」Appプロジェクトの概要
このプロジェクトで開発しているのは、水平を計測するために「iPadのモーションセンサーを使って取得したデータ」をグラフィカルに表示する「BubbleLevel」アプリです。現実世界における気泡水平器のような役割を果たします。
Core Motionフレームワーク
iPhoneやiPadなどのデバイスには「加速度を検知するモーションセンサー」および「デバイスが向いている方向を検知するジャイロセンサー」が搭載されています。これらのハードウェア機能にアクセスするためのAPIのパッケージがCore Motionフレームワークです。加速度は「前後、左右、上下」の3方向に分解して計測されます。デバイスの傾きは「ピッチ、ロール、ヨー」の3軸を基準に計測されます。
デバイス画面を天井に向けた状態を基準として、前後の傾斜をピッチ、左右の傾斜をロール、中心点を軸にした回転をヨーと言います。
以降、この投稿内では加速度やデバイス方向を区別せずにモーションと呼びます。
Core Motionフレームワークについて、詳しくはApple Developer Documentaionを確認してください。
リソースのファイル
このプロジェクトには6つのSwiftコードファイルが含まれています。
以下、アルファベット順に挙げます。
- BubbleLevel
- BubbleLevelApp
- DoubleExtension
- LevelView
- MotionDetector
- OrientationDataView
ファイルごとの役割を「アプリの外観」と「モーションデータの取得・可視化」に分けることが出来ます。
MotionDetector.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
このアプリ自体を定義したファイルです。
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
これは、大きな円形の中を移動する「小さな球体」を描画するビューを定義したファイルです。
デバイスが水平な時、気泡円を示す「小さな球体」は中心に位置します。
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本のラインを十字に引いています。
さらに、水準器外枠の上下左右にも、それぞれラインを引いています。
DoubleExtention.swift
これは、Double
型の定義を拡張するエクステンションです。
describeAsFixedLengthString()
メソッドは、数値を「指定した固定桁数」の文字列にして返します。パラメータを指定しない場合、デフォルトで「整数部分が2桁、少数点数部分が2桁」の数字にします。
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」となるビューです。
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
モディファイアを使って実装しています。
OrientationDataView.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
モディファイアに注目してください。デフォルト設定のプロポーショナルなフォントでは数値次第でビューの横幅が変化してしまうので、等幅に設定を上書きしています。