2017年2月18日土曜日

CMMotionManagerのサンプル(2)



AppStoreに載せているPanViewerは、パノラマ写真をiPhone/iPadを水平方向に回転させて見るアプリです。
このアプリはCMMotionManagerをからGyroデータ取得し、デバイスの回転角度に応じて画像を移動させています。

その方法について紹介ですが、今回は前回のサンプルの次の問題点に対処します。
・画像移動がギクシャクする。
・画像が画面からはみ出すとその部分が空白になる。

前者についてはanimationを利用して画像移動をスムーズにします。
→ func moveImageLayer 参照

後者については同じ画像をセットしたUIImageViewを3つ並べ、空白が生じないようにします。
→ func setImageViewSize 参照

また、移動後の位置が画面からはみ出す場合に位置調整を行います。
→ func newPosition 参照

今回のサンプルアプリのプロジェクトはここからダウンロードできます。

以下、前回との変更点だけ載せます。

    //画像を表示するUIImageView。画像が画面からはみ出した時に画像を繋げて表示するために3つ使う。
    @IBOutlet weak var imageViewL: UIImageView? //Left
    @IBOutlet weak var imageViewC: UIImageView? //Center
    @IBOutlet weak var imageViewR: UIImageView? //Right
  
    //3つのUIImageViewのsuperview。
    //画像移動はimageViewContainerのlayerを用いて行う。
    //位置判定が容易になるよう、frame.originは(0,0)とし、サイズはimageViewCと同じにする。
    @IBOutlet weak var imageViewContainer: UIView?
  
    //中央のUIImageViewの左右の端が画面に入ると画像外の部分が空白になる。
    //その空白を埋めるために左右にもUIImageViewを置き、同じ画像をセットする。
    func setImage(imageName: String) {
        if let image = UIImage(named: imageName) {
            if self.imageViewC!.image != image {
                self.imageViewL!.image = image; //左側
                self.imageViewC!.image = image; //中央
                self.imageViewR!.image = image; //右側
            }
        }
    }
    
    //3つのUIImageViewとimageViewContainerが画面にフィットするようにサイズ、位置を設定する。
    func setImageViewSize(viewSize: CGSize) {
        self.adjX = 0
        //originX: 3つのUIImageViewのorigin.xを設定するために用いる。
        var originX: CGFloat = 0;
        //3つのimageView及びimageViewContainerのサイズを設定する。
        for imageView in [self.imageViewL!, self.imageViewC!, self.imageViewR!] {
            if let image = imageView.image {
                //imageViewが画面にフィットするようにサイズを設定する。
                imageView.frame.size.width = image.size.width * viewSize.height / image.size.height
                imageView.frame.size.height = viewSize.height
                imageView.frame.origin.y = 0;
                //origin.xを-imageView.frame.size.width, 0, imageView.frame.size.widthの順にセット。
                imageView.frame.origin.x = originX - imageView.frame.size.width;
                originX += imageView.frame.size.width;
                //imageViewCに合わせてimageViewContainerのサイズを設定。
                if imageView == self.imageViewC {
                    self.imageViewContainer!.frame.size = self.imageViewC!.frame.size
                    self.imageViewContainer!.layer.position.x = 0
                }
            }
        }
    }
    
    //self.imageViewContainer.layerの位置を変えることで画像を水平方向に移動する。
    //画像移動をスムーズにするためanimationを使用する。
    func moveImageLayer() {
        let (from, to) = self.newPosition()
        let anim: CABasicAnimation = CABasicAnimation(keyPath: "position")
        anim.fromValue = from
        anim.toValue = to
        anim.duration = self.interval
        //animation終了後に初期位置に戻るので、layer.positionを移動後の位置にしておく。
        self.imageViewContainer!.layer.position = to
        self.imageViewContainer!.layer.add(anim, forKey:"move-layer")
    }
    
    //デバイスの回転から画像の表示位置を計算する。
    func newPosition() -> (CGPoint, CGPoint) {
        var from = self.imageViewContainer!.layer.position
        //deviceMotionのquaternionの値から回転角を求め、水平方向の移動量を計算する。
        if let deviceMotion = self.motionManager?.deviceMotion {
            let attitude: CMAttitude = deviceMotion.attitude
            let q: CMQuaternion = attitude.quaternion
            //angleはimageの視野角。360、180など。iPhoneで撮影したパノラマ写真なら180。
            let tx = (CGFloat)(atan2(q.y, q.x) / Double.pi * (360 / angle))
            let w = self.imageViewC!.frame.size.width
            var x = w * tx + adjX;
            //初期状態ではadjX=0。この場合、画像表示位置と計算上の位置が一致するようにadjXを設定する。
            if adjX == 0 {
                adjX = from.x - x
                x = from.x
            }
            let maxX = w / 2
            var posAdjusted = false
            //mageViewCの移動後、画面に欠ける部分ができる場合画面を覆う位置に調整する。
            //atan2の値はpiから-pi、またはその逆へ非連続に変化する。
            //視野角により画像のwidthより大きな変化(180度の場合はwidth*2前後)になるため、whileで適正位置に移動するまでループする。
            if x > maxX {
                //mageViewCが画面の右端から画像が消える位置に移動する場合。
                while(x > maxX) {
                    adjX -= w
                    x -= w
                }
                posAdjusted = true
            } else if x < -maxX {
                //mageViewCが画面の左端から画像が消える位置に移動する場合。
                while(x < -maxX) {
                    adjX += w
                    x += w
                }
                posAdjusted = true
            }
            if posAdjusted {
                //位置調整が行われた場合、layerの移動量がmaxX以上になったら調整後の位置に合わせてlayerのanimation開始位置も調整する。
                //少々いい加減な判断だが、よほど激しく回転させなけばチラつきは発生しない。
                let dx = x - self.imageViewContainer!.layer.position.x
                if dx > maxX {
                    from.x += w;
                } else if dx < -maxX {
                    from.x -= w;
                }
                //位置調整時はanimationを使用しない。
                self.imageViewContainer!.layer.position.x = from.x
            }
            return (from, CGPoint(x:x, y:from.y))
        }
        return (from, from)
    }




