AutoLayout を使わずにわかりやすいコードでレイアウトが組めるフレームワーク NotAutoLayout

  • 26
    Like
  • 0
    Comment

前書き

以前書いた記事の通り、筆者は Storyboard も AutoLayout も大嫌いです。自分のプロジェクトには基本的に全てコードでレイアウト組んでます。
かと言ってレイアウトのために ViewController ごとに UIView を作るのもなんかだるいし、ViewController の中に子 ViewController が含まれた場合 ViewView の関係もなんか複雑になってくるのでややこしくなりかねない難点があります。
なのでレイアウトだけのために UIViewController ごとに UIView をサブクラスしなくてもいいように、UIViewController からレイアウトに必要な情報を渡して正しくレイアウトしてくれる UIView を作って Framework にまとめることにしました。

目標

  • コントローラーの中に子ビューと一緒に子ビューの必要なレイアウト情報を渡すだけで済む
  • レイアウト情報はなるべく見やすく、かつ一箇所にまとめられるようにする
  • 自由度があり、親ビューの大きさに応じて正しくレイアウトが組める
  • レスポンシブレイアウトもスマートに組める
  • でもなるべくコード量を抑えたい

完成物

GitHub にあります。

解説(20170226書き直し)

以前の記事にも解説書きましたが、当時のバージョンと比べて現在の 0.7 版はだいぶ変わったので解説書き直します。

変更点

まず LayoutView というクラスはまだ存続していますが、それは UIView から継承しただけでなく、今回新たに追加した LayoutControllable というプロトコルに準拠しました。基本的に具体的なレイアウトのやり方は全て LayoutControllableextension で実装しましたので、これにより UIView 以外のビュー、例えば UIImageVew などでも自分で作って LayoutControllable に準拠すれば OK です。

次に当時のバージョンにあった layoutingSubviews: [(view: UIView, layoutMethods: [LayoutMethod])] というプロパティーがなくなって、その代わり layoutInfo: [UIViwe: [LayoutMethod]]zIndexInfo: [UIView: Int] という二つの辞書型プロパティーが使われることになりました。これにより前は layoutingSubviews というプロパティーに追加しなければならないという通常の子ビューの追加の仕方とはちょっと違うという問題が解消され、今は通常の子ビューの追加と同じ addSubview でできるようになりました。

また以前はほぼ全ての型は LayoutView の下にぶら下げていましたが、今は全部上に出して、LayoutView.LayoutMethod ではなく普通に LayoutMethod で書けば OK です。

使い方

NotAutoLayout を import したら、LayoutView という UIView のサブクラスが使えるようになります。

LayoutView には layoutInfozIndexInfo というプロパティーがあります、中にはそれぞれ子ビューのレイアウトに必要な情報([LayoutMethod])と zIndex の情報(Int)が入っています。

LayoutMethod(condition: (CGSize) -> Bool, position: LayoutPosition)typealias です。condition は現在の LayoutView のサイズから求められたレイアウト条件で、position は具体的なレイアウト方法です。例えば condition{$0.width > $0.height} と書くと、LayoutView のサイズが高さより幅の方が大きい、つまりランドスケープの場合に position を求めてレイアウトしますし、condition{$0.width > 1024 && $0.height > 768} と書くとこれは画面サイズが 1024x768 より大きい場合に position を求めてレイアウトします。さらには条件を特に決めたくなければ {_ in true} と書けば無条件に position を求めます。

ただし注意しなければならないのは layoutMethodsLayoutMethod の配列ですので、first(where:) メソッドで配列の中に一番最初に条件に合った LayoutMethod だけが求められます。また配列に条件にあった LayoutMethod がない場合は再レイアウトしません。

