iOSアプリのUIをコードで書いてみる話 - Re.Ra.Ku アドベントカレンダー day 12
Re.Ra.Ku アドベントカレンダー 12日目です。
こんにちは、磯貝です。前回は Sketch.app のプラグインについての記事でした。
今回は、iOS アプリの UI をコードで書く際の知見をいくつか紹介します。
UI をコードで書く利点
iOS アプリ開発において UI を構築する手段として一般的なのは、Xcode の Interface Builder を用いたものでしょう。優れた GUI ツールにより、実行時の見た目を即座に確認しながら、豊かな表現を行うことができます。
しかし現在弊社で開発されている iOS アプリは、ほぼ全てにおいて UI をコードにより実装しています。
この方法には、以下のような利点があります:
- ファイルの変更点が確認しやすく、レビューを行いやすい
- nib の xml のように、理不尽な diff が発生することもなく、conflict も起こりづらいです。
- View の使い回しをしやすい
- Xib として View を定義した場合、それを他の Xib や Storyboard から利用するには、結局コードによる初期化が必要です。最初からピュアな UIView のサブクラスとして View を定義すれば、オーバーヘッド無しで再利用可能です。
- デザインや文言を一貫して管理できる
- 定数やユーティリティクラスを用いて、色、フォント、マージンといったデザイン要素や、各種文言、そのローカライズを一貫して管理できます。
- ViewController の初期化を厳密に行うことができる
- Segue を用いた画面遷移を行ったり、Storyboard から ViewController を初期化する場合、値の受け渡しは新しい ViewController インスタンスのプロパティへ値をセットする形で行います。しかしこの方法では値の受け渡しを強制することはできず、すべての Optional なプロパティに対して何らかの対処をする必要があります。UI をコードで記述すると、ViewController の初期化はイニシャライザを用いて行うことができるため、そこで適切に値を渡せばこのような問題は起こりません。また、ViewController 同士が疎結合となり、再利用が容易になります。
// Storyboard から初期化する場合 class ArticleViewController: UIViewController { var id: ArticleId! var reader: Reader? ... } let articleVC = UIStoryboard(name: "Article", bundle: nil).instantiateViewController(withIdentifier: "Article") articleVC.id = articleId articleVC.reader = reader // イニシャライザを用いる場合 class ArticleViewController: UIViewController { let id: ArticleId let reader: Reader? init(id: ArticleId, reader: Reader?) { // 実際は ApplicationService 等へ渡していく self.id = id self.reader = reader super.init(...) ... } ... } let articleVC = ArticleViewController(id: articleId, reader: reader)
一方、次のような欠点もあります:
- 実際にビルドして実行するまで、見た目を確認できない
- Size Classes への対応が難しい (すみません、調べきれませんでした)
特に Size Classes はどうしても必要となることもあるでしょう。要件に応じて利点欠点を天秤にかけつつやっていくしかなさそうです。
(余談ですが、Size Classes に関しては、Web におけるレスポンシブで行くか別テンプレートでいくか、みたいな判断にも似ているなーと感じます。)
UI の書き方 Tips
いくら利点があるとはいっても、View を愚直にコードで書き起こしていくのは骨が折れる作業です。
それを解決するために弊社で利用しているライブラリと、いくつかの実装を紹介します。
SnapKit
SnapKit は、Auto Layout をシンプルな DSL を用いて記述できるライブラリです。
例として、画面中央に緑の枠線を表示し、その中にラベルとボタンを表示するサンプルを用意しました。
class SampleViewController: UIViewController { weak var label: UILabel! override func loadView() { view = UIView() view.backgroundColor = UIColor.white let container = UIView() view.addSubview(container) container.layer.borderWidth = 5 container.layer.borderColor = UIColor.green.cgColor // SnapKit container.snp.makeConstraints { make in make.centerY.equalTo(view) make.left.right.equalTo(view).inset(20) make.height.equalTo(view).multipliedBy(0.5) } let label = UILabel() container.addSubview(label) self.label = label label.font = UIFont.boldSystemFont(ofSize: 20) label.textColor = UIColor.red label.textAlignment = .center label.text = "Some Text!!" // SnapKit label.snp.makeConstraints { make in make.top.left.right.equalTo(container).inset(30) } let button = UIButton() container.addSubview(button) button.backgroundColor = UIColor.lightGray button.setTitle("OK", for: .normal) button.addTarget(self, action: #selector(self.buttonTapped(_:)), for: .touchUpInside) // SnapKit button.snp.makeConstraints { make in make.top.equalTo(label.snp.bottom).offset(30) make.left.right.equalTo(container).inset(20) make.bottom.equalTo(container).inset(20).priority(UILayoutPriorityDefaultLow) make.height.greaterThanOrEqualTo(44) } } @objc private func buttonTapped(_: UIButton) { label.text = "tapped!!" } }
結果:
あまりよろしくないレイアウトをしていますが、機能の例としてご覧ください。
コードの階層化
上記の例では、
view
container
label
button
という View の親子/兄弟関係がありますが、すべてベタ書きしているため、なんとも構造がわかりづらくなってしまいます。
そこで、以下のような Extension を導入します。
protocol ViewConstructable {} extension ViewConstructable { func addSubview<Sub: UIView, Super: UIView>(_ subview: Sub, toSuperview superview: Super, initializer: (Sub, Super) -> Void) -> Sub { superview.addSubview(subview) initializer(subview, superview) return subview } }
これを用いることで、先程のサンプルは以下のように書き直すことができます:
class SampleViewController: UIViewController, ViewConstructable { weak var label: UILabel! override func loadView() { constructView() } private func constructView() { view = UIView() view.backgroundColor = UIColor.white let /* container */ _ = addSubview(UIView(), toSuperview: view) { sb, sp in sb.layer.borderWidth = 5 sb.layer.borderColor = UIColor.green.cgColor sb.snp.makeConstraints { make in make.centerY.equalTo(sp) make.left.right.equalTo(sp).inset(20) make.height.equalTo(sp).multipliedBy(0.5) } label = addSubview(UILabel(), toSuperview: sb) { sb, sp in sb.font = UIFont.boldSystemFont(ofSize: 20) sb.textColor = UIColor.red sb.textAlignment = .center sb.text = "Some Text!!" sb.snp.makeConstraints { make in make.top.left.right.equalTo(sp).inset(30) } } let /* button */ _ = addSubview(UIButton(), toSuperview: sb) { sb, sp in sb.backgroundColor = UIColor.lightGray sb.setTitle("OK", for: .normal) sb.addTarget(self, action: #selector(self.buttonTapped(_:)), for: .touchUpInside) sb.snp.makeConstraints { make in make.top.equalTo(label.snp.bottom).offset(30) make.left.right.equalTo(sp).inset(20) make.bottom.equalTo(sp).inset(20).priority(UILayoutPriorityDefaultLow) make.height.greaterThanOrEqualTo(44) } } } } @objc private func buttonTapped(_: UIButton) { label.text = "tapped!!" } }
クロージャ内で child view を生成することにより、コードのインデントと View の階層を一致させることができました。
ViewController と View のファイルを分割する
上の例では、ViewController 内で UI を構築しています。これではファイルが肥大化しますし、ViewController と View の責務を適切に分割できません。
そこで、以下のような ViewController の基底クラスを用意してみました。
class ViewController<ViewType: UIView>: UIViewController { private var tempView: ViewType? weak var v: ViewType! convenience init() { self.init(view: ViewType()) } convenience init(view: ViewType) { self.init(nibName: nil, bundle: nil) tempView = view } override func loadView() { view = tempView v = tempView tempView = nil } }
これを用いることで、先程のサンプルを以下のように書き直すことができます:
class SampleView: UIView, ViewConstructable { weak var label: UILabel! weak var button: UIButton! required init?(coder aDecoder: NSCoder) { fatalError() } init() { super.init(frame: CGRect.zero) constructView() } private func constructView() { backgroundColor = UIColor.white let /* container */ _ = addSubview(UIView(), toSuperview: self) { sb, sp in sb.layer.borderWidth = 5 sb.layer.borderColor = UIColor.green.cgColor sb.snp.makeConstraints { make in make.centerY.equalTo(sp) make.left.right.equalTo(sp).inset(20) make.height.equalTo(sp).multipliedBy(0.5) } label = addSubview(UILabel(), toSuperview: sb) { sb, sp in sb.font = UIFont.boldSystemFont(ofSize: 20) sb.textColor = UIColor.red sb.textAlignment = .center sb.text = "Some Text!!" sb.snp.makeConstraints { make in make.top.left.right.equalTo(sp).inset(30) } } button = addSubview(UIButton(), toSuperview: sb) { sb, sp in sb.backgroundColor = UIColor.lightGray sb.setTitle("OK", for: .normal) sb.snp.makeConstraints { make in make.top.equalTo(label.snp.bottom).offset(30) make.left.right.equalTo(sp).inset(20) make.bottom.equalTo(sp).inset(20).priority(UILayoutPriorityDefaultLow) make.height.greaterThanOrEqualTo(44) } } } } } class SampleViewController: ViewController<SampleView> { override func loadView() { super.loadView() v.button.addTarget(self, action: #selector(self.buttonTapped(_:)), for: .touchUpInside) } @objc private func buttonTapped(_: UIButton) { v.label.text = "tapped!!" } }
View は UI の構築のみに注力し、イベントの受け取りや View の更新は ViewController が行っています。
まとめ
いかがでしたでしょうか。
UI をコードで書く手法は、弊社としてもまだまだ試行錯誤を続けている段階です。またアップデートがあれば記事にできればと思います。
それでは、今回もお読み頂きありがとうございました!