LoginSignup
14
15

More than 5 years have passed since last update.

巨大なビューをStoryboardだけで表現する

Last updated at Posted at 2018-12-17

iOSアプリの画面を実装する場面において、

  1. 映画作品の詳細を紹介する画面
  2. Photoshopのような、複数レイヤで構成される画像の編集画面

このような多数のUIコンポーネントを配置しなければいけない画面があったとき、あなたはどのようにレイアウトを構築しますか?

多くの方はUIコンポーネント群を、何かの単位(1.の例であれば詳細画面のセクションごとなど)で分類し、場合によってはその中も再分割を行うことで、構造的に分解することを考えるのではないかと思います。

ここではその一例として、StoryboardContainer Viewを使った構造化された画面設計について考えてみたいと思います。

サンプルコード: StructuredViewControllerSample

Container Viewを使ってビュー(コントローラ)を構造化する

Storyboardを使うと、Container Viewというコンポーネントを利用することで、親子関係を持つビューコントローラ(以下、VCと略します)を作ることができます。これはつまり、多数のUIコンポーネントを要する画面を、複数のビューコントローラに分解して管理することができることを表しています。

Container ViewのSelf-sizing

Container Viewは、何もしないと自身に付けられた大きさの制約に従ってレイアウトされます。中の子VCが期待するサイズにSelf-sizingしたい場合は、以下を行う必要があります。

  • 子VCのview.translatesAutoresizingMaskIntoConstraintsfalseを設定する
ChildViewController.swift
class ChildViewController: UIViewController {
    override func loadView() {
        super.loadView()
        self.view.translatesAutoresizingMaskIntoConstraints = false
    }

    ...
}
  • Container View自体に付与している大きさの制約を削除する
    • Interface Builder上で制約のエラーが発生する場合、Intrinsic SizePlaceholderに変更する

画面の一部を「リスト」化するには

例えば、ある映画作品の詳細画面にユーザレビューを最大3件表示する必要があったとき、Container Viewの集合で作られた画面ではどのように実現したらいいでしょうか?

  • 3つのContainer Viewを作り、その参照先をすべてレビューを表示するVCに向ける
  • 要素の数だけ伸張する「スクロールしないTable View」を作る

2〜3件程度であれば前者の方法でも良さそうです(サンプルコードの「セクション」はこの方法で実装)が、件数が不定の場合は後者の方法が最適でしょう。

Scrolling Enabled がオフのとき、全てのセルが表示される大きさに伸長するようなTable Viewを作るには、以下のような継承クラスを定義するといいでしょう。

IntrinsicTableView.swift
class IntrinsicTableView: UITableView {
    override var contentSize: CGSize {
        didSet {
            if !self.isScrollEnabled {
                self.invalidateIntrinsicContentSize()
            }
        }
    }

    override var intrinsicContentSize: CGSize {
        if self.isScrollEnabled {
            return super.intrinsicContentSize
        } else {
            self.layoutIfNeeded()
            return CGSize(
                width: UIView.noIntrinsicMetric,
                height: self.contentSize.height
            )
        }
    }
}

参考: https://stackoverflow.com/questions/2595118/resizing-uitableview-to-fit-content/48623673#48623673

Container Viewの参照先のロードを遅延させるには

Container Viewは、hiddenを設定していたとしても、参照先の子VCは必ずロードされてしまいます。コンテンツによって構成が変わるような画面では、効率が悪くパフォーマンスも低下します。

この子VCの読み込みは、 shouldPerformSegue(withIdentifier:sender:) で制御することができます。そして遅延させた子VCを読み込む場合は performSegue(withIdentifier:sender:) を明示的に呼び出します。

ContentViewController.swift
    override func shouldPerformSegue(withIdentifier identifier: String, sender: Any?) -> Bool {
        if identifier == "onDemandView" {
            return false
        } else {
            return true
        }
    }

    ...

    func loadPendingViews() {
        // 読み込み済のSegue Identifierを指定するとクラッシュするので要
注意
        self.performSegue(withIdentifier: "onDemandView", sender: self)
    }

より実践的な課題

