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

2017年1月20日金曜日

[__NSArrayI pointValue]: unrecognized selector sent to instance ...


swiftでコーディング中に次のエラーが発生した
[__NSArrayI pointValue]: unrecognized selector sent to instance

NSArrayにセットしたオブジェクトがCGPointと想定されるが、それ以外の値がセットされている場合に発生する。

Googleで検索するとintの場合なども出てくる。
[__NSArrayM intValue]: unrecognized selector sent to instance

私の場合、Appleのドキュメントにある次のサンプルに倣ってCABasicAnimationに数値のArrayをセットしたところ発生した。
let animation = CABasicAnimation(keyPath: "position")
animation.fromValue = [0, 0]
animation.toValue = [100, 100]

単独のアニメーションはこれで動作するが、ループで繰り返し実行中にデバイスを回転させると上記エラーが発生する。

次のように変更するとエラーが発生しなくなる。
anim.fromValue = CGPoint(x:0, y:0)
anim.toValue = CGPoint(x:100, y:100)

CABasicAnimation のfromValue、toValueはkeyPathによって異なる値をとるので、なんでもセットすることができてしまう。
"position"の場合は基本的にはCGPointとswiftのArrayの両方に対応しているが、どこかに未対応のところが残っているため、発生するのだろう。

このようなエラーが発生した場合はObjective-Cのドキュメントを参照し、その場合と型が異なっていないかチェックしてみるとよいでしょう。