142
139

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Swift時代の画面遷移実装を考える (Swift3改訂版)

Last updated at Posted at 2016-06-07

Swift5時代の画面遷移フレームワークを考えるというタイトルで新しい記事を書いています!良ければこちらも参照ください🙇‍♂️

皆さんはiOSアプリケーションにおいて、画面遷移の実装をどのようにされているでしょうか?

  • Storyboard Segueを使う
  • 自分でpushViewControllerpresentViewControllerを行う

私はどちらの方法が優れているということはなく、ケースバイケースだと思っています。
ここではその議論ではなく、

  • 画面遷移のロジックをどのように記述するか?
  • 遷移先にパラメータを渡す場合、どのように行うか?

について、考えていることを書いてみました。
もし「こんなやり方がある」とか「ぼくがかんがえたさいきょうの...」という方法があればコメントいただけると幸いです。

iOSの画面遷移実装のここがイヤ!

prepareForSegueでごにょごにょする

Storyboard segueで画面遷移を実装する場合、prepareForSegueで遷移先画面のVCにパラメータを渡す等の処理を記述していると思いますが、遷移先VCの参照を得るために以下のようなロジックを実装されているのではないでしょうか?

    override func prepare(for segue: UIStoryboardSegue, sender: Any?) {

        switch segue.identifier {
        case "showDetail"?:
            guard
                let indexPath = self.tableView.indexPathForSelectedRow,
                let selectedItem = self.items?[indexPath.row],
                let detailViewController = segue.desitination as? DetailViewController
            else {
                fatalError()
            }

            // 遷移先VCに選択されたアイテムのIDを渡す
            destinationViewController.itemID = selectedItem.ID

        case "preferences"?:
            // 設定画面はナビゲーションコントローラ配下にある
            guard
               let navigationController = segue.destination as? UINavigationController,
               let preferencesViewController = navigationController.topViewController as? PreferencesViewController
             else {
                 fatalError()
             }

             // 遷移先VCにパラメータを渡すなど...
             preferencesViewController.xxx = ...

        default:
            break // FIXME: Unknown segue
        }
    }

ダウンキャストを評価したり、Navigation Controllerの有無を考慮したり、あまり書きたくないコードですね...

遷移先へのパラメータの渡し方が原始的

上記の例のとおり、遷移先にパラメータを渡す方法が原始的で、これもどうにかならないかなぁといつも感じるポイントです。

見えない導線

自分は導線を明確にするという意味で、できるかぎりStoryboard segueで画面遷移を表現したいと思っていますが、そうできない場合があります。

  • プッシュ通知をユーザがタップしたら、その内容に対応する画面に遷移したい
  • URLスキームでアプリが起動された場合、はじめに対応する画面を表示したい
  • あらゆる画面から割り込み遷移する可能性がある画面、例えばオンデマンドでログインを要求する場合

このようなときはpresentViewControllerなどを使うことになりますが、ここでも上記の「パラメータの渡し方」が気になるのと、こういった遷移が必要な画面の生成の手続きを共通化したいと考えるでしょう。

Swiftの言語特性を生かしたRouterの実装

このような課題を解決すべく、Swiftの言語特性を生かしたRouterフレームワークを実装してみました。サンプルコードを公開しています。
https://github.com/imk2o/MK2Router

DestinationType: 遷移先が受け入れるパラメータを制約する

DestinationTypeは、遷移先VCが受け入れ可能なパラメータの型を定義したプロトコルです。
原則として、遷移先となるすべてのビューコントローラがこのプロトコルに準拠します。

DestinationType.swift
protocol DestinationType: class
{
    associatedtype Context
    var context: Context! { get }
}

extension DestinationType where Self: UIViewController {
    var context: Context! {
        return ContextStore.shared.context(for: self)
    }
}
ItemDetailViewController.swift
class ItemDetailViewController: UIViewController, DestinationType {
    // この画面は、表示するアイテムIDをパラメータとして受け取る
    typealias Context = Int

    override func viewDidLoad() {
        super.viewDidLoad()

        // 画面遷移時に、Routerに預けられたコンテキストを取得
        let itemID = self.context
        ...
    }

    ...
}

画面遷移をアシストするUIStoryboardSegue拡張

UIStoryboardSegueを拡張し、prepareForSegue内で行う、遷移先VCへのパラメータ渡し処理などの記述を簡素化します。

ItemListViewController.swift
    override func prepare(for segue: UIStoryboardSegue, sender: AnyObject?) {
        // segue.identifierが"showDetail"の場合、
        // 遷移先のItemDetailViewControllerにInt型のパラメータを渡す
        segue.mk2.context(ifIdentifierEquals: "showDetail") { (destination: ItemDetailViewController) -> Int in
            guard
                let indexPath = self.tableView.indexPathForSelectedRow,
                let selectedItem = self.items?[indexPath.row]
            else {
                fatalError()
            }
        
            return selectedItem.ID
        }
    }

Router: 汎用の画面遷移

