"弊社 Advent Calendar 2017 2日目" の記事、"StoryboardのBestPracticeについて考える(1) 〜Storyboardの分割〜" で、Storyboardが抱えるいくつかのデメリットと、そのうち1つの解決案である "Storyboardの分割" を紹介させてもらった。

今回は "StoryboardのBestPracticeについて考える" の第2弾として、"ビューの再利用" を提案したいと思う。

"StoryboardのBestPracticeについて考える(1)" の記事で述べたように、Stroyboardには以下のデメリットが挙げられる。

  1. Storyboardの肥大化
  2. チーム開発に不向き
  3. 汎用性, 柔軟性に欠ける
  4. コンパイル時に問題が検出できない

本記事では、"3. 汎用性, 柔軟性に欠ける"の解決案を紹介したい。

ビューの再利用

以下のStoryboardのように、"List" における "ComicView" と "Detail" における "ComicView" はまったく同じ構成になっている。一般的には、Storyboard上で対象のコンポーネントをコピー&ペーストして配置する方法をとるだろう。しかしこの方法だと、ペースト先でも "IBOutlet" や "IBAction" などの関連付けの設定を行わなければならなず、多画面にわたって同様のUIが存在する場合やもっと複雑な構成のビューだった場合の作業量は比例的に増えていく。さらに、"ComicView" のUI変更があった際には、すべての該当箇所に修正を加えなければならず、メンテナンスコストの増加やバグの温床になりかねない。コードでUIを作成していた場合は、"ComicView" をクラス化して汎用的にすることができたのに!、と感じたことだろう。

スクリーンショット 2017-12-06 17.14.03.png

というわけで、コードによる汎用性とIB(Interface Builder)のメリット、両者のいいとこ取りしたビューの再利用の方法を紹介しよう。

CustomViewクラスの作成

IBと連携できるUIViewのカスタムクラスを作成する。

CustomView.swift

import UIKit

@IBDesignable class CustomView: UIView {

    override init(frame: CGRect) {

        super.init(frame: frame)

        loadFromNib()

    }

    required init?(coder aDecoder: NSCoder) {

        super.init(coder: aDecoder)

        loadFromNib()

    }

    /// IBから読み込まれる時に呼ばれる
    override func prepareForInterfaceBuilder() {

        loadFromNib()

    }

    /// Xibファイルからビューを読み込む
    private func loadFromNib() {

        let nib = UINib(nibName: String(describing: type(of: self)), bundle: Bundle(for: type(of: self)))
        let view = nib.instantiate(withOwner: self, options: nil).first as! UIView

        view.frame = bounds
        addSubview(view)

        view.translatesAutoresizingMaskIntoConstraints = false
        addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "H:|[view]|", options: NSLayoutFormatOptions(rawValue: 0), metrics: nil, views: ["view": view]))
        addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "V:|[view]|", options: NSLayoutFormatOptions(rawValue: 0), metrics: nil, views: ["view": view]))

    }

}


ポイントとしては、

  1. "@IBDesignable"でクラスを宣言する
    "@IBDesignable"をクラス名の前につけることで、IB上で設置されたコンポーネントの "Custom Class" の "Class" に指定された場合に、UIをIB上にレンダリングさせることができるようになる。

  2. Xibファイルからビューを読み込む処理(loadFromNib())を実装する
    "UINib" を生成する時の "nibName" に、"String(describing: type(of: self)" を指定することで、自身のクラス名と同名のXibファイルをロードすることができる。また、"@IBDesignable" に対応するために、"Bundle"を生成する際の "for" にも "type(of: self)" を指定している。さらに、Xibファイルから読み込んだビューを自身と同じサイズとするレイアウト制約を追加することで、自身のAutoLayoutに対応している。

  3. 初期化処理の中で "loadFromNib()" を呼ぶ
    "init(frame: CGRect)", "init?(coder aDecoder: NSCoder)"、それぞれの初期化処理の中で "loadFromNib()" を呼ぶことで、"コードから初期化された場合", "IBから初期化された場合"に対応している。"prepareForInterfaceBuilder()" は、"@IBDesignable"が指定されたクラスにおいて、IB上にレンダリングする際に呼ばれる。

"CustomView" を継承した、再利用したいビューのクラスを作成

ComicView.swift

import UIKit

class ComicView: CustomView {

    @IBOutlet weak var coverImageView: UIImageView!
    @IBOutlet weak var titleLabel: UILabel!
    @IBOutlet weak var authorLabel: UILabel!
    @IBOutlet weak var descriptionLabel: UILabel!

}


"CustomView" を継承した上で、"IBOutlet" や "IBAction" でIB上のコンポーネントと関連付けしたいプロパティやメソッドを定義する。

再利用したいビューのUIをXibで管理

再利用したいビューをStoryboardからXibに切り出す。このとき重要なのは、Xibのファイル名を先に作成したクラスのクラス名と同名にすることである。(サンプルでは "ComicView.xib" )

スクリーンショット 2017-12-06 17.15.30.png

"Files Owner" の "Custom Class" の "Class" に、先に作成したクラスを指定し、各コンポーネントをクラスのプロパティ, メソッドと関連付けする。

Storyboard上でカスタムビューを配置する

スクリーンショット 2017-12-06 18.25.06.png

Storyboard上で、再利用したいビューの位置に "UIView" コンポーネントを配置し、"Custom Class" の "Class" に "ComicView" を指定する。すると、"ComicView.xib" で作成したUIが、Storyboard上にレンダリングされるはずである。また、"ComicView" を配置したそれぞれのクラスにおいて、"ComicView" を プロパティとして "IBOutlet" で関連付けしておけば、"ComicView" のプロパティである "titleLabel" や "authorLabel" にもアクセスすることができ実用性も高い。

終わりに

結局、Storyboardにおけるビューの再利用には、多少なりともコードを書かなければならないのだが、"CustomView" クラスさえ作ってしまえば、あとは再利用したいビューのクラスとXibを用意するだけなので、そこまで手間にはならないハズ!…である。なによりも、Storyboardにおけるビューを汎用化できることに価値があるハズ!…である。