iPhoneアプリ開発:AR 目次

 

 今回からXcodeのバージョンは10になってます。

 仕事で、旧バージョン使う必要があるとかでもないなら、さっさとApp StoreのアップデートタグからXcodeをアップデートしましょう。

§

 油圧ショベル化にあたり、ついでにリファクタリング(refactoring:再因子化)します。リファクタリングてのは、あれだ、分解して整理しなおすって意味くらいでとらえてればいいんじゃないかと。

 いつまでもVIewControllerばっか拡張するわけないっしょ。

 会社を発展させるのと同じで、適材適所、役割分担していくわけですよ。

 

 というわけで、油圧ショベル、操作盤のオブジェクトを用意しVIewControllerを通して連携させます。

Excavatorクラス

 油圧ショベルを構成する各ノードや姿勢を管理する。

 各ノードを動かすためのメッセージなんかも用意する。

 

 用意するメッセージ名と、回転軸や可動範囲

 肩

  名前:moveShoulder

  回転軸:y、可動範囲:-170〜170度

 ブーム

  名前:moveBoom

  回転軸:z、可動範囲:0〜90度

 アーム

  名前:moveArm

  回転軸:z、可動範囲:0〜90度

 バケット

  名前:moveBucket

  回転軸:z、可動範囲:-20〜90度

 

 各move***メッセージについては、引数direction:Floatを用意、基準速度に対する倍率とし0.0で停止することにする。

 func move***(direction:Float)

CrossControlクラス

 油圧ショベルを操作するための操作盤を画面に提供する。

 CGPoint型のregionというプロパティを持たせ、操作状態(指先がどこにあるか)を上下、左右についてそれぞれ[-1、0、1]で表現し参照できるようにする。

 

 

 そして、ユーザーのドラッグによる操作盤の操作による状態変化に対応し、ターゲット・アクションデザインパターンで他のオブジェクトと連携する。

 

 操作盤の操作方法には、予告通りJIS方式を使います。なので、右手用と左手用の2つのCrossControlオブジェクトを使いことになるっす。

 で、これらのオブジェクトをViewControllerで作成して利用するわけですな。

 

 

 上の図は、yumboという名前の1つのExcavatorオブジェクトと、leftHandle,

rightHandleという名前の2つのCrossControlオブジェクトがViewControllerオブジェクトと関係があるよってのを表現してます。

 

 

 :(コロン)を挟んだ左側がインスタンス名で、右側がクラス名になります。名前がないときはインスタンス名を省略して書いてもいい(:ViewControllerとかがそれ)。

注意)インスタンス(instance)ってのは「実体」って意味くらいにとらえてればいいです。今回の場合、CrossControlクラス定義に従って作成されたCrossControlオブジェクトや、Excavatorクラス定義に従って作成されたExcavatorオブジェクトのことをインスタンスって言ったりします。3つのインスタンス(実体)を作るわけっすね。

 別に、インスタンス名って書かずにオブジェクト名って書いても間違いじゃないと思うけど、オブジェクトがクラスオブジェクト(知らない人は「色々と脱線」参照)も含めちゃうイメージなのに対して、インスタンスはクラスや構造体の定義から作成されたもの限定ってイメージがあって、使い分けられたりします。


 leftHandleはこんな感じでyumboの姿勢を制御。

 

 

 rightHandleはこんな感じでyumboの姿勢を制御。

 

 

 CrossControlは画面に操作盤を表示するのでUIView派生クラスにします。

 でもって操作盤のドラッグ対応にはUIPanGestureRecognizerを使う。自分自身にUIPanGestureRecognizerを登録しドラッグを見張り、regionプロパティが変化した時にターゲット・アクションデザインパターンを使って連携先に通知する。

 こうすることで、前回やった指先位置から[-1, 0, 1]への変換なんかはCrossControlの内部に隠蔽でき、CrossControlを使う側は、この変化を[-1、0、1]という形で受け取ることができる。

 

 

 ということでCrossControlは、UIViewからではなくUIControlから派生させることにします。