2017年2月16日木曜日

CMMotionManagerのサンプル(1)



AppStoreに載せているPanViewerは、パノラマ写真をiPhone/iPadを水平方向に回転させて見るアプリです。
このアプリはCMMotionManagerをからGyroデータ取得し、デバイスの回転角度に応じて画像を移動させています。その方法について紹介します。

今回はロジックを単純にし、肝心な部分をわかりやすくしています。

ポイントは次の3点です。
・CMMotionManagerからdeviceMotionのデータを取得する。
・attitude.quaternionのx、yの値からatan2関数でデバイスの水平方向の角度を得る。
・画像を角度に応じて水平方向に移動させる。

正直なところ、attitude.quaternionの値とatan2関数を使う方法は試行錯誤の結果たどり着いたものです。このアプリではデバイスを自分の前に構えて体を回転させ、それに応じて画像を動かすことを意図しています。attitude.rollでは体は動かさず手で回転させた場合は近い動作になりますが、体を回転させた場合は期待通りになりません。また、回転軸がデバイスの縦軸のため、横にした場合はattitude.pitchの方が近い動作になり、対応困難になります。

このサンプルでは次の問題があります。
・画像移動がギクシャクする。
・画像が画面からはみ出すとその部分が空白になる。

これらについては次回のサンプルで対処します。

サンプルアプリのプロジェクトはここからダウンロードできます。

以下はプロジェクト中のViewControllerのコード(一部を省略)です。このサンプルでは画像移動に関するコードがあるのはこのクラスだけです。

//CoreMotionをインポート
import UIKit
import CoreMotion

//このサンプルでは表示画面はViewControllerだけ
class ViewController: UIViewController {

    //CMMotionManagerのインスタンスをセットする。
    var motionManager: CMMotionManager?

    //画像を表示するUIImageView。画像の左右の端が画面内にある時に画像を繋げて表示するために3つ使う。
    @IBOutlet weak var imageView: UIImageView? //Center
  
