前書き
以前書いた記事の通り、筆者は Storyboard も AutoLayout も大嫌いです。自分のプロジェクトには基本的に全てコードでレイアウト組んでます。
かと言ってレイアウトのために ViewController
ごとに UIView
を作るのもなんかだるいし、ViewController
の中に子 ViewController
が含まれた場合 View
と View
の関係もなんか複雑になってくるのでややこしくなりかねない難点があります。
なのでレイアウトだけのために UIViewController
ごとに UIView
をサブクラスしなくてもいいように、UIViewController
からレイアウトに必要な情報を渡して正しくレイアウトしてくれる UIView
を作って Framework にまとめることにしました。
目標
- コントローラーの中に子ビューと一緒に子ビューの必要なレイアウト情報を渡すだけで済む
- レイアウト情報はなるべく見やすく、かつ一箇所にまとめられるようにする
- 自由度があり、親ビューの大きさに応じて正しくレイアウトが組める
- レスポンシブレイアウトもスマートに組める
- でもなるべくコード量を抑えたい
完成物
GitHub にあります。
解説(20170226書き直し)
以前の記事にも解説書きましたが、当時のバージョンと比べて現在の 0.7 版はだいぶ変わったので解説書き直します。
変更点
まず LayoutView
というクラスはまだ存続していますが、それは UIView
から継承しただけでなく、今回新たに追加した LayoutControllable
というプロトコルに準拠しました。基本的に具体的なレイアウトのやり方は全て LayoutControllable
の extension
で実装しましたので、これにより 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
には layoutInfo
と zIndexInfo
というプロパティーがあります、中にはそれぞれ子ビューのレイアウトに必要な情報([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
を求めます。
ただし注意しなければならないのは layoutMethods
は LayoutMethod
の配列ですので、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
があります。 -
absolute
はLayoutView
のサイズと関係なくCGRect
の数値のみでレイアウトを決めます。例えば.absolute(CGRect(x: 10, y: 20, width: 30, height: 40))
を使えば、LayoutView
のサイズ関係なく指定の子ビューのframe
は常にCGRect(x: 10, y: 20, width: 30, height: 40)
になります。 -
relative
のCGRect
の数値は全てLayoutView
のサイズから見た相対的な大きさで、例えば.relative(CGRect(x:0.1, y: 0.2, width: 0.3, height: 0.4))
、LayoutView
のbounds.size
はCGSize(width: 100, height: 200)
の場合、指定の子ビューのframe
はCGRect(x: 100 * 0.1, y: 200 * 0.2, width: 100 * 0.3, height: 200 * 0.4)
になります。 -
insets
はLayoutView
から見た余白の指定になります。例えばinsets
はUIEdgeInsets(top: 10, left: 20, bottom: 30, right: 40)
、LayoutView
のbounds.size
はCGSize(width: 100, height: 200)
の場合、指定した子ビューのframe
はCGRect(x: 20, y: 10, width: 100 - 20 - 40, height: 200 - 10 - 30)
になります -
offset
は指定した基準点からのオフセットとサイズで指定した子ビューのframe
が決まります。基準点であるOffsetOrigin
は.topLeft
、.topCenter
等の 9 パターンがあります。例えばここでvalue
がUIOffset(horizontal: -10, vertical: 10)
、from
が.topRight
、size
がCGSize(width: 30, height: 40)
で、LayoutView
のbounds.size
がCGSize(width: 100, height: 200)
の場合、指定した子ビューのframe
はCGRect(x: 100 - 30 + (-10), y: 10, width: 30, height: 40)
になります。 -
customByXXXX
はその都度LayoutView
のbounds.size
をクロージャーに渡して必要なフレームを求める非常に自由度の高い指定法です。違うのは戻り値はCGRect
なのか、それともCGRect
の構成要素なのかです。なぜこんな風に作ったかというと物によってはframe
ではなくx, y, width, height
の方が書きやすかったりする場合があるからです。また、クロージャーを用いるので非常に自由度は高いですが、逆にこれはlayoutInfo
に格納されているので、必要に応じてキャプチャーリストを入れてあげないとメモリリークになりますのでご注意を。
また、position
に使う CGRect
は frame
プロパティーと同じように、左上の原点座標とサイズを記述する書き方ですが、中身は直接 frame
プロパティーにではなく、bounds
と center
にそれぞれ適用していますので、transform
が入っても問題なく表示できます(多分…)
直接 [LayoutMethod]
を書くのは物によってソースコードが煩雑化する可能性が高いので、便利なメソッドもいくつか用意してあります:
-
setLayoutMethods(_ methods: [LayoutMethod], for subview: UIView)
はsubview
にmethods
を関連づけ、もし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
に準拠させる場合は layoutInfo
と zIndexInfo
の格納プロパティーを作って、レイアウトメソッド内に(UIView
なら layoutSubviews()
)layoutControl()
を呼び出せば OK です。LayoutView
の実装を読んでみればわかると思います。
なお、zIndex
についてですが、これは UIKit
は zIndex
や zPosition
といった奥行き情報に対応していないため、基本手動で reloadSubviews()
を呼び出さないと zIndexInfo
の情報が反映されません。なのでどっちかというと子ビューを追加するときに順番通りに addSubview
もしくは insertSubview
などで入れることがオススメです。reloadSubviews()
を呼び出すと、現在の LayoutView
に追加された全ての subvuews
を一旦 remove
して、そして zIndexInfo
からそれら奥行き情報を参照し(なければデフォルト値の 0 とみなす)、小さい順でソートし直し(Int
型なのでマイナスの値を与えるとデフォルト値より奥に配置されます)、ソートされた順番でもう一度 addSubview
されます。
使用例
例えば、下記の図のように、ラベルをキャンバスの幅の 20% の正方形、また右上に 10 ピクセルの余白をつけたい場合
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()
をオーバーライドして view
を LayoutView
にすればいいでしょう。
また、計算量が増えるにもかかわらず、LayoutView
にレイアウト条件を入れるのではなく、子ビューにレイアウト条件を入れるのはその方が子ビューにとってもっとレイアウトの自由度が増えるし(例えば特定な子ビューを画面サイズだけでなく他の条件を応じたレイアウトも可能)、子ビューの要素がそこまで増えなければ 0.01 秒の誤差なんて無視しても問題ないからです。それに LayoutView
をネストすることも可能ですので、ネストをうまく使えばレイアウト条件の計算を減らすことも可能かと思います。
あとがき
relative
と absolute
の単語は CSS からパクってきたけど意味としては CSS のそれとは違うので微妙に気持ち悪いっちゃ悪い。何かもっとふさわしい単語のオススメがあれば教えていただきたいです。
他にももし何か質問あれば気軽にどうぞ。
GitHub で Star くれるととても喜びます。