UIControl

 UIControlはUIViewから派生してターゲット・アクションデザインパターンを実行可能にしたクラス。こいつから派生すると、お手軽にターゲット・アクションデザインパターンを実現できる。

 ただしUIControlは、ドラッグを自分自身が管理する前提なので、UIPanGestureRecognizerに任せる場合には、ちょっとした工夫が必要になるっす。

 というのも、UIPanGestureRecognizerは初期設定で、ドラッグを検出したら、その時から指が放されるまでの間、指先情報を独占するようになってるんですな。なので、UIPanGestureRecognizerを使ったドラッグ検出中にはUIControl側に指先情報が渡らない。

 そうなると、UIControlがドラッグ中にターゲット・アクションデザインパターンで行なってるイベント(Event)通知が発生しないことになるんですよ。

イベント(Event)

 マウスが動いた、動いているのが止まった、キーボードのキーが押された、放された、そういった事象の変化を、この業界ではイベントと表現したりします。

 UIPanGestureRecognizerなら、ドラッグが始まったとか指が動いたとかがイベントです。例えばUIControlだと代表的なイベントには以下のようなものがあるっす。

 

 

 で、ここらの通知をするためには、ドラッグ中の指先情報をUIControlが管理できないとダメなんですよ。

 なので、本来、UIControl派生クラスが独自のドラッグ対応をする場合、UIControlがあらかじめ用意してるbeginTracking、continueTracking、endTrackingというドラッグ対応用のメソッドをオーバーライドすることになってるんですな。

 それを横着してUIPanGestureRecognizerで対応して、指先情報をそっちに独占させちゃうと、UIControlクラスがドラッグ中に行なっていたイベント通知が発生しなくなるわけっす。

 この問題を解決するには、指先情報をUIPanGestureRecognizerで独占せずにUIControl側にも回すようにしなければいけない。そのためにやるのがUIPanGestureRecognizerのcancelsTouchesInViewプロパティをfalseにすること。

cancelsTouchesInViewプロパティ

 デフォルトはtrueで、trueだと指先情報をUIPanGestureRecognizerが独占する。これをfalseにすることで、UIControlまで指先情報が伝わりドラッグ中のイベント通知が正しく発信されるようになるっす。

        let panRecognizer = UIPanGestureRecognizer(
        	target: self, action: #selector(panHandler(_:)))
        panRecognizer.cancelsTouchesInView = false
        addGestureRecognizer(panRecognizer)

 あとは、どうやってCrossControlが持つregionプロパティの状態変化を、ターゲット・アクションデザインパターンとして通知するかなんだけど…

 これにはUIControl自身にsendActionsメッセージを送ると、登録されたターゲットのアクションメソッド群が自動的に呼び出されるって仕組みを使います。

sendActionsメッセージ

	sendActions(for: .valueChanged)

 このメッセージでは、for:引数に、呼び出すアクションの引き金となった状態変化の種類、つまりイベントの種類を指定することになってます。

 今回ならregionプロパティの状態変化なんだけど、これはUIControlで言うところの値の変化というイベントに該当し、こいつはUIControl.Eventという構造体で

	UIControl.Event.valueChanged

として、すでに定義されてるんで、こいつを渡すことにしました。

注意)UIControlとEventを .(ドット)で結んだ構造体名が何を意味するかは後述。それと、for:引数に書くときにUIControl.Event.valueChangedじゃなく.valueChangedと書いてるのは省略形。もちろんUIControl.Event.valueChangedと書いてもいい。メソッド側でfor:引数の型がUIControl.Eventと指定されてるので、UIControl.Event部分を省略できる。

 

 具体的な内容は、CrossControlを使う単純なサンプルを用意したのでソースコードを読んでちょ。AR使ってないんでシミュレータでも動きます。

 

サンプル:

 http://tetera.jp/xcc/book-sample/CrossControl.zip

 

 このサンプル見てわかると思うけど、UIControlのターゲット・アクションデザインパターンの実行方法自体はUIGestureRecognizer系とほぼ同じです。addTargetメッセージでターゲットとアクションメソッドを登録します。

 アクションに指定できるメソッドの引数形式は以下の3種類。

	@objc func メソッド名()
    @objc func メソッド名(_ sender:UIControl)
    @objc func メソッド名(_ sender:UIControl, forEvent event:UIEvent)

