読者です 読者をやめる 読者になる 読者になる

hiragram no blog

iOSとか

Segueを使わずにかっこよく画面遷移する方法を考えた

最近Segueをいかに安全に使って画面遷移するかということを考えていたけど、#swtwsを見ていてそもそもSegueを使わない/嫌いという人が結構いるんだなと思ったのでSegueを使わないで楽に安全に画面遷移する方法を考えてみたら意外といい感じになった。

Segueの危うい所

  • コード内ではSegueのidentifierを単なる文字列として扱う所
performSegue(withIdentifier: "toDetailVC", sender: nil)

だからSegueの名前が変わった時に直し忘れていてもコンパイル時にチェックできない。

  • 遷移先のViewControllerをいじるのはいわゆるprepareForSegueメソッドの中なので、複数の遷移に関する処理が一つのメソッドにまとめられてしまう所
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
  switch segue.identifier {
  case "toDetailVC":
    // DetailVCの準備
  case "toOtherVC":
    // OtherVCの準備
  default:
    break
  }
}
  • prepareForSegueの中で遷移先の型でVCを取得するにはダウンキャストが必要な所
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
  switch segue.identifier {
  case "toDetailVC":
    // segue.destinationの型はUIViewController
    let vc = segue.destination as! DetailViewController
    vc.hoge = "hogehoge"
  default:
    break
  }
}

identifierと同じく行き先の型が正しいかどうかもコンパイル時のチェックができない。

これくらいかと思う。

Segueを使わずに画面遷移する

本題。

Segueを使わずに画面遷移をするということは、どうにかして遷移先のVCのインスタンスを作って、presentなりshowなりで表示するということだろう。

VCのインスタンスをどこから作るかというと、UIStoryboardのinstantiateInitialViewController()instantiateViewController(withIdentifier:_)を使うことが多いはずだ。

コードに起こすとこんな感じだろうか。

@IBAction func buttonTapped() {
  let storyboard = UIStoryboard.init(name: "DetailViewController", bundle: nil)
  let vc = storyboard.instantiateInitialViewController() as! DetailViewController
  vc.hoge = "hogehoge"
  show(vc, sender: nil)
}

確かにSegueのidentifierからは脱却したが、Storyboardの名前はまだ文字列で指定しているし、ダウンキャストもある。こんなコードがいろんなVCのいろんなところに散らばっているのは、相変わらず治安が悪いと言えるだろう。

こんな方法ならどうですか

こんな感じのコードで気軽に画面遷移できたらよくないですか。

@IBAction func buttonTapped() {
  show(DetailViewController.self, sender: nil) { (vc) in
    // ここでのvcはUIViewControllerではなくDetailViewControllerのインスタンス
    vc.hoge = "hogehoge"
  }
}

これは以下の仕組みによって実現できる。

まず、UIViewControllerのサブクラスに対して、「このVCはStoryboardからインスタンスを作ることができる」ということを示すプロトコルを定義する。

public protocol StoryboardInstantitable {}

// StoryboardInstantiatableなUIViewControllerのサブクラスで利用可能になるもの
public extension StoryboardInstantitable where Self: UIViewController {
  static var storyboardName: String {
    // UIStoryboard.init(name:_, bundle:_)に渡すname (*1)
    return String.init(describing: Self.self)
  }

  static var storyboard: UIStoryboard {
    return UIStoryboard.init(name: storyboardName, bundle: nil)
  }

  static func instantiateFromStoryboard() -> Self {
    // storyboardからインスタンスを生成して自分自身の型にキャストして返す (*2)
    return storyboard.instantiateInitialViewController() as! Self
  }
}

これによって、以下のコードでキャスト済のVCのインスタンスを得られるようになった。

let vc = DetailViewController.instantiateFromStoryboard()

次に、UIViewControllerにこのようなextensionを追加する。

public extension UIViewController {
  public func show<T: StoryboardInstantitable>(_ vcType: T.Type, sender: Any?, configuration: ((T) -> Void)?) where T: UIViewController {
    // 中身はのちほど...
  }
}

シグネチャが長くなってしまったが、重要なのはvcType: T.Typeconfiguration: ((T) -> Void)である。 このメソッドは型パラメータTを持ち、このTはStoryboardInstantiatableプロトコルを実装し(<T: StoryboardInstantiatable>)、且つUIViewControllerあるいはそのサブクラスである(where T: UIViewController)という制約がかかっている。

vcType: T.Typeは遷移先のVCの型である。 configuration: ((T) -> Void)は遷移先のVCのインスタンスを引数にとるクロージャで、この中で遷移先VCに値を渡したり、プロパティを変更したりすることを想定している。

このメソッドの具体的な実装は以下のようになる。数字の順番が逆転しているが、数字の順に説明する。

public func show<T: StoryboardInstantitable>(_ vcType: T.Type, sender: Any?, configuration: ((T) -> Void)?) where T: UIViewController {
  let vc = T.instantiateFromStoryboard() // ①
  _ = vc.view // ③
  configuration?(vc) // ②
  show(vc, sender: sender)
}

まず、①の行では先程定義したStoryboardInstantiatableのinstantiateFromStoryboardメソッドを使い、Storyboardから遷移先VCのインスタンスを得る。

次に、②の行で呼び出し元から受け取ったconfigurationブロックを使ってVCのインスタンスを設定する。

但し、この時点ではまだIBOutletなプロパティが初期化されておらず(=nil)、例えばconfigurationブロックの中でIBOutletでStoryboardに接続されたUILabelのテキストを設定しようとすると暗黙アンラップによってアプリがクラッシュする。
これはUIViewControllerのviewが遅延ロードされるためで、一度読み出してやればサブビューも含めて初期化される(loadViewviewDidLoadも呼ばれる)。その為の③の行である。 configurationブロックを用いてVCの設定をしたら、あとは従来あるUIViewControllerのshowメソッドで画面遷移を実行するだけだ。

これで、先程示した

show(DetailViewController.self, sender: nil) { (vc) in
  // ここでのvcはUIViewControllerではなくDetailViewControllerのインスタンス
  vc.hoge = "hogehoge"
}

// (trailing closureを使わなければ)
show(DetailViewController.self, sender: nil, configure: { (vc) in
  // ここでのvcはUIViewControllerではなくDetailViewControllerのインスタンス
  vc.hoge = "hogehoge"
})

という呼び出し方ができるようになった。

導入のしやすさ

一番簡単にこの仕組みに乗っかるなら、VCのクラス名 = storyboardの名前 且つ そのVCがInitial View Controllerにすれば遷移先のVCのクラスにStoryboardInstantiatableプロトコルへの適合を記述するだけでよい(追加でメソッドを生やしたりしなくてよい)。

もう既に開発が進行しているアプリで、例えばクラス名=storyboard名になっていなくて、わざわざリネームするのが面倒なときは

extension DetailViewController: StoryboardInstantiatable {
  static let storyboardName = "SomeOtherName"
}

とデフォルト実装を上書いてやるだけでいいし、Initial View Controllerでないときも同様に

extension DetailViewController: StoryboardInstantiatable {
  static func instantiateFromStoryboard() -> DetailViewController {
    return storyboard.instantiateViewController(withIdentifier: "IdentifierForThisClass") as! DetailViewController
  }
}

としてやるだけでよい。

ごちゃごちゃコードがあってわからん

そうおっしゃると思って、GitHubにサンプルつきで上げておきました。cloneして触ってみてください。

github.com

注意

記事中のSwiftのコードは本文を書きながらそらで打ったものもあるのでコピペしてもコンパイル出来ないかもしれません。だからやっぱりサンプルを触ってみてください。