Edited at
SpeeeDay 8

再利用可能なContainer View Controllerの作り方

More than 1 year has passed since last update.

この記事は Speee Advent Calendar 2017 8日目の記事です。

7日目は @mogmog2 による、 ブログを継続運用するために必要な2つのこと でした。

アドベントカレンダーがちゃんと毎日続いていて、ここらで一度切っておかないと後の人がプレッシャーを感じるのではと思ったのですが、怒られたのでちゃんと書きます :sushi:


はじめに

この記事では、Container View Controllerを実装する際に、どのように再利用性を高めるかについて説明します。

ひとことで説明するとそれは iOS View Controllerプログラミングガイド を遵守するということになるのですが、実際の業務では全て理想通り行くとは限りません。


  • 一度実装した Container View Controller をアプリの他の場所や他のプロジェクトで使用できない

  • 外観関連メソッド1が呼ばれない/呼ばれるタイミングがおかしい

といった問題に悩まされる、あるいは外部ライブラリにそのような問題があり困った経験は誰しもあるのではないでしょうか。

この記事では具体的な例を挙げて、これらの問題を解消する方法を説明します。


再利用可能とは

Container View Controllr はアプリケーションコードへの依存を少なくしやすく、別アプリで再利用したり、単体でOSSとして公開することも容易です。

そういった Container View Controller はUIKitが提供している UINavigationControllerUITabBarController と同様に使用でき、条件としては以下のものを満たします。


  • View Controller Hierarchyに乗っている

  • 外観関連メソッド、画面回転時のメソッドが適切なタイミングで呼び出される

  • Container View Controller と Child View Controller が独立している


View Controller Hierarchyに乗せる

Hierarchy に乗っているとはどういうことかというと、View Controllerの parentpresentingViewController を再帰的に辿ると UIApplication.shared.keyWindow?.rootViewController まで行き着くことができる、ということです。

外観関連メソッドや画面回転時のメソッドは全てこの Hierarchy を辿って流れてくるので、きちんと追加しておく必要があります。

ここに関してはまりポイントはないのでドキュメントを参照して子 View Controller としてきちんと追加するだけです。


外観関連メソッド、画面回転時のメソッドを適切なタイミングで呼び出す

外部ライブラリを使用した際に、viewWillAppear などの呼び出しタイミングがおかしくて困る、という経験をした人は多いのではないかと思います。

今回ははまることが多い、外観関連メソッドを自動で呼び出している場合( shouldAutomaticallyForwardAppearanceMethods == true の状態)について説明します。

そもそも 子 View Controller の viewDidLoadviewDidAppear を直接呼び出すのは論外としても、以下のように愚直に実装しても問題が起きることがあり、問題回避のためのコードを書いているうちにこんがらかってしまう状況をしばしば目にします。


愚直な実装

class SomeContainerController: UIViewController {

@IBOutlet private var containerView: UIView! // 子のviewを追加するためのview

var contentViewController: UIViewController? {
didSet {
guard let contentViewController = contentViewController else {
return
}

// 新たな子を追加
addChildViewController(contentViewController)
contentViewController.view.frame = containerView.bounds // (a)
containerView.addSubview(contentViewController.view)
contentViewController.didMove(toParentViewController: self)
}
}
}

このコードを見てください。

これはひとつの 子 View Controller をもつ Container View Controller のソースで一応動きますが、以下のような問題を抱えています。



  1. contentViewController を入れ替える際に古い View Controller が View Controller Hierarchy から削除されていない

  2. viewがロードされる前に contentViewController をセットすると containerView が存在しないために unexpectedly found nil エラーが発生する(a)

これらの問題を解消するために、コードを以下のように変更します。

class SomeContainerController: UIViewController {

@IBOutlet private var containerView: UIView! // 子のviewを追加するためのview

var contentViewController: UIViewController? {
didSet {
// 問題1の対応
// セットされていた子を削除
oldValue?.willMove(toParentViewController: nil)
oldValue?.view.removeFromSuperview()
oldValue?.removeFromParentViewController()

// 問題2の対応 - 1
// viewがロードされていない場合、子は追加するが子のviewの追加は行わない
addChildViewController(contentViewController) // (b)
contentViewController.didMove(toParentViewController: self)

guard isViewLoaded else {
return
}
setupContentView()
}
}

override func viewDidLoad() {
super.viewDidLoad()

// 問題2の対応 - 2
// viewがロードされた際に子のviewを containerView へ追加
setupContentView()
}

private func setupContentView() {
guard let contentViewController = contentViewController else {
return
}
let isAlreadyAdded = (contentViewController.isViewLoaded) && containerView.subviews.contains(contentViewController.view)
guard isAlreadyAdded == false else {
return // 二重呼び出し防止
}

contentViewController.view.frame = containerView.bounds
containerView.addSubview(contentViewController.view) // (c)
}
}

