【iOS】CMMotionManagerを使ってViewを動かして遊ぶ

CMMotionManagerの勉強がてら、端末を傾けてViewを動かす実装をやってみたので書き留めました。
2019.12.31

大阪オフィスの山田です。CMMotionManagerの勉強がてら、端末を傾けてViewを動かす実装をやってみたので書き留めておきます。

開発環境

  • Xcode 11.2.1
  • macOS 10.14.6

作るアプリ

端末を傾けるとその方向にViewが動いて、他のViewに重ねて遊ぶ、のような簡単なお遊びアプリを作ります。

Core Motionについて

Core Motionフレームワークを使うことで、ハードウェアが生成したデータをアプリ内で使用することができます。このフレームワークの多くのサービスでは、ハードウェアから提供される生データと処理されたデータの両方を取得することができます。処理されたデータは、影響のあるバイアスが取り除かれています。例えば、処理された加速度の値は重力による加速度を分離して、純粋にユーザーが発生させた加速度を取得することができます。 device-motion serviceは関連するハードウェアを使用してCMDeviceMotionオブジェクトを生成します。 device-motion serviceでは不要なバイアスを取り除いたデータを使うことができます。 CMDeviceMotionオブジェクトは以下の情報を含んでいます。

  • reference frameを基準とした3次元空間における端末の方向(もしくは角度)
  • バイアスのない回転率
  • 重力加速度
  • ユーザーが発生させた、重力加速度を除いた加速度ベクター
  • 磁場ベクトル

CMMotionManagerを使ってデバイスの傾きを取得する

今回はCMMotionManagerを使って、処理されたデータを使ってデバイスの傾きを取得しようと思います。 deviceMotionUpdateIntervalプロパティにデータの更新頻度を指定することができます。データの更新頻度の最大値はハードウェアによりますが、通常は最低100Hzは指定できます。もし、ハードウェアがサポートしている最大値より大きい値を指定した場合は、サポートしている最大値が代わりに使用されます。 データの取得方法については2種類あります。1つ目はデータが必要なタイミングでプロパティを参照して値を取得する方法です。2つ目はhandlerを渡して、指定した一定間隔で値を取得する方法です。今回は2の方法を採用します。以下にソースコードを示します。

import CoreMotion

let motionManager = CMMotionManager()

override func viewDidLoad() {
    super.viewDidLoad()
    guard motionManager.isDeviceMotionAvailable else { return }
    motionManager.deviceMotionUpdateInterval = 1 / 100

    motionManager.startDeviceMotionUpdates(to: OperationQueue.current!, withHandler: { (motion, error) in
        guard let motion = motion, error == nil else { return }

        print("attitude pitch: \(motion.attitude.pitch * 180 / Double.pi)")
        print("attitude roll : \(motion.attitude.roll * 180 / Double.pi)")
        print("attitude yaw  : \(motion.attitude.yaw * 180 / Double.pi)")

    })
}

isDeviceMotionAvailableプロパティでdevice-motion serviceが使用可能かチェックしています。deviceMotionUpdateIntervalには100Hzを指定しています(1秒間に100回)。startDeviceMotionUpdatesメソッドにて、データの取得を開始します。データを取得したらwithHnadlerに指定したhandlerが実行されます。CMMotionData(ソースコード内のmotion)のattitudeでデバイスの角度を取得します。pitch, roll, yawはそれぞれ以下の図で示す方向を表しています。また、この時取得できる値はラジアンですので、これを角度に変換してコンソールに表示しています。角度はフラットな面に端末を置いた角度が基準になります。yawはそのままだと、アプリ起動時の端末の角度が0となります。startDeviceMotionUpdatesメソッドにのusing引数に、CMAttitudeReferenceFrameが指定でき、xMagneticNorthZVertical, xTrueNorthZVerticalを指定すると端末の右方向を北にした基準を元に角度が計算されます。詳細については公式ブログ: Understanding Reference Frames and Device Attitudeを確認してください。

ラジアンと角度の変換について

いつも忘れてググってしまうので変換方法の覚え方をまとめておきます。ラジアンは半径と弧の長さが等しい時の角度です(孤度とも言います)。この定義から、1radの角度を求めるとすると、直径x円周率は360度であることと、円周の長さと角度は比例することから

\[ 2πr = 360 \\ r = 360/2π \\ r = 180/π \]