注意)UIControlのところは派生クラスの型にしてもOK

 

 受け取りたい情報によって、好きな引数形式のメソッドを用意すればいいでしょ。参考までに上記メソッドの#selector()への書き方は上から順に次のようになります。

	#selector(メソッド名)
    #selector(メソッド名(_:))
    #selector(メソッド名(_:forEvent:)) 
         ↑ _:とforEvent:の間に ,(カンマ)が入らない点に注意

 で、UIGestureRecognizer系と違ってUIControlのaddTargetメッセージは、もう1つ引数があって、通知を受けたいイベントの種類をfor:引数に指定するようになってます。

 イベント種類には、sendActionsメッセージのfor:引数で指定したように、UIControl.Event定義の値を使います。例えば.valueChangedの通知をcrossRegionHandlerメソッドで受けたいならこんな感じ。

    override func viewDidLoad() {
        super.viewDidLoad()
        let control = CrossControl(
        	frame:CGRect(x: 50, y: 100, width: 200, height: 200))
        self.view.addSubview(control)
        
        control.addTarget(self, 
        	action: #selector(crossRegionHandler(_:)), 
        	for: .valueChanged)
    }
    ・・・
    @objc func crossRegionHandler(_ control:CrossControl) {
        ・・・
    }

 UIControl.Event構造体はOptionSetプロトコルを採用してるので、複数指定も可能っす。複数指定したい場合は [ ] (スクエアブラケットペア)で囲んで、通知してもらいたいイベント種類群を ,(カンマ)で区切って羅列すればいい。

 例えば、.touchDragEnter.touchDragExitのどちらのイベントでもenterExitHandlerメソッドを呼び出してもらいたいなら次のように書く。

        control.addTarget(self, 
        	action: #selector(enterExitHandler(_:)), 
            for: [.touchDragEnter, .touchDragExit])

注意)OptionSetプロトコルに関しては、使う分には、複数の値をセットにして、このうちのどれかって指定ができる仕組みくらいの理解でいいんじゃないかと。本格的に調べるのは、自分で定義した型にOptionSetプロトコルを採用する時あたりでいいでしょう。プロトコルや採用がわからん人は「重箱の隅をつつくようにネチネチと進めてみる」ね。

 

 こんな風に、任意のイベントを任意のアクションメソッドに振り分けられるのがUIControlのターゲット・アクションデザインパターンの特徴。ここら辺も、コメントアウトして先のサンプルに書き込んでるので、色々試してみてください。

 

 Excavatorの方は、各ノード群の作成とか、関節をアニメーションで動かすのかなんかは、これまでの説明でわかると思うので、下のサンプルのソースコードを読みましょう。

 

サンプル:

 http://tetera.jp/xcc/book-sample/yumbo.zip

 実機で動かす時はTeamを自分のApple IDにしてね

 

 最後に、リブートキャンプ以外のSwift言語解説に触れてない人は、多分Excavatorクラス定義の以下の記述が謎になるだろうから補足説明しておく。

 まずは、ノードの名前をsholderIDという名前の定数プロパティとして宣言している部分から。

class Excavator {
    
   /// ノード識別用文字列
    private static let sholderID = "shoulder"  //  肩

 そこに出てくるprivateというキーワードはスコープの指定っす。スコープがわからん人は「ダイナミックなレイアウト」ね。

 こうすることで、Excavatorのクラス定義外からはsholderIDという定数が参照できなくなる。内々で使うプロパティなんで、Excavatorオブジェクトを使う側の人は気にする必要ありませんよ〜的な意思表示ですな。付ける付けないでアプリの動作スピードには影響しませんが、変数や定数、メソッドの影響範囲が限定されるとソースコードを読む時に楽なので、外部に公開する必要がなかったり、派生先でオーバーライドさせる気がないプロパティやメソッドには極力privateスコープをつけてきましょう。

 で、次に出てくるstaticというキーワード。

class Excavator {
    
   /// ノード識別用文字列
    private static let sholderID = "shoulder"  //  肩

 これは、このプロパティを、クラスオブジェクト側のプロパティにしろっていう指定っす。クラスオブジェクトがわからん人は「色々と脱線」ね。

 static指定してsholderIDをクラスオブジェクト側のプロパティにしたのは、ノード名はインスタンスごとには変わらないので、クラスオブジェクトのプロパティの方が適切だというのもあるんだけど、それ以外に、その下にある各ノード別の回転限界値を辞書の初期値として持たせたかったってのも理由としてあります。辞書がわからん人は「アンラップしてチン♪」の連想型配列のとこを読みましょう。

    /// 回転角の下限と上限(360度単位)
    private struct Limit {
        /// 下限
        let min:Float
        /// 上限
        let max:Float
    }