そして条件に合ったメソッドが見つかった場合、メソッドの position を基にレイアウトを決めますが、この position はさらに Position という enum 型となります。中には
- .absolute(CGRect)
- .relative(CGRect)
- .insets(UIEdgeInsets)
- .offset(value: UIOffset, from: OffsetOrigin, size: CGSize)
- .customByFrame(frame: (CGSize) -> CGRect)
- .customByOriginSize(origin: (CGSize) -> CGPoint, size: (CGSize) -> CGSize)
- .customByXYWidthHeight(x: (CGSize) -> CGFloat, y: (CGSize) -> CGFloat, width: (CGSize) -> CGFloat, height: (CGSize) -> CGFloat)
の七つの case があります。

  • absoluteLayoutView のサイズと関係なく CGRect の数値のみでレイアウトを決めます。例えば .absolute(CGRect(x: 10, y: 20, width: 30, height: 40)) を使えば、LayoutView のサイズ関係なく指定の子ビューの frame は常に CGRect(x: 10, y: 20, width: 30, height: 40) になります。
  • relativeCGRect の数値は全て LayoutView のサイズから見た相対的な大きさで、例えば .relative(CGRect(x:0.1, y: 0.2, width: 0.3, height: 0.4))LayoutViewbounds.sizeCGSize(width: 100, height: 200) の場合、指定の子ビューの frameCGRect(x: 100 * 0.1, y: 200 * 0.2, width: 100 * 0.3, height: 200 * 0.4) になります。
  • insetsLayoutView から見た余白の指定になります。例えば insetsUIEdgeInsets(top: 10, left: 20, bottom: 30, right: 40)LayoutViewbounds.sizeCGSize(width: 100, height: 200) の場合、指定した子ビューの frameCGRect(x: 20, y: 10, width: 100 - 20 - 40, height: 200 - 10 - 30) になります
  • offset は指定した基準点からのオフセットとサイズで指定した子ビューの frame が決まります。基準点である OffsetOrigin.topLeft.topCenter 等の 9 パターンがあります。例えばここで valueUIOffset(horizontal: -10, vertical: 10)from.topRightsizeCGSize(width: 30, height: 40) で、LayoutViewbounds.sizeCGSize(width: 100, height: 200) の場合、指定した子ビューの frameCGRect(x: 100 - 30 + (-10), y: 10, width: 30, height: 40) になります。
  • customByXXXX はその都度 LayoutViewbounds.size をクロージャーに渡して必要なフレームを求める非常に自由度の高い指定法です。違うのは戻り値は CGRect なのか、それとも CGRect の構成要素なのかです。なぜこんな風に作ったかというと物によっては frame ではなく x, y, width, height の方が書きやすかったりする場合があるからです。また、クロージャーを用いるので非常に自由度は高いですが、逆にこれは layoutInfo に格納されているので、必要に応じてキャプチャーリストを入れてあげないとメモリリークになりますのでご注意を。

また、position に使う CGRectframe プロパティーと同じように、左上の原点座標とサイズを記述する書き方ですが、中身は直接 frame プロパティーにではなく、boundscenter にそれぞれ適用していますので、transform が入っても問題なく表示できます(多分…)

直接 [LayoutMethod] を書くのは物によってソースコードが煩雑化する可能性が高いので、便利なメソッドもいくつか用意してあります:

  • setLayoutMethods(_ methods: [LayoutMethod], for subview: UIView)subviewmethods を関連づけ、もし subview にはすでに他の [LayoutMethod] と関連付けされた場合は古い値は破棄されます。
  • setConstantPosition(_ position: LayoutPosition, for subview: UIView)subview(condition: {_ in true}, position: position) という LayoutMethod と関連付け、もし subview にはすでに他の [LayoutMethod] と関連付けされた場合は古い値は破棄されます。
  • appendLayoutMethod(_ method: LayoutMethod, for subview: UIView)subview に既存の関連付けされた [LayoutMethod] に新たに method を追加するメソッドです。もし既存の [LayoutMethod] がなければ [method] を関連付けします。
  • appendConstantPosition(_ position: LayoutPosition, for subview: UIView)subview に既存の関連付けされた [LayoutMethod] に新たに (condition: {_ in true}, position: position) という LayoutMethod を追加するメソッドです。もし既存の [LayoutMethod] がなければ [(condition: {_ in true}, position: position)] を関連付けします。
  • setLayout(of subview: UIView, at position: LayoutPosition, while condition: @escaping LayoutCondition)appendLayoutMethod(_ method: LayoutMethod, for subview: UIView) の別バージョンです。