問題1 の解決は、単に古い 子 View Controller を削除するコードを書いているだけです。

問題2 については少々複雑ですが、「View Controllerの追加タイミング(b)」と「viewの追加タイミング(c)」を分離し、それぞれ適切な時点で呼び出している、ということです。

これにより、どのような状況で使用しても外観関連メソッドが期待通りのタイミングで呼ばれるようになります。


補足:良くないライブラリに当たってしまったら

おかしなタイミングで外観関連メソッドが呼ばれる Container View Controller ライブラリを何かの理由で使いたい場合、 Container View Controller もしくは 子 View Controller の viewDidLoad をこちらの指定するタイミングで呼んでしまう、という方法が使えます。

viewは実際に表示される状態になくとも、アクセスされればロードされ viewDidLoad が呼ばれるという仕様を利用します。

let _ = viewController.view

// viewController.viewDidLoad() が呼ばれる


Container View Controller と 子 View Controller を独立させる

任意の UIViewController を 子 View Controller として受け入れられるようにすることで

しかし、仕様上特定のインターフェースをもつ View Controller のみしか扱えないという場合は、必要なインターフェースを protocol に定義して 子 View Controller に適用します。

子 → ContainerへのアクセスにはContainer へのアクセサを使用し、Container → 子へのアクセスには、独自のインターフェースでアクセスする場合はprotocolを使用するようにします。

protocol Content {

}

// Container View Controller
class SomeContainerController: UIViewController {
// 子 View Controllerに必要なインターフェースは protocol に定義する
func set<T> (contentViewControllers: [T]) where T: UIViewController, T: Content {
}
}

// 子 View Controller
class ContentViewController: UIViewController {
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)

// Container View Controller にアクセス
someContainerController?.doSomething()
}
}


その他

実装しておくと便利な機能


Container へのアクセサ

UIkit標準の Container View Controller を使用する際によく使う仕組みです。

someViewController.navigationControlelr

子から Container View Controller へアクセスするのに便利なので実装しておくと良いでしょう。

注意する点としては、単に parent をチェックするだけではなく、再帰的に Parent View Controller を検索する必要がある、ということです。

Container View Controller の直接の子でなくとも、View Controller Hierarchy 上では Container の配下に入っているので、 Container の要素にアクセスしたい、という場合がままあるからです。

UITabBarController の中に入っている UINavigationControllertopViewController から tabBarController にアクセスしたい、という状況を考えるとわかりやすいでしょう。

ソースコードはこのようになります。

extension UIViewController {

var someContainerController: SomeContainerController? {
var controller: UIViewController? = self

while controller?.parent != nil {
if let containerController = controller?.parent as? SomeContainerController {
return containerController
}
controller = controller?.parent
}
return nil
}
}


おわりに

この記事では、iOS View Controllerプログラミングガイドを具体的にどのようなコードに落とし込めば良いのかを説明しました。

さらに理解を深めたいという方は Container View Controller をひとつ実装するべきですが、それには UITabBarController 互換のものを作るのが良いと思います。

理由としては、


  • 画面上部に Tab Bar があるタイプの TabBarController はUIKitで提供されておらず、また決定的なライブラリが無いので作れるようになっておくと業務に役立つ

  • 子 View Controller の切り替え時のアニメーションが不要で実装が簡単

というものが挙げられます。


SpeeeではTwitterアプリのプロフィール画面UIを再現した Container View Controller ライブラリ ScrollableTabController も公開しています。

Contributor Wanted!

次回は @yamakei7323より、『顔だ、顔を認識したい』です!





  1. viewの表示/非表示に関連するイベント通知メソッド。 viewDidLoad, viewWill/DidAppear, viewWill/DidDisppear, etc