    /// 各ノードごとの回転角の下限と上限
    private static let limits = [
        sholderID:Limit(min:-180, max:180),
        boomID:Limit(min:0, max:90),
        armID:Limit(min:0, max:90),
        bucketID:Limit(min:-30, max:90)
    ]

 というのも、sholderIDや、ここで用意してるlimitsという辞書をクラスオブジェクト側じゃなく、インスタンス側のプロパティにしちゃうとlimitsの初期化部分で文句言われちゃうんですよ。

 「limits辞書の初期化で使ってるsholderIDだけど、インスタンスのプロパティなんで、イニシャライザ通った後でないと使えないよ」的なことを注意されます。

 イニシャライザでlimitsを初期化しちゃえばいいだけの話なんだけど、最初の理由にあるように、sholderIDやlimitsプロパティはクラスオブジェクト側に所属させるのが素直だと思うんでstaticつけてます。

 ちなみにlimits辞書の値に使ってるLimit構造体定義を、こんな感じで、Excavatorクラス定義の中に埋め込むってのはよくやる手法っす。

class Excavator {
    private struct Limit {
    	・・・
    }
}

 やっぱりスコープ関係の話で、Limitなんてありがちな構造体名を外部にまで晒しちゃうと迷惑なんで、Excavator所属のLimit構造体ですよ〜的に、定義の中に定義を埋め込んじゃうわけです。

 で、ここでもしprivateをつけなければ、外部から次のように書いて利用することも可能になる。

	Excavator.Limit

 今回紹介したUIControl.Event構造体なんかがそれです。

 でもってUIControl.Event.valueChangedなんかは、UIControl.Event構造体定義内でstatic指定で用意されてるプロパティっす。

タイププロパティ

 クラスオブジェクトのプロパティ同様に、型名とプロパティ名を .(ドット)で結んで指定できます。この手のプロパティは、クラスオブジェクトのプロパティも含め、タイプ(型)プロパティと呼ばれてます。

 

 最後に、Excavator.swiftの下の方に書かれている型のextension(機能拡張)定義について。

extension

extension Float {
    /// 自身の値を360度値と解釈して、これをラジアン値に変換した値を戻す
    var radian : Float {
        return 2.0 * .pi * self / 360.0
    }
}

 swiftでは、こんな風にextensionに続けて既存の型名を書いて、あとはクラスや構造体定義のようにメソッドや、今回定義しているradianのようなコンピューティッド・プロパティを記述すると、それらが元々の型に追加できるようになってるんですよ。

 

コンピューティッド・プロパティ

 って、なんすかそれって、なると思うけど、swiftのプロパティ定義には、これまでに使ってきた値を記録するストアド・プロパティと呼ばれるものとは別に、コンピューティッド・プロパティと呼ばれる、値を記録値じゃなく逐次計算で作り出すものがあるんす。

 extentionではストアド・プロパティを追加することは許されてません。許されてるのは、このコンピューティッド・プロパティの方。

 

 ↓親切本からの引用

 

 

 というわけで、ここで定義してるradianは、ゲッターのみを用意したコンピューティッド・プロパティで、コメントで書いてるとおり、自分が記録してる値を360度値と解釈してのラジアン値への変換を行うもの。

 これでFloat型には、もともとradianプロパティがあるかのように振舞ってくれるんで、Float型の定数・変数はこんな風に使うことができるようになる。

	let x:Float = 180	←180度
    let rad = x.radian	←3.14がradに代入される

 limit辞書を用意する時に、ラジアン値で書くとスッゲー分かりにくいんで、このradianプロパティ用意して使うときにラジアン値に変換して使うようにしました。

 

 まー、あとはStoryboardでARSCNViewやCrossControlをAuto Layout使って配置したり、@IBOutletや@IBAction使ってViewController.swiftと連携したりしてますが、こっちは「ストーリーボードでAuto Layoutだ」、「ストーリーボードでモーダル遷移だ」や「ストーリーボードにカスタムビューだ」なんかを読んで自力で解析しましょう。

 

 

 Auto Layoutでは、CrossControlの矩形を120x120固定とし、縦位置はself.viewの中央に、横位置は、左CrossControlはself.viewの左端、右CrossControlはself.viewの右端に来るように設定してます。