子VCから、オーナーVCを参照するには

すべての子・孫VCを包含する、オーナーVCを特定するには、子・孫VCから self.parent を辿っていけば良さそうなのですが、self.parentnil を返す self がオーナーVCとは限りません。オーナーVCが、 Navigation ControllerTab Bar Controller 等に包含されている場合があるためです。

そこで、子・孫VCであることを宣言するプロトコルを定義し、オーナーVCを容易に参照できる拡張を追加します。

StructuredProtocol.swift
protocol StructuredChildProtocol: StructuredProtocol {
    associatedtype OwnerViewController: UIViewController
}

extension StructuredChildProtocol where Self: UIViewController {
    var ownerViewController: OwnerViewController? {
        return self.parent(of: OwnerViewController.self)
    }

    private func parent<VC: UIViewController>(of type: VC.Type) -> VC? {
        var parent = self.parent
        while parent != nil {
            if let typedParent = parent as? VC {
                return typedParent
            }
            parent = parent?.parent
        }

        return nil
    }
}

子VCから親VCを参照するときの注意点

子VCの loadView() は、親VCの prepare(for:sender:) の後に呼び出されるため、親から子へのデータの受け渡しは比較的問題なく行えます。一方、子VCから親やオーナーVCが持つデータの参照はやや難しくなります。

その理由が、 loadView(), viewDidLoad()の呼び出される順序と、 self.parent が参照できるタイミングです。

  • loadView(): 親→子→孫の順
  • viewDidLoad(): 孫→子→親の順
  • loadView(), viewDidLoad() の時点では self.parentnil を返す
    • didMove(toParentViewController:) がコールされた後に self.parent が設定される。但しこのメソッドも孫→子の順に呼ばれるため、self.parent?.parentnil となる。

以上のことから、これらのライフサイクル・コールバックにおいてはVC階層は完成しておらず、子VCにおいて、先の ownerViewController の参照がうまくできません。

解決策として、先の StructuredChildProtocol プロトコルを以下のように変更した上、オーナーVCも StructuredProtocol プロトコルへ準拠させます。

StructuredProtocol.swift
protocol StructuredProtocol: class {
    func configureSelf()
}
typealias StructuredViewController = UIViewController & StructuredProtocol

extension StructuredProtocol where Self: UIViewController {
    func configure() {
        self.configureSelf()

        self.children
            .compactMap { $0 as? StructuredViewController }
            .forEach { $0.configure() }
    }

    func configureSelf() {}
}

protocol StructuredChildProtocol: StructuredProtocol // class から変更 {
    associatedtype OwnerViewController: UIViewController
}

// ほかは変更なし

こうすると、オーナーVCの viewDidLoad() 内で configure() を呼び出すことで、全ての子・孫VCのセットアップ処理を指示することができます。子・孫VCは configureSelf() の中で ownerViewController を参照することができます。

親・子VC間の依存関係とやりとりの検討事項

採用するアーキテクチャにもよりますが、さらに以下の観点で設計すると良いでしょう。

  • 子VCは極力親に依存せず、自身の責務を果たすようにする
  • 同様に親VCは子に機能や振る舞いを委譲する
  • とはいえ、多くの場合は関連性のあるものを扱うはずなので、無理に低依存にする必要もない
  • prepare(for segue:, sender:)でコンテキスト(ViewModelやPresenter)を渡す
    • あるイチ機能に対応する画面を分解したのであれば、その全体を表すVMやPresenterで考えてもよさそう
  • 子の変化は親VCのメソッドを直接呼ぶのではなく、Delegateや共有したVMの監視などで行う

XIBを使う必要があるケース

StoryboardとContainer Viewを用いたレイアウトの構造的表現は、実現したい複雑な画面を要素分解する場合に向いていると言えます。一方XIBは、以下のような場合に使う必要があるでしょう。

  • 共有するTable View CellやCollection View Cellがある
  • Table Header/Footer View
  • Table View CellとVCへの埋め込みのどちらにも利用できるレイアウトを作りたいとき
  • Storyboardの同時編集で競合を避けたいとき
14
15
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
14
15