iOS
Swift

StoryboardからUIViewControllerを生成するボイラープレートコード(お約束のコード)をなくす

More than 1 year has passed since last update.

はじめに

StoryboardからUIViewControllerの派生クラスを生成する処理について考えます。
難しくはないものの、似たようなコードをたくさん書きたくないです😑

ベタに生成すると以下のようになります。

let storyboard = UIStoryboard(name: "FileName", bundle: Bundle(for: ViewController.self))
let viewController = storyboard.instantiateViewController(withIdentifier: "identifier") as! ViewController
// もしくは
// let viewController = storyboard.instantiateInitialViewController() as! ViewController

生成には以下の4つの要素が必要そうです。

  • Storyboardのファイル名
  • Storyboardが存在するBundle
  • UIViewControllerに設定されたIdentifier(もしくはinitialとして生成する)
  • UIViewController派生クラスのクラス型

ViewControllerを作る側も使う側も、これらをあまり意識せず気軽にかけるものを目指します。
また使う側は!を書かないで済むようにしたいです。

お約束をなくしたコード

場面に応じて使い分ける2種類の部品を作ってみました。

ViewControllerとStoryboardは 1:1 の場合

// InstantiableFromStoryboardを採用する
class MainViewController: UIViewController, InstantiableFromStoryboard {
}

func instantiate() {
    let c: MainViewController = MainViewController.instantiate()
}

または

// InstantiableFromStoryboardを採用する
class MainViewController: UIViewController, InstantiableFromStoryboard {

    // StoryboardとViewControllerが同じBundle内に存在する場合省略化
    public static func searchBundle() -> Bundle? {
        return Bundle(identifier: "bundle.identifier")
    }

    // Storyboardのファイル名とViewControllerのクラス名が同じ場合省略化
    public static var storyboardName: String {
        return "FileName"
    }

    // Is Initial View Controllerを生成する場合省略化
    public static var defaultIdentifier: String? {
        return "Identifier"
    }
}

func instantiate() {
    let c: MainViewController = MainViewController.instantiate()
}
  • ひとつのViewControllerにひとつのStoryboardがある。
  • Storyboardのファイル名とViewControllerのクラス名が同じ。
  • Is Initial View Controllerになっている。
  • StoryboardとViewControllerが同じBundle内に存在する。

これらを全て満たす場合は InstantiableFromStoryboard を書くだけで済みます👍

ViewControllerとStoryboardは 1:N の場合

extension Bundle {
    open class var sub: Bundle {
        return Bundle(identifier: "bundle.identifier.Sub")!
    }
}

extension Storyboard {
    static var sub = Storyboard("SubViewController", bundle: .sub)
    static var subCustom = Storyboard("SubCustomViewController", bundle: .sub)
}

public class SubViewController: UIViewController {

    public static let another = StoryboardLoader<SubViewController>(.sub, identifier: "Another")
    public static let custom = StoryboardLoader<SubViewController>(.subCustom)
}

func instantiate() {
    let c1: SubViewController = SubViewController.another.instantiate()
    let c2: SubViewController = SubViewController.custom.instantiate()
}

「使う場所に応じて複数のStoryboardがある」「iPhoneとiPadで違うStoryboardを用意している」といった状況を想定しています。

ライブラリのコード

ViewControllerとStoryboardは 1:1 の場合

public protocol BundleSearchable {

    static func searchBundle() -> Bundle?
}

extension BundleSearchable {

    public static func searchBundle() -> Bundle? {
        if let anyClass = self as? AnyClass {
            return Bundle(for: anyClass)
        } else {
            return nil
        }
    }
}

Bundleを探します。デフォルト実装ではBundle.init(for:)を使っています。
UIViewControllerの派生クラスに限れば必ずClassですが、structやenumでもおかしくならないように処理を分けています。structやenumからもBundle取得できるようになってほしい😓

public protocol InstantiableFromStoryboard: BundleSearchable {

    associatedtype VCType = Self

    static var storyboardName: String { get }
    static var defaultIdentifier: String? { get }

    static func instantiate() -> VCType
}

はじめにで記載した4つの要素を BundleSearchable, VCType, storyboardName defaultIdentifier で取得する想定です。

extension InstantiableFromStoryboard {

    public static var storyboardName: String {
        return String(describing: VCType.self)
    }

    public static var defaultIdentifier: String? {
        return nil
    }

    public static func instantiate() -> VCType {
        let storyboard = UIStoryboard(name: storyboardName, bundle: searchBundle())

        if let identifier = defaultIdentifier {
            return storyboard.instantiateViewController(withIdentifier: identifier) as! VCType
        } else {
            return storyboard.instantiateInitialViewController() as! VCType
        }
    }
}

デフォルト実装では、Storyboardのファイル名をクラス名から生成します。
デフォルト実装では、identifierはnilです。nilの場合、identifierを指定せずinstantiateInitialViewController()を使ってViewControllerを生成します。

ViewControllerとStoryboardは 1:N の場合

public struct Storyboard {
    public init(_ name: String, bundle: Bundle? = nil) {
        self.name = name
        self.bundle = bundle
    }
    public var name: String
    public var bundle: Bundle?

    public func instantiate<VCType>(_ vcType: VCType.Type = VCType.self, identifier: String? = nil) -> VCType? {
        if let identifier = identifier {
            return UIStoryboard(name: name, bundle: bundle).instantiateViewController(withIdentifier: identifier) as? VCType
        } else {
            return UIStoryboard(name: name, bundle: bundle).instantiateInitialViewController() as? VCType
        }
    }
}

特定のStoryboardを表す構造体です。はじめにで記載した4つの要素のうちの2つ

  • Storyboardのファイル名
  • Storyboardが存在するBundle

を表します。

この構造体は使用せず、後述するStoryboardLoaderに同等の機能を混ぜ込むことも考えました。ですがひとつのStoryboardに複数のViewControllerが入っていると、ファイル名を複数箇所にベタ書きせざるをえなくなるためこの構造体を用意しました。

public struct StoryboardLoader<VCType: UIViewController> {
    let storyboard: Storyboard
    let identifier: String?

    public init(_ storyboard: Storyboard, identifier: String? = nil) {
        self.storyboard = storyboard
        self.identifier = identifier
    }

    // Storyboard構造体の定義を横着するとき用
    public init(storyboardName: String, identifier: String? = nil, bundle: Bundle? = nil) {
        self.storyboard = Storyboard(storyboardName, bundle: bundle ?? Bundle(for: VCType.self))
        self.identifier = identifier
    }

    public func instantiate() -> VCType {
        return storyboard.instantiate(VCType.self, identifier: identifier)!
    }
}

この構造体はStoryboard構造体と、はじめにで記載した4つの要素のうちの2つ

  • UIViewControllerに設定されたIdentifier(もしくはinitialとして生成する)
  • UIViewController派生クラスのクラス型

を使ってViewControllerを生成します。

おわりに

ViewControllerの型を起点にするように作りましたが

  • Storyboardを起点に生成する
  • ViewControllerとStoryboardを混ぜ合わせて生成する

という考え方もありそうです。

自分が使う上で困らないものができ満足していますが、皆さんはどうしていますか🤔
Storyboardは使わない、R.swiftを導入するという人も多そうですね。