 Interface Builder画面のoutlineビューで、値を見たい拘束を選べば、Attribute Inspector画面で内容が表示されるよ。

 

 

 関係ないけど、今回からXcodeのバージョンは10のInterface Builder画面では、ライブラリの位置が変わってたりする。最初迷ったわ。

 

 

 ほ〜らほら。

 

 

 

 そーれそれそれ。
 というわけで、次回は、いよいよメッシュの変更。

Android Studioをインストールしてツールを使う

 

インストール

 adb install -r ファイル パス

署名確認

 keytool -list -printcert -jarfile ファイル パス

情報取得

 aapt l -a ファイル パス

 

 前情報になるので、パイプでgrepを併用するのが楽

 例)SdkVersionだけ知りたい

 ・・・  | grep "SdkVersion"

 

 また、aaptにはパスが通ってない場合がある。場所は

 ~/Library/Android/sdk/build-tools/

 のバージョン別のフォルダ内

 

 

iPhoneアプリ開発:AR 目次

 

 ということで、ちゃっちゃとアニメーション対応します。

 一番問題なのは、ドラッグ中の指位置変化のたびにアニメーションをやっちゃってること。

 こいつはARとか3Dとは関係なくUIPanGestureRecognizerの扱い方の問題っすな。前回言ったように[ー、0、+]の三層スイッチにするべきなんですよ。

 そのためにはドラッグエリアを三層に分けて、違う層に切り替わった時だけ反応するようにすればいい。

 

 

 あと、プルプル指先が震える人対策として、層の間に無効領域を作るとモアベターよ。

 

 

 でもってドラッグ開始位置を角度0としてるのもダメダメ。

 

 

 ドラッグを開始した時の角度から相対で回転させたいわけですよ。

 

 

 そういうわけで、回転させる角度をtranslation.yから割出さずに、固定値にして加算するようにします。

	node.eulerAngles.z = Float(translation.y / 100.0)

としてたのを

	node.eulerAngles.z += 1.0

とかにするわけですな(= で代入じゃなく += で加算してる点に注意)。

 これで、アニメーションの時間と回転角度が両方固定されるのでいつでも同じ速度で動くことになる。

 2πラジアンが360度なんで、1.0ラジアンは(1 / (2 x 3.14)) x 360で60度くらいっす。5秒間で60度回る。

 で、停止領域や指が放された時に回転を止める。それが、これ。

class ViewController: UIViewController {
	・・・
    /// ドラッグ中の指の存在領域を示す -1:負回転 0:停止 1:正回転
    private var directionRegion = 0
    
    /// 前回moveBoomが呼び出された時のdirectionRegionの値を記録
    private var lastDirectionRegion = 0
    
    /// ドラッグを検出した
    @objc func moveBoom(_ panRecognizer:UIPanGestureRecognizer) {
        if panRecognizer.state == .changed {
            
            let translation = panRecognizer.translation(
            	in: panRecognizer.view)
            
            //  ドラッグ中の指の存在領域の決定 停止領域の上下20ポイント分は
            //  無効な領域になっていてdirectionRegionを変化させない
            if translation.y < -50 {
                directionRegion = 1
            } else if translation.y > 50 {
                directionRegion = -1
            } else if translation.y > -30 
            && translation.y < 30 {
                directionRegion = 0
            }
            
            if lastDirectionRegion != directionRegion {
                //  領域が変わった
                lastDirectionRegion = directionRegion
                
                if let node = excavator.childNode(
                	withName: "boom", recursively: true) {
                    //  まず停止:画面上の角度を設定することで
                    //  アニメーション中なら強制停止になる
                    node.eulerAngles.z = node.presentation.eulerAngles.z
                    if directionRegion != 0 {
                        //  停止領域でないなら領域方向に一定速度で
                        //  アニメーションする
                        SCNTransaction.begin()
                        SCNTransaction.animationDuration = 5
                        node.eulerAngles.z += 1.0 * Float(directionRegion)
                        SCNTransaction.commit()
                    }
                }
            }
        }
        
        //  ドラッグ終了もしくはキャンセル時はアニメーションを強制的に停止
        if panRecognizer.state == .ended 
        || panRecognizer.state == .cancelled {
            if let node = excavator.childNode(
            	withName: "boom", recursively: true) {
                node.eulerAngles.z = node.presentation.eulerAngles.z
            }
        }
    }

サンプル:

 http://tetera.jp/xcc/book-sample/excavator-3.zip

 実機で動かす時はTeamを自分のApple IDにしてね

 

 ドラッグ開始点から上下50ポイント以上が回転起動領域っす。停止領域は上下30ポイント以下として無効領域も用意しました。

 

 

 や〜っと望んだ動きに近づいた。

 

 あとは、60度回転じゃなく、回転起動領域を押してる間中は一定速度で動き続けて、人間の関節みたいに限界の角度になるとそれ以上回転しなくなるようにすれば完璧。

 一つの方法としては、事前に自分の現在の角度から可動範囲を計算して、それに見合ったアニメーション時間と角度を指定する方法。これは、今持ってる知識で実現可能っす。

 こんな感じ。

  if lastDirectionRegion != directionRegion {
		・・・
    if directionRegion != 0 {
        //  -0.5π 〜 +0.5π の範囲を可動範囲とする
        //  現在の角度から、範囲内の残り可動角を算出
        let left = (0.5 * Float.pi * Float(directionRegion)) 
        	- node.eulerAngles.z
        if abs(left) > 0.0 {
            //  可動範囲内なので動かす
            
            //  回転速度は固定 1/5(ラジアン/秒)
            let velocity:Float = 1.0 / 5.0
            
            //  角度に必要な時間を、動作速度から割り出す
            let duration = abs(left) / velocity
            SCNTransaction.begin()
            SCNTransaction.animationDuration = CFTimeInterval(duration)
            node.eulerAngles.z += left
            SCNTransaction.commit()
        }
    }
  }

サンプル:

 http://tetera.jp/xcc/book-sample/excavator-4.zip

 実機で動かす時はTeamを自分のApple IDにしてね

 

 もっと根本的な対応方法としては、レンダリング・ループでアニメーションを直接コントロールする方法がある。

レンダリング・ループ

 アニメーションの原理に暗い人は、なんだそれってことになると思いますが…

 2D、3D関係なくアニメーションってのは、少しずつ変化してる静止画を高速で切り替えて、見ている人間の脳を錯覚させることで成り立ってるんですな。

 

 

 そのため絶えず、そして素早く画像を更新する処理が必要になるわけですよ。

 だいたい1秒間に60回も更新すればかなり滑らかっす。

 60回/秒と書いてもいいけど、普通はヘルツ(Hz)という単位を使って60Hzって書きます。

注意)VRなんかだと100Hzくらいでやった方が良いって言われてるけど、iPhoneの液晶画面自体は60Hzで更新されてるので、いくら内部で100Hzで更新しても意味がない。これがiPad Pro (10.5 インチ) または iPad Pro 12.9 インチ (第 2 世代)の液晶画面だと120Hzになる。60Hzはブラウン管の頃の日本やアメリカのテレビ放送の規格っすね。ヨーロッパのテレビ放送だと50Hz。ちなみに日本のアニメは8Hzが基本。

 

 そういうわけで、今回のようにブームの回転アニメーションでも、SceneKitはブームのpresentation側eulerAngles.zをちょっとだけ変化→その値でレンダリング→できた画像を表示、て作業を60Hzで延々と繰り返してるんですよ。

 これがレンダリング・ループ。

 

 

 ↓SceneKitでのレンダリングループの詳細が知りたい人はここを見ましょう。

https://developer.apple.com/documentation/scenekit/scnscenerendererdelegate

 

 で、この時、どんなアニメーションをするか(回転?、移動?、拡大縮小?、どのくらいの量?)という情報が必要なわけで、これを管理してるのがCAAnimationってオブジェクトです。

CAAnimation

 AppleのSceneKitのアニメーションについて書かれたドキュメントによると、例えば今回のeulerAngles.zプロパティの回転アニメーションなら、このクラスの派生であるCABasicAnimationを作成して使えばいいみたいっす。

 こんな感じ。

        let animation = CABasicAnimation(keyPath: "eulerAngles.z")
        animation.toValue = node.eulerAngles.z + left
        animation.duration = CFTimeInterval(duration)

 イニシャライザのkeyPath:引数に指定してる文字列は、どのプロパティをアニメーションさせるかって指定で、eulerAnglesプロパティのzプロパティっていうような階層構造もswiftの書式と同じようにドット(.)で繋いで表現できるようになっとります。イニシャライザがわからん人は「UIViewの派生でイニシャライザって…」、プロパティがわからん人は「そしてiPhoneアプリへ」ね。

 