rは半径で弧の長さと等しいため、弧がrの長さの時の角度は180/πとなります。度数法に換算すると180 / 3.1415926535 = 57.2957795147となり、約57度が1radに相当します。iOSで使われるのはラジアンなので度数法に直すには、180/πを乗算すると良いということになります。

radian * 180 / Double.pi

Viewを動かす実装

実際にViewを動かして遊ぶゲームの実装全体は以下のようになります。

import UIKit
import CoreMotion

class ViewController: UIViewController {

    private let motionManager = CMMotionManager()
    private weak var circleView: UIView?
    private weak var targetView: UIView?
    private var goalCount = 1

    override func viewDidLoad() {
        super.viewDidLoad()
        addTargetView()
        addCircleView()

        guard motionManager.isDeviceMotionAvailable else { return }
        motionManager.deviceMotionUpdateInterval = 1 / 100

        motionManager.startDeviceMotionUpdates(using: .xMagneticNorthZVertical, to: OperationQueue.current!, withHandler: { [weak self] (motion, error) in
            guard let motion = motion, error == nil else { return }
            guard let strongSelf = self else { return }

            let xAngle = motion.attitude.roll * 180 / Double.pi
            let yAngle = motion.attitude.pitch * 180 / Double.pi

            /// 係数を使って感度を調整する
            let coefficient: CGFloat = 0.1

            print("attitude pitch: \(motion.attitude.pitch * 180 / Double.pi)")
            print("attitude roll : \(motion.attitude.roll * 180 / Double.pi)")
            print("attitude yaw  : \(motion.attitude.yaw * 180 / Double.pi)")

            strongSelf.circleView?.addX(CGFloat(xAngle) * coefficient)
            strongSelf.circleView?.addY(CGFloat(yAngle) * coefficient)

            strongSelf.judgeGoal()
        })
    }
}

private extension ViewController {
    /// 動かす青いViewを追加する
    func addCircleView() {
        let size: CGFloat = 32.0
        let circleView = UIView()
        circleView.frame = CGRect(x:0, y:0 ,width: size, height:size)
        circleView.backgroundColor = UIColor.blue
        circleView.layer.cornerRadius = size / 2
        self.view.addSubview(circleView)
        self.circleView = circleView
    }

    /// 目標となるグレーのViewを追加する
    func addTargetView() {
        let size: CGFloat = 48.0
        let targetView = UIView()
        targetView.frame = CGRect(x:0, y:0 ,width: size, height:size)
        targetView.backgroundColor = UIColor.gray
        targetView.layer.cornerRadius = size / 2
        var frame = targetView.frame
        frame.origin.x = CGFloat.random(in: view.frame.minX..<view.frame.maxX - frame.width)
        frame.origin.y = CGFloat.random(in: view.frame.minY..<view.frame.maxY - frame.height)
        targetView.frame = frame
        view.addSubview(targetView)
        self.targetView = targetView
        view.sendSubviewToBack(targetView)
    }

    /// 目標のViewに含まれているか判定する
    /// 5回繰り返したら終了ということで、DeviceMotionUpdateをstopする
    func judgeGoal() {
        if let targetView = targetView, let circleView = circleView,
            targetView.frame.contains(circleView.frame) {
            goalCount += 1
            if goalCount <= 5 {
                targetView.removeFromSuperview()
                addTargetView()
            } else {
                motionManager.stopDeviceMotionUpdates()
            }
        }
    }
}

extension UIView {
    /// X方向にViewを動かす
    func addX(_ x: CGFloat) {
        var frame:CGRect = self.frame
        frame.origin.x += x
        if let superViewFrame = superview?.frame {
            if superViewFrame.minX > frame.origin.x {
                frame.origin.x = superViewFrame.minX
            } else if superViewFrame.maxX - frame.width < frame.origin.x {
                frame.origin.x = superViewFrame.maxX - frame.width
            }
        }
        self.frame = frame
    }

    /// Y方向にViewを動かす
    func addY(_ y: CGFloat) {
        var frame:CGRect = self.frame
        frame.origin.y += y
        if let superViewFrame = superview?.frame {
            if superViewFrame.minY > frame.origin.y {
                frame.origin.y = superViewFrame.minY
            } else if superViewFrame.maxY - frame.height < frame.origin.y {
                frame.origin.y = superViewFrame.maxY - frame.height
            }
        }
        self.frame = frame
    }
}

おわり

  • 冬休み前: 勉強いっぱいするぞー
  • 現実: 虚無