RouterはStoryboard segueに依らない画面遷移機能を提供します。

Router.swift
class Router {
    static let shared: Router = Router()
    
    private init() {
    }

    /**
     画面遷移を行う.
     遷移元のビューコントローラがNavigation Controller配下にあり、遷移先がNavigation Controllerでなければ
     プッシュ遷移、それ以外の場合はモーダル遷移を行う.
     
     - parameter sourceViewController:      遷移元ビューコントローラ.
     - parameter destinationViewController: 遷移先ビューコントローラ.
     - parameter contextForDestination:     遷移先へ渡すコンテキストを求めるブロック.
     */
    func perform<DestinationVC>(
        _ sourceViewController: UIViewController,
        destinationViewController: UIViewController,
        contextForDestination: ((DestinationVC) -> DestinationVC.Context)
    ) where DestinationVC: DestinationType, DestinationVC: UIViewController

    /**
     画面遷移を行う.
     
     - parameter sourceViewController:  遷移元ビューコントローラ.
     - parameter storyboardName:        遷移先ストーリーボード名.
     - parameter storyboardID:          遷移先ストーリーボードID. nilの場合はInitial View Controllerが遷移先となる.
     - parameter contextForDestination: 遷移先へ渡すコンテキストを求めるブロック.
     */
    func perform<DestinationVC>(
        _ sourceViewController: UIViewController,
        storyboardName: String,
        storyboardID: String? = nil,
        contextForDestination: ((DestinationVC) -> DestinationVC.Context)
    ) where DestinationVC: DestinationType, DestinationVC: UIViewController

この実装はアプリに依存しない汎用機能なのでそのままは使いづらいでしょう。
次に、各アプリにおける最適化の方法を紹介します。

各アプリケーションに最適化する

対象アプリにおいて必要となる「見えない導線」に対応するルートをenumで定義し、それぞれcaseで画面遷移を行うperformメソッドを実装します。
このとき、Associated Valueで遷移パラメータを定義しておくとよいでしょう。

Routes.swift
enum Route {
    case contactForm(Int)		// 問い合わせ画面(アイテムID)
    case modalItemDetail(Int)	// 詳細画面(アイテムID)
}

extension Route {
    func perform(_ sourceViewController: UIViewController) {
        switch self {
        case .contactForm(let itemID):
            Router.shared.perform(sourceViewController, storyboardName: "Misc", storyboardID: "ContactFormNav") { (destination: ContactFormViewController) -> Int in
                return itemID
            }
        case .modalItemDetail(let itemID):
            Router.shared.perform(sourceViewController, storyboardName: "Main", storyboardID: "ItemDetailNav") { (destination: ItemDetailViewController) -> Int in
                return itemID
            }
        }
    }
}

このRouteにより、以下のような形で画面遷移を実装することができます。

ItemDetailViewController.swift
    @IBAction func showContactForm(sender: UIButton) {
        guard let itemID = self.context else {
            return
        }

        // 指定のルートで問い合わせ画面へ
        Route.contactForm(itemID).perform(self)
    }

Unwind Segueを利用した値のフィードバック

ある画面で入力された値を帰還先の画面にフィードバックしたいことがあります。
Delegateプロトコルを定義してフィードバックさせることも可能ですが、画面間の依存度が高まるのと、複数のステップで入力する場合などに難しくなってくると思います。
そこで、Unwind Segueを利用する方法を紹介したいと思います。

準備

帰還先のビューコントローラにUnwind Segue用のアクションunwindFromSearchOptionを定義しておきます。
次にInterface BuilderでUnwind Segueを作成してください。
identifierは"feedback"とし、unwindFromSearchOptionアクションにバインドします。

FeedbackTypeへの準拠

FeedbackTypeプロトコルを実装し、フィードバックする値の型を指定します。

SearchOptionViewController.swift
extension SearchOptionViewController: MK2Router.FeedbackType {
    typealias Feedback = String
}

Unwind SegueがトリガされたときのprepareForSegue

UIStoryboardSegue拡張のmk2.feedback(ifIdentifierEquals:)メソッドを用いて、フィードバックしたい値を返します。

SearchOptionViewController.swift
    override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
        // 入力された検索条件をフィードバック
        segue.mk2.feedback(ifIdentifierEquals: "feedback") { (source: SearchOptionViewController) in
            return self.keywordTextField.text ?? ""
        }
    }

帰還先ビューコントローラでの値の受け取り

UIStoryboardSegue拡張のmk2.feedback(from:)メソッドを用いて、フィードバックされた値を取得します。

ItemListViewController.swift
    @IBAction func unwindFromSearchOption(_ segue: UIStoryboardSegue) {
        // SeachOptionViewControllerからのフィードバックを取得し、再検索する
        if let keyword = segue.mk2.feedback(from: SearchOptionViewController.self) {
            self.loadItems(keyword: keyword)
        }
    }

Context同様、フィードバックした値は元のビューコントローラとともに消失します。

142
139
0

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
142
139

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?