  eulerAngles.z  ← プロパティの階層構造は、ドットで繋いで表現する

 

 あとはtoValueプロパティにアニメーション終端側でのeulerAngles.zの値、durationプロパティにそのアニメーション時間を指定(CFTimeIntervalという型なんでキャストしてる)すれば最低限の準備は完了。toValueに対応するfromValueプロパティてのもあるけど、未設定の場合はアニメーション直前のeulerAngles.zの値を使うって決まりになってるので、それでいいなら指定する必要はない。

 こうして作成したオブジェクトをブームノードにaddAnimationメッセージで登録すると、ブームノードの指定したプロパティについてアニメーションが引き起こされるってわけです。

注意)全てのプロパティがアニメーション対応してるわけではない。プロパティのヘルプをみると対応してるものはAnimatableって書いている。ヘルプの出し方がわからん人は「または私は如何にして心配するのを止めて…」を読みましょう。

		node.addAnimation(animation, forKey: "boom move")

 forKey:引数で指定してるのはアニメーションの識別子。addAnimationメッセージてとこから推測できると思うけど、CAAnimationは複数登録できるんで、その識別のためにプログラマが適当に名前を決めることになってます。あとあと識別する気がないならnilを指定してもいい。

 今回はブームを動かしてるので"boom move"って名付けました。

 こんな感じでCAAnimation派生のオブジェクトを直接作って指示するアニメーションはexplicit(エクスプリシット:露骨)なアニメーションと呼ばれます。

explicitアニメーション

 で、まあ、アニメーションするのはするのはいいけど、こんなの毎回書くの面倒だよね、ソースも読み辛くなるし〜、ということで用意されたのがプロパティを設定したら自動的にアニメーションする仕組み。こっちはimplict(インプリシット:暗黙裡)なアニメーションと呼ばれてます。

implictアニメーション

 こっちが、これまで書いてきたSCNTransactionを使ったアニメーションなわけです。

 で、implictと違ってexplicitアニメーションを途中で止める場合は、やっぱり露骨に作業する必要があります。

		node.eulerAngles.z = node.presentation.eulerAngles.z

とやるだけでは止まってくれない。

 アニメーションしてるノードにremoveAnimationメッセージで、止めたいアニメーションを削除する指定が必要になります。削除することが強制停止になるわけです。

		node.removeAnimation(forKey: "boom move")

 forKey:引数に止めたいアニメーションの識別子(addAnimation時に付けたやつ)を指定ね。ちなみに、時間がきてアニメーションが終わった場合は自動的に削除されるようになってるのでremoveAnimationメッセージを送る必要はない。

 で、ここでimplictとの重要な違いが1つ。

 強制停止だろうとアニメーション完了での停止だろうと、explicitアニメーションの場合は、アニメーションが止まるとオリジナル側のnode.eulerAngles.zの位置に戻ります。

 強制停止の場合、removeAnimationの前にオリジナル側のeulerAngles.zをpresentation側のeulerAngles.zで更新してるので問題ないんだけど、完了での停止での対応をしてないんですよ。なので、ドラッグ中にアニメーションが完了するとアニメーション直前に設定されてる位置に戻っちゃうんですな。

 なので、implictと同じようにしたいなら次のような処理を追加する必要がある。

        let animation = CABasicAnimation(keyPath: "eulerAngles.z")
        animation.toValue = node.eulerAngles.z + left
        animation.duration = CFTimeInterval(duration)
        animation.fromValue = node.eulerAngles.z
        SCNTransaction.disableActions = true
        node.eulerAngles.z += left
        SCNTransaction.disableActions = false

 まずfromValueプロパティに現在のeulerAngles.zの値を設定して、その後オリジナル側のeulerAngles.zをアニメーション到達点の値に設定する。

 その際、implictアニメーションが発生するのを避けるためにSCNTransactionのdisableActionsプロパティをfalseにしておく。

