###はじめに###
お好み焼 ゆきです。普段は大阪でお好み焼きを焼いています。残念ながら仕事でRubyMotionを使う機会はなかなかありませんが(当たり前)、RubyCocoa来の「RubyでCocoaファン」です。本日はRubyMotionともすごく相性のいい、すばらしきAuto Layoutの世界を紹介させてください。対象としてはRubyMotionの基本が分かっておられる方を想定しています。どうぞよろしくお願いします(ちなみにプロファイルイメージは愛らしい感じですが、実際は男(しかも中年)が書いています。申し訳ない)。
###Auto Layoutとは###
Auto Layout はVer.6からiOSにも加えられた、view
や control
といったエレメントに、画面配置上の「縛り(Constraint)」をかけることでレイアウトを実現するAPIのことをいいます。
[AppleのドキュメントだとConstraintが「制約」となっていましたが、ここでは「縛り」で統一しています。「制約」=「縛り」として読んでください]
ここでいう「縛り」とは、たとえば
-
@yellowView
は、width
が画面の横サイズと等しく、superview
と底(bottom)どうしで接している -
@yellowView
のsubview
である@blueView
と@redView
は、それぞれ一定のマージンを保って横並びに配置されている -
@blueView
と@redView
のwidth
は互いに等しい
といったもののことで、 このようないくつかの縛りを実効時に矛盾が起きないように評価することで自動で画面レイアウト(hence an auto layout.)を実現しよう、というわけです。
###Auto Layout のメリット###
Auto Layout を使うとどんな優位性があるか?
- 複数の画面サイズ(3.5インチ、4インチ、iPadなど)とその複数のオリエンテーションに、いちどの設定で対応も可能
- プロジェクトの一部だけ Auto Layout を適用して、他はハードコードする、というような柔軟な使い方ができる
- 極められれば従来のオートリサイジングマスクよりもかなり複雑なことができる(らしい)
- ちょっとしたミスでも大量のエラーが表示されるので、rake の度にスリルを味わうことができる
さらにRubyMotionユーザーには
- Xcodeをワークフローから除外しているケースが多いであろうRMユーザーにはうってつけの Visual Format という記法が用意されている
- RM にはそのVisual Formatをラップした
motion-layout
という Gem がある - 1と2のおかげで楽しく Auto Layout が書ける
- デバッグにREPLが使える
という利点もあると僕は思っています。さらにいうなら、Xcodeを利用した場合と比較して、問題解決のためひとに助言を求めるとき、VIsual Format だと格段に状況の説明がしやすいんだろうと思います。うーむ、まさにいいことだらけ・・・。
そんなわけでXcodeにはまったく触れず、さっそくVisual Formatの話に入りたいと思います。
###Visual Format の基本###
Visual Formatというのは、さきほど例として上げたような縛りを、GUIでも散文的Obejctive-Cコードでもなく、絵文字ライクな記法で実装しようというものです。
先ほどの例を実際に作成すると下図のようになりますが、
これを frame
でハードコードしようとするとあれこれ計算したりしてなかなかたいへんです。でもAuto Layout と motion-layout
を使えば以下のような視覚的コードのみで実現できるのです。
Motion::Layout.new do |layout|
layout.view self.view
layout.metrics height: 120
layout.subviews yellow: @yellowView
layout.vertical "[yellow(height)]|"
layout.horizontal "|[yellow]|"
end
Motion::Layout.new do |layout|
layout.view @yellowView
layout.subviews red: @redView, blue: @blueView
layout.vertical "|-[red]-|"
layout.vertical "|-[blue]-|"
layout.horizontal "|-[red]-[blue(==red)]-|"
end
含まれている三つの view
は初期化した段階から一切 setFrame:
されていませんが、プログラムを実行すればきちんと上図の通りにレイアウトされます。ランドスケープにしてもこの通り。画面に合わせて view
が引き伸ばされているのがお分かりいただけると思います。
3.5インチの画面サイズでも、iPadでも同様に、画面サイズにあわせてレイアウトされます。
frame
をがっちり決め打ちするのではなく、その時々の状況に適したレイアウトが自動で行われるようにする ーー Auto Layout の魅力の一つがここにあります。
###motion-layout###
motion-layout
はかの高名なる米国はシカゴの 37signals で REMOTE ワークにいそしんでおられるニックさん作の、Rubyライクに Visual Format が書ける DSL です。とかく冗長になりがちなObjective-Cのコードをコンサイスにまとめてくれ、あらためてRubyとCocoaの相性の良さに気づかせてくれる、すばらしいgemだと思います。
ここで、さきほどのコードを一行ずつ説明しておきます。
Motion::Layout.new do |layout|
...
end
Layout
を初期化する際に渡すブロック内でレイアウトします
layout.view self.view
view:
には、縛りを加えたい view
、つまり縛りをかける際の礎となるべき view
を渡します。複数のエレメントを扱いたい場合、それらが共通にもつ直近の 親view
を指定します。
この例では、画面上でルートとなる view
(viewController
の view
プロパティ)を渡しています。
layout.subviews yellow: @yellowView
layout.metrics height: 120
subviews:
と metrics:
では、Visual Format String 内で使用する「呼び名」を定義します。
subviews:
ではレイアウトしたいオブジェクトを、metrics:
では値に「呼び名」をつけます。渡すのは、呼び名とオブジェクトをKey: Value でペアにしたHash(Dictionary)です。
この例では、@yellowView
オブジェクト に yellow
という呼び名をつけ、height
という単語に 120
という値を結びつけています。
layout.vertical "[yellow(height)]|"
layout.horizontal "|[yellow]|"
さていよいよ vertical:
と horizontal:
で絵を描きます。両方ともstringを渡します。
-
vertical:
で垂直方向を、horizontal:
で水平方向のレイアウトをする(垂直方向も当然横書きなので、最初はややこしいですが) - ブラケット(”[ ]”)を使って
subviews:
で決めた「呼び名」を括ると、Hashでその呼び名とペアにしたオブジェクトを表す - パイプ(”|”)は
view:
で渡したオブジェクトを表す - ハイフン(”ー”)はスペースを表す。一本なら Aqua標準のスペースを表し、二本なら間に値を挟むことでその値のスペースにすることができる(例:
"|-space-[my_view]"
=> 親ビューからspace
ポイント間をあけてmy_view
)
[yellow]
が @yellowView
を表し、|
が ルートviewを表していることになります。
layout.vertical "|-[red]-|"
layout.vertical "|-[blue]-|"
layout.horizontal "|-[red]-[blue(==red)]-|"
さらにさきほど metrics:
で 120
に設定した height
を、vertical:
に渡すstringの中で [yellow]
に丸括弧付きで加えることで、高さを120
に明示的に固定しています(ちなみに同じことを horizontal:
ですれば、幅が固定されます。height
なのになぜ幅が? と思ってしまいそうになりますが、height
はあくまで任意の「呼び名」でしかなく、しょせん意味は120
なのでその値に従って幅が変更されてしまうわけです)。
実際のコードは簡単なのに、ことばにすると結構ややこしく感じられるかもしれません。それこそ論より証拠というか、実際につくられたものを見た方が理解するのにもぐんと効率がいいと思うので、いよいよ目標のナンバーパッドをつくりたいと思います。
###ナンバーパッドをつくる###
目指すはこのレイアウトです
ブラケットで文字列を括ったものが view
(ここではコントロール:UIButton
)になるんだから、上のようなナンバーパッド実現のためには、要するに?こういうものが書ければいいわけです(注:数字ボタンの並びは電卓仕様)
|[7][8][9]|
|[4][5][6||
|[1][2][3]|
| [0][C]|
これを垂直と水平に分けてレイアウトを書いて、あとはボタン間のスペースを空けてやれば・・・
材料: ボタンが11個(0から9までの番号と消去ボタン)
土台となるview
がひとつ
手順: まずはボタンをまとめてつくります。その際そのまま subviews:
に渡せるよう(DRY精神)、Hashのインスタンスである @buttons
に呼び名とペアにして入れていきます。
@buttons = {}
(0..10).each { |num|
NumericButton.new(num, color) { |numbtn|
numbtn.set_targets delegate
instance_variable_set "@b#{num}", numbtn
@buttons[:"b#{num}"] = numbtn
}
}
# 消去キーだけ見た目を変えます #
@b10.tap { |btn|
btn.setTitle 'Clear', forState: UIControlStateNormal
btn.setTitleColor UIColor.grayColor, forState: UIControlStateNormal
btn.layer.borderWidth = 0
}
UIButton
をサブクラス化したNumericButton
をここでは使っていますが、初期化のプロシージャを移しただけで他はたいしたことはしていません。
これでボタンは完成。土台のviewはほぼプレーンなviewで十分なので、あとは
”[ボタン7]-スペース-[ボタン8]…”
と、やっていけばあっという間にレイアウト(だけ)は完成です! が、今回は『パスコードロック画面風』ということなので、せっかくならボタンを囲む枠を正円にしたいところ。そこで、手軽に cornerRadius
を利用してまんまるにするために、ボタンのフレームが常に正方形になるよう「縛る」ことにします。
@b9.addConstraint(
NSLayoutConstraint.constraintWithItem( @b9,
attribute: NSLayoutAttributeHeight,
relatedBy: NSLayoutRelationEqual,
toItem: @b9,
attribute: NSLayoutAttributeWidth,
multiplier: 1,
constant: 0 )
)
Visual Format では比を使った縛りはできないはずなので、NSLayoutConstraint
でコーディングしています。うーん、ヴァーボース! でも、それだけに読んでそのままという利点もあるかもしれない。ここではボタンの縦横サイズが同じになるように縛りをかけています(高さアトリビュートに1をかけて0を足したものを幅アトリビュートにする、つまり両者をイコールにするということ)。
そしてメインのレイアウトがこうなります。
btn_size = Device.ipad? ? '128@600' : '76@600'
Motion::Layout.new { |layout|
layout.view self
layout.subviews @buttons
layout.metrics "hs" => 20, "vs" => 12.5
layout.horizontal "|[b7(b9)]-hs-[b8(b9)]-hs-[b9(#{btn_size})]|"
layout.horizontal "|[b4(b9)]-hs-[b5(b9)]-hs-[b6(b9)]|"
layout.horizontal "|[b1(b9)]-hs-[b2(b9)]-hs-[b3(b9)]|"
layout.horizontal "[b0(b9)]-hs-[b10(b9)]|"
layout.vertical "|[b9]-vs-[b6(b9)]-vs-[b3(b9)]-vs-[b10(b9)]|"
layout.vertical "|[b8(b9)]-vs-[b5(b9)]-vs-[b2(b9)]-vs-[b0(b9)]|"#, :center_x
layout.vertical "|[b7(b9)]-vs-[b4(b9)]-vs-[b1(b9)]"
}
まず現在の画面サイズに基づいて円の大きさを調整すべく、デバイス判定をしてボタンのwidthを決めています(bubble-wrap
を利用)。あらかじめ@b9のwidthとheightが同じになるよう縛っているので、@b9の width
を決めれば当然 height
も決まります。そうして導きだされたボタンのサイズを見本にしてすべてのボタンの大きさを統一しよう、という算段です。丸括弧内で b9
と書かれているのは すべて ==b9
(b9と等しい)を表します。==
は省略可。
さてここではiPadでボタンサイズが大きくなるように調整しているのですが、128
や76
はサイズを表す値だとして、しかし@マーク
以下に続く600
はなんでしょうか? じつはこれは縛りの「優先度」を表しています。
優先度の話の前にコードの残りを説明しておくと、view:
には 土台となるview、つまりここでは self
を渡し、subviews:
には事前に用意しておいたHashをそのまま渡せばオーケーです。
更にmetrics:
では、後からの調節を楽にできるよう hs
(horizontal space) と vs
(vertical space) をそれぞれ決めています。
###Priority(優先度)###
「優先度」はそのままレイアウトの優先度のことで、互いに履行不可能(Unsatisfiable)な「縛り」にぶつかった際に、どちらを優先してレイアウトするのかを、この値で判断します。正直僕自身まだまだ勉強中でここできちんと説明できる自信はありませんが、 とりあえずここのケースで「なぜ優先度をいじったのか?」のリーズニングだけはしておくと、
- ここではwidthにかけた縛りが守られる優先度を「600」にしている。優先度は1000点満点(というかなんというか)かつそれが初期値なので、ここでは他に比して600まで優先度を下げていることになる
- なぜ優先度を下げたのかというと、ゴールはあくまで「円を描くためボタンを正方形にすること」であって、128なり76なりといったサイズが維持されることではないから
- つまり、優先度を下げたエレメントに「もしレイアウトの都合で私の値を維持するのが難しいのであれば、どうぞサイズを変更して他の優先度の高い縛り(ここでは正方形の維持)を守ってやってください。ええ、私のことならおかまいなく、どうにでもなりますんで。それよりどうか他の皆さんによろしく!」と言わせている
ということになります。Xcodeでは優先度を調節するたびにリアルタイムに状態が反映される説明書きが出るそうなので、雰囲気をつかむためにも積もったほこりを払ってXcodeを起ち上げ試してみるのも手かもしれません。
###サンプル実行例###
ProMotion と BubbleWrap をつかったサンプルコードはこちら。
Motion::NumpadView
を:green
で初期化し、任意のviewControllerに適切に加えてやるとこうなります。
horizontal では縦方向(vertical)が短くなるためボタンサイズが自動的にリサイズされますが、正円は維持。
サンプルではボタンはアニメーション以外特になにもしません。delegate
にしたオブジェクトのnumbutton_touch_up:
メソッドを埋めてボタンを機能させてやってください。
###デバッグ###
せっかくのRubyMotionなので、REPLを活用します。
#####_autolayoutTrace####
(main)> puts App.window._autolayoutTrace
*UIWindow:0x92e8230 - AMBIGUOUS LAYOUT
| *
| | *NumberPadView:0x92e97a0 - AMBIGUOUS LAYOUT
=> nil
この例では AMBIGUOUS LAYOUT
と表示されています。定義が曖昧(解が絞れない)でレイアウトできない、といっているわけです。
####frameを見る####
画面上にあるべきエレメントが表示されないなら、コマンドクリックでその親viewを選択してから
self.subviews.each { |sv| p sv.frame }
してみるのも手です。origin
や size
に不自然なところはありませんか?
###リファレンス###
- WWDC 2012 のセッション202はマスト。2007年の例のキーノート以来の衝撃(言い過ぎ)。
motion-layout
以前のMateusさんの記事- 公式ドキュメントのAppendix AではVisual Format の書き方がコンパクトにまとめられています。
###最後に###
まだまだ Visual Format だけではできないこともありますが、パッケージでみれば Auto Layout は本当に最高です。素人が(はじめてのMarkdownに苦しみつつ)書いた記事ですので、いろいろおかしなところもあるかと思いまが、なにかの足しにでもしていただければと思います。ご意見もお待ちしています。ありがとうございました。