再度レイアウトが必要な場合は通常の UIView と同じようにメインスレッドから setNeedsLayout() を呼べばもう一度レイアウト情報通りにレイアウトしてくれます。画面の向きを変えた場合は OS が勝手に layoutSubviews() を呼び出してくれるので手動で呼び出す必要がありません。

また、ここでは LayoutView のインスタンスをそのまま利用していることを前提に解説していますが、LayoutView をサブクラスしても、もしくは他の自分のクラスを LayoutControllable に準拠させるのも可能です。LayoutControllable に準拠させる場合は layoutInfozIndexInfo の格納プロパティーを作って、レイアウトメソッド内に(UIView なら layoutSubviews()layoutControl() を呼び出せば OK です。LayoutView の実装を読んでみればわかると思います。

なお、zIndex についてですが、これは UIKitzIndexzPosition といった奥行き情報に対応していないため、基本手動で reloadSubviews() を呼び出さないと zIndexInfo の情報が反映されません。なのでどっちかというと子ビューを追加するときに順番通りに addSubview もしくは insertSubview などで入れることがオススメです。reloadSubviews() を呼び出すと、現在の LayoutView に追加された全ての subvuews を一旦 remove して、そして zIndexInfo からそれら奥行き情報を参照し(なければデフォルト値の 0 とみなす)、小さい順でソートし直し(Int 型なのでマイナスの値を与えるとデフォルト値より奥に配置されます)、ソートされた順番でもう一度 addSubview されます。

使用例

例えば、下記の図のように、ラベルをキャンバスの幅の 20% の正方形、また右上に 10 ピクセルの余白をつけたい場合
スクリーンショット 2017-02-26 12.55.28.png

Playground でこれを書いてみるとこんな感じになります:

Playground
import UIKit
import PlaygroundSupport
import NotAutoLayout

let view = LayoutView(frame: CGRect(x: 0, y: 0, width: 320, height: 568))
view.backgroundColor = .white
PlaygroundPage.current.liveView = view

let label: UILabel = {
    let label = UILabel()
    label.backgroundColor = .red
    label.textAlignment = .center
    label.text = "Label A"
    view.addSubview(label)
    return label
}()

let position = LayoutPosition.customByXYWidthHeight(x: { $0.width * 0.8 - 10 },
                                                    y: { _ in 10 },
                                                    width: { $0.width * 0.2 },
                                                    height: { $0.width * 0.2 })
view.setConstantPosition(position, for: label)

view.setNeedsLayout()

といった感じになります。UIViewController なら loadView() をオーバーライドして viewLayoutView にすればいいでしょう。

また、計算量が増えるにもかかわらず、LayoutView にレイアウト条件を入れるのではなく、子ビューにレイアウト条件を入れるのはその方が子ビューにとってもっとレイアウトの自由度が増えるし(例えば特定な子ビューを画面サイズだけでなく他の条件を応じたレイアウトも可能)、子ビューの要素がそこまで増えなければ 0.01 秒の誤差なんて無視しても問題ないからです。それに LayoutView をネストすることも可能ですので、ネストをうまく使えばレイアウト条件の計算を減らすことも可能かと思います。

あとがき

relativeabsolute の単語は CSS からパクってきたけど意味としては CSS のそれとは違うので微妙に気持ち悪いっちゃ悪い。何かもっとふさわしい単語のオススメがあれば教えていただきたいです。
他にももし何か質問あれば気軽にどうぞ。
GitHub で Star くれるととても喜びます。