今回から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画面では、ライブラリの位置が変わってたりする。最初迷ったわ。
ほ〜らほら。
そーれそれそれ。
というわけで、次回は、いよいよメッシュの変更。