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!!"
    }
}

結果: f:id:y1soga1:20161212062156p:plain

あまりよろしくないレイアウトをしていますが、機能の例としてご覧ください。

コードの階層化

上記の例では、

  • 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 をコードで書く手法は、弊社としてもまだまだ試行錯誤を続けている段階です。またアップデートがあれば記事にできればと思います。 それでは、今回もお読み頂きありがとうございました!