Auto Layout でパスコードロック画面風ナンバーパッドをつくる

  • 27
    いいね
  • 0
    コメント
この記事は最終更新日から1年以上が経過しています。

はじめに

お好み焼 ゆきです。普段は大阪でお好み焼きを焼いています。残念ながら仕事でRubyMotionを使う機会はなかなかありませんが(当たり前)、RubyCocoa来の「RubyでCocoaファン」です。本日はRubyMotionともすごく相性のいい、すばらしきAuto Layoutの世界を紹介させてください。対象としてはRubyMotionの基本が分かっておられる方を想定しています。どうぞよろしくお願いします(ちなみにプロファイルイメージは愛らしい感じですが、実際は男(しかも中年)が書いています。申し訳ない)。

Auto Layoutとは

Auto Layout はVer.6からiOSにも加えられた、viewcontrol といったエレメントに、画面配置上の「縛り(Constraint)」をかけることでレイアウトを実現するAPIのことをいいます。

[AppleのドキュメントだとConstraintが「制約」となっていましたが、ここでは「縛り」で統一しています。「制約」=「縛り」として読んでください]

ここでいう「縛り」とは、たとえば

  1. @yellowView は、width が画面の横サイズと等しく、superview と底(bottom)どうしで接している
  2. @yellowViewsubview である @blueView@redView は、それぞれ一定のマージンを保って横並びに配置されている
  3. @blueView@redViewwidth は互いに等しい

といったもののことで、 このようないくつかの縛りを実効時に矛盾が起きないように評価することで自動で画面レイアウト(hence an auto layout.)を実現しよう、というわけです。

Auto Layout のメリット

Auto Layout を使うとどんな優位性があるか?

  1. 複数の画面サイズ(3.5インチ、4インチ、iPadなど)とその複数のオリエンテーションに、いちどの設定で対応も可能
  2. プロジェクトの一部だけ Auto Layout を適用して、他はハードコードする、というような柔軟な使い方ができる
  3. 極められれば従来のオートリサイジングマスクよりもかなり複雑なことができる(らしい)
  4. ちょっとしたミスでも大量のエラーが表示されるので、rake の度にスリルを味わうことができる

さらにRubyMotionユーザーには

  1. Xcodeをワークフローから除外しているケースが多いであろうRMユーザーにはうってつけの Visual Format という記法が用意されている
  2. RM にはそのVisual Formatをラップした motion-layout という Gem がある
  3. 1と2のおかげで楽しく Auto Layout が書ける
  4. デバッグにREPLが使える

という利点もあると僕は思っています。さらにいうなら、Xcodeを利用した場合と比較して、問題解決のためひとに助言を求めるとき、VIsual Format だと格段に状況の説明がしやすいんだろうと思います。うーむ、まさにいいことだらけ・・・。

そんなわけでXcodeにはまったく触れず、さっそくVisual Formatの話に入りたいと思います。

Visual Format の基本

Visual Formatというのは、さきほど例として上げたような縛りを、GUIでも散文的Obejctive-Cコードでもなく、絵文字ライクな記法で実装しようというものです。

先ほどの例を実際に作成すると下図のようになりますが、

al_sample_1.jpeg

これを 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 が引き伸ばされているのがお分かりいただけると思います。

al_sample_2.jpeg.jpeg

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 を指定します。

この例では、画面上でルートとなる viewviewControllerview プロパティ)を渡しています。

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)]-|"

の三行をスクリーンに重ねるとこうなります。
visual_format.png

さらにさきほど metrics:120 に設定した height を、vertical:に渡すstringの中で [yellow] に丸括弧付きで加えることで、高さを120に明示的に固定しています(ちなみに同じことを horizontal: ですれば、幅が固定されます。height なのになぜ幅が? と思ってしまいそうになりますが、heightはあくまで任意の「呼び名」でしかなく、しょせん意味は120なのでその値に従って幅が変更されてしまうわけです)。

実際のコードは簡単なのに、ことばにすると結構ややこしく感じられるかもしれません。それこそ論より証拠というか、実際につくられたものを見た方が理解するのにもぐんと効率がいいと思うので、いよいよ目標のナンバーパッドをつくりたいと思います。

ナンバーパッドをつくる

目指すはこのレイアウトです

ipad_numberpad.png

ブラケットで文字列を括ったものが view (ここではコントロール:UIButton)になるんだから、上のようなナンバーパッド実現のためには、要するに?こういうものが書ければいいわけです(注:数字ボタンの並びは電卓仕様)


    |[7][8][9]|
    |[4][5][6||
    |[1][2][3]|
    |   [0][C]|

これを垂直と水平に分けてレイアウトを書いて、あとはボタン間のスペースを空けてやれば・・・

材料: ボタンが11個(0から9までの番号と消去ボタン)
土台となるviewがひとつ

手順: まずはボタンをまとめてつくります。その際そのまま subviews: に渡せるよう(DRY精神)、Hashのインスタンスである @buttons に呼び名とペアにして入れていきます。

number_pad_view.rb

     @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 を利用してまんまるにするために、ボタンのフレームが常に正方形になるよう「縛る」ことにします。

number_pad_view.rb

       @b9.addConstraint( 
        NSLayoutConstraint.constraintWithItem( @b9,
                                    attribute: NSLayoutAttributeHeight,
                                    relatedBy: NSLayoutRelationEqual,
                                       toItem: @b9,
                                    attribute: NSLayoutAttributeWidth,
                                   multiplier: 1,
                                     constant: 0 )
      )

Visual Format では比を使った縛りはできないはずなので、NSLayoutConstraintでコーディングしています。うーん、ヴァーボース! でも、それだけに読んでそのままという利点もあるかもしれない。ここではボタンの縦横サイズが同じになるように縛りをかけています(高さアトリビュートに1をかけて0を足したものを幅アトリビュートにする、つまり両者をイコールにするということ)。

そしてメインのレイアウトがこうなります。

number_pad_view.rb

    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が同じになるよう縛っているので、@b9width を決めれば当然 height も決まります。そうして導きだされたボタンのサイズを見本にしてすべてのボタンの大きさを統一しよう、という算段です。丸括弧内で b9 と書かれているのは すべて ==b9 (b9と等しい)を表します。== は省略可。

さてここではiPadでボタンサイズが大きくなるように調整しているのですが、12876はサイズを表す値だとして、しかし@マーク以下に続く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に適切に加えてやるとこうなります。

numpad_1.jpeg

horizontal では縦方向(vertical)が短くなるためボタンサイズが自動的にリサイズされますが、正円は維持。

numpad_2.jpeg

サンプルではボタンはアニメーション以外特になにもしません。delegate にしたオブジェクトのnumbutton_touch_up:メソッドを埋めてボタンを機能させてやってください。

デバッグ

せっかくのRubyMotionなので、REPLを活用します。

#_autolayoutTrace

(main)> puts App.window._autolayoutTrace
*<UIWindow:0x92e8230> - AMBIGUOUS LAYOUT
|   *<PXUIView_UIView:0x92e96a0>
|   |   *<NumberPadView:0x92e97a0> - AMBIGUOUS LAYOUT

=> nil

この例では AMBIGUOUS LAYOUT と表示されています。定義が曖昧(解が絞れない)でレイアウトできない、といっているわけです。

frameを見る

画面上にあるべきエレメントが表示されないなら、コマンドクリックでその親viewを選択してから

self.subviews.each { |sv| p sv.frame }

してみるのも手です。originsize に不自然なところはありませんか?

リファレンス

  • WWDC 2012 のセッション202はマスト。2007年の例のキーノート以来の衝撃(言い過ぎ)。
  • motion-layout 以前のMateusさんの記事
  • 公式ドキュメントのAppendix AではVisual Format の書き方がコンパクトにまとめられています。

最後に

まだまだ Visual Format だけではできないこともありますが、パッケージでみれば Auto Layout は本当に最高です。素人が(はじめてのMarkdownに苦しみつつ)書いた記事ですので、いろいろおかしなところもあるかと思いまが、なにかの足しにでもしていただければと思います。ご意見もお待ちしています。ありがとうございました。

この投稿は RubyMotion Advent Calendar 201312日目の記事です。