    //3つのUIImageViewのsuperview。
    //画像移動はimageViewContainerのlayerを用いて行う。
    //位置判定が容易になるよう、frame.originは(0,0)とし、サイズはimageViewと同じにする。
    @IBOutlet weak var imageViewContainer: UIView?
    
    //motionManagerの値から計算した位置を画像の画像位置に合わせるための調整値。
    var adjX: CGFloat = 0
    
    //画像視野角。iPhoneで撮影したパノラマ写真の場合は概ね180度。
    let angle = 180.0
    
    //motionManagerから位置情報を取得するタイマーイベント間隔。
    let interval = 0.1
    
    //viewWillAppearで画像設定
    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        self.imageView!.frame = self.view.frame
        self.imageViewContainer!.frame = self.view.frame
        self.setImage(imageName: "ashinoko.jpg");
        self.setImageViewSize(viewSize: self.view.frame.size)
    }
    
    //画面表示後にmoveImageを呼ぶ。moveImageはタイマーイベントでループ実行する。
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        moveImage()
    }
  
    //デバイス回転時に縦、横の画面サイズに合わせてImageViewのサイズを変更する。
    override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
        self.setImageViewSize(viewSize: size)
    }
    
    //motionManagerをスタートさせる。
    func startMotionManger() {
        if self.motionManager == nil {
            self.motionManager = CMMotionManager()
        }
        self.motionManager!.startDeviceMotionUpdates()
    }
    
    //motionManagerをストップさせる。
    func stopMotionManger() {
        NSObject.cancelPreviousPerformRequests(withTarget:self);
        if let motionManager = self.motionManager {
            motionManager.stopDeviceMotionUpdates()
        }
        self.motionManager = nil;
    }

    //imageViewに画像をセットする。
    func setImage(imageName: String) {
        if let image = UIImage(named: imageName) {
            if self.imageView!.image != image {
                self.imageView!.image = image;
            }
        }
    }
    
    //3つのUIImageViewとimageViewContainerが画面にフィットするようにサイズ、位置を設定する。
    func setImageViewSize(viewSize: CGSize) {
        self.adjX = 0
        //3つのimageView及びimageViewContainerのサイズを設定する。
        if let imageView = self.imageView {
            if let image = imageView.image {
                //imageViewが画面にフィットするようにサイズを設定する。
                imageView.frame.size.width = image.size.width * viewSize.height / image.size.height
                imageView.frame.size.height = viewSize.height
                //imageViewに合わせてimageViewContainerのサイズを設定。
                self.imageViewContainer!.frame.size = imageView.frame.size
                self.imageViewContainer!.layer.position.x = 0
            }
        }
    }
    
    //タイマーイベントで実行し、デバイスの回転に応じて画像を水平方向に移動する。
    func moveImage() {
        if self.motionManager != nil {
            self.moveImageLayer()
        } else {
            self.startMotionManger();
        }
        self.perform(#selector(moveImage), with: nil, afterDelay: interval)
    }

    //self.imageViewContainer.layerの位置を変えることで画像を水平方向に移動する。
    //animationなし。ロジックはシンプル。
    func moveImageLayer() {
        let to = self.newPosition()
        self.imageViewContainer!.layer.position = to
    }

    func newPosition() -> CGPoint {
        let from = self.imageViewContainer!.layer.position
        //deviceMotionのquaternionの値から回転角を求め、水平方向の移動量を計算する。
        if let deviceMotion = self.motionManager?.deviceMotion {
            let attitude: CMAttitude = deviceMotion.attitude
            let q: CMQuaternion = attitude.quaternion
            //angleはimageの視野角。360、180など。iPhoneで撮影したパノラマ写真なら180。
            let tx = (CGFloat)(atan2(q.y, q.x) / Double.pi * (360 / angle))
            let w = self.imageView!.frame.size.width
            var x = w * tx + adjX;
            //初期状態ではadjX=0。この場合、画像表示位置と計算上の位置が一致するようにadjXを設定する。
            if adjX == 0 {
                adjX = from.x - x
                x = from.x
            }
            return CGPoint(x:x, y:from.y)
        }
        return from
    }
}