disableActionsプロパティ

 このプロパティがtrueならアニメーション対応プロパティを設定してもimplictアニメーションを発生させないってのが決まりです。

 他のやり方として、アニメーション終了時も自動では削除させない、終了時にオリジナル側の値で表示しないという設定にする手もあります。

        let animation = CABasicAnimation(keyPath: "eulerAngles.z")
        animation.toValue = node.eulerAngles.z + left
        animation.duration = CFTimeInterval(duration)
        animation.isRemovedOnCompletion = false
        animation.fillMode = kCAFillModeForwards

 isRemovedOnCompletionプロパティがアニメーション終了時に自動で削除するかどうかの指定で、fillModeプロパティが終了時の表示をどうするかの指定ね。

 kCAFillModeForwardsでアニメーション終了時の位置のままにする指定になる。

 implictアニメーションであるサンプル:excavator-3をexplicitアニメーションに置き換えたやつをサンプル:excavator-3-exとして置いとくので、興味がある人は比較してみてください。ソースを比較するときは「キャッチして」の初頭で紹介したGit(ソースコントロール機能)を使うといいですよ。

 ヘルプ見ながらisRemovedOnCompletionやfillModeを使うやり方とか試してみるのもいいでしょう。

 

サンプル:

 http://tetera.jp/xcc/book-sample/excavator-4-ex.zip

 実機で動かす時はTeamを自分のApple IDにしてね

 

 とまあ、implict、explicitアニメーション、ここら辺の仕組みはUIViewやCALayerと同じっすね。UIViewやCALayerのimplictアニメーション用にはCATransactionてのが用意されてます。

 Appleはアニメーション表現をかなり重視してて、開発者に解放されたiOS 2.0の時からこの仕組みが用意されてるんだけど、もともとはmacOSからCALayerと一緒に流れて来たもので、プロパティに値が設定されるのをKVO(Key Value Observe)て仕組みで見張っていて、これを引き金に裏方としてCAActionというオブジェクトがCAAnimationを作成したりして実行します。

 ここら辺の仕組みに興味が湧いた人は「Core Animationプログラミングガイド」を読みましょう。

 

 Appleの日本語ドキュメントサイト

 

 ちなみに、ここまで書いててなんだけど、SceneKitでのexplicitアニメーションはSCNActionってのを使った方が簡単です。

SCNAction

 紛らわしいけど、上で紹介したCAActionとは役割が違います。役割としてはSpriteKitのSKActionと同じ。explicitアニメーション機能をCAAnimationより使いやすくする目的で用意されたオブジェクトです。

注意)SpriteKit:SceneKitの2D版(こっちが先に作られた)

 

 上のCAAnimation版アニメーションはSCNActionを使うとこうなる。

	let action = SCNAction.rotateBy(x: 0, y: 0, z: CGFloat(left), 
    	duration: TimeInterval(duration))
    node.runAction(action, forKey: "boom move")

 アニメーション停止はこう。

	node.removeAction(forKey: "boom move")

サンプル:

 http://tetera.jp/xcc/book-sample/excavator-4-ex-ac.zip

 実機で動かす時はTeamを自分のApple IDにしてね

 

 なので、SceneKitでのexplicitアニメーションはSCNActionを積極使用っすかね。

 CAAnimationの方は、主にキャラクターのモーションなんかを、Xcodeとは別の3Dモデラーで用意して、それを利用するために使うことになるでしょう。

 それとSCNAnimationてのもあります。

SCNAnimation

 こっちは2017年に発表されiOS 11から利用可能になったもんで、アニメーション同士の合成で威力を発揮するみたいっす。これもキャラクターのモーション合成なんかでお世話になるかと思われ。

 

 ↓WWDC2017のSCNAnimationについての説明はここ

https://developer.apple.com/videos/play/wwdc2017/604/

 

注意)このページにあるPresentation Slides (PDF)てのがセッション全文で、SCNAnimationは122ページあたりに出てきます。

 

 で、これに加えてPhysics Simulationという物理運動用のアニメーション機構が絡んでくるわけですよ〜。

 

↓Physics Simulationについてはここ

https://developer.apple.com/documentation/scenekit/physics_simulation

 

 ふう。先は長い。

 とりま、そういう使い分けは、これからということで、まずはロボットアームの完全版をね。

 まあ、その3の写真見て気付いてると思うけど、油圧ショベルにするんですよ。ユンボちゃんにするの。

 

 

 油圧ショベルのコントローラにはJIS方式を使います。

 画面上の左右のコントローラで3m近いブームやアームをブンブン動かすから。

 いいも悪いもリモコン次第〜。

 待て次回!