49
44

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.

iOSアプリの基本設計を考える:「UIViewController」画面遷移(1)

Last updated at Posted at 2017-11-12

はじめに

 UIViewControllerを使った画面遷移は、遷移のルートが複雑になればなるほど混沌に陥る可能性が高くなります。一つの理由はUIViewControllerの表示・非表示の方法が複数あり、これらをどのような設計方針で実装するべきかが分かりにくいことが挙げられます。

UIViewControllerの表示・非表示の方法は、例えば以下ののようなものがあります。

Class 表示 非表示
UIViewController present(_:animated:completion:) dismiss(animated:completion:)
UINavigationController pushViewController(_:animated:) popViewController(animated:)
UIViewController + UIView addChildViewController(:) + addSubview(:) removeFromParentViewController() + removeFromSuperview()

問題

 画面をひとつ作成する場合、他の画面と密に結合するよりは、他に依存することなく単独で動作するほうがより良いデザインであるはずです。ここで考えたいのは、他に依存することなく(画面遷移元と密結合することなく)どのように画面を終了するのか、ということです。

 例えば画面内のボタンを押下して画面を終了する場合、present(_:animated:completion:) で遷移したUIViewControllerはどこで dismiss(animated:completion:) を呼べば良いのでしょうか。

Swift
class ViewController: UIViewController {

    // ボタンが押下された
    @IBAction func pushedButton(_ sender: Any) {
        dismiss(animated: true, completion: nil)  // <-- ここを分岐する?
    }
    
}

と書いてしまうと、pushViewController(:animated:)で呼び出した場合は、if文で分岐しますか?
分岐するためには呼び出し元がpresent(
:animated:completion:)で呼び出したか、pushViewController(_:animated:) で呼び出したかを記録する必要もありますね。

delegate

 このような問題を避けるために、delegateを使って呼び出し元に画面終了のための制御を与える設計が多く採用されているのではないでしょうか。しかしながらdelegateによる実装は画面遷移の表示、非表示のコードがが分離してしまい構造が複雑になるデメリットもあります。

クロージャ

 delegateではなくクロージャを使って呼び出し元に画面終了の手段を与えることで、可読性の高いコードが実現できるのではないでしょうか。

 下記のコードでは、FirstViewController画面からViewController画面を呼び出します。FirstViewControllerには2つのボタンを配置しました。1つ目はpresent(:animated:completion:)を呼び出します。2つ目はpushViewController(:animated:)を呼び出します。
ViewControllerではpushedButton
がボタン押下で呼び出されると画面を終了します。またナビゲーションを使用している場合は、ナビゲーションバーの戻るボタン "<" を押下するとdeinitが呼び出され、この中で完了クロージャが呼び出されます。

Swift
import UIKit

class ViewController: UIViewController {

    // ストリーボードからインスタンを生成
    static func getInstance() -> ViewController?
    {
        let storyboardInstance = UIStoryboard(name: "Main", bundle: nil)
        let vc = storyboardInstance.instantiateViewController(withIdentifier: String(describing: self))
        return vc as? ViewController
    }

    // 完了クロージャ
    var completion: ((_ viewController: UIViewController, _ animated: Bool) -> Void)? = nil

    @IBAction func pushedButton(_ sender: Any) {
        dismissCore(animated: true)
    }

    // ボタンなどで画面を終了する場合
    func dismissCore(animated: Bool)
    {
        completion?(self, animated)
        completion = nil
        print("\(String(describing: type(of: self)))\(#function) が呼び出されました")
    }
    
    // ナビゲーションバーの戻るボタン "<" で画面を終了する場合
    deinit
    {
        completion?(self, true)
        completion = nil
        print("\(String(describing: type(of: self)))\(#function) が呼び出されました")
    }
}

Swift

import UIKit

class FirstViewController: UIViewController {
    
    // present(_:animated:completion:)を呼び出す
    @IBAction func pushedPresent(_ sender: Any) {
        
        if let vc = ViewController.getInstance()  // インスタンス生成
        {
            // 終了クロージャの設定
            vc.completion = { viewController, animated in
                
                // 画面終了(インスタンスの消滅)
                viewController.dismiss(animated: animated, completion: nil)
                
                print("\(String(describing: type(of: self)))\(#function) が呼び出されました")
                
            }
            
            // 画面表示
            self.present(vc, animated: true)
        }

    }
    
    // pushViewController(_:animated:)を呼び出す
    @IBAction func pushedPushViewController(_ sender: Any) {
        
        if let vc = ViewController.getInstance()  // インスタンス生成
        {
            // 終了クロージャの設定
            vc.completion = { viewController, animated in

                // 画面終了(インスタンスの消滅)
                viewController.navigationController?.popViewController(animated: true)
                
                print("\(String(describing: type(of: self)))\(#function) が呼び出されました")
                
            }
            
            // 画面表示
            self.navigationController?.pushViewController(vc, animated: true)
        }
        
    }
}

注目したいのは、

Swift
            // 終了クロージャの設定
            vc.completion = { viewController, animated in
                
                // 非表示(インスタンスの消滅)
                viewController.dismiss(animated: animated, completion: nil)
                
            }
            
            // 表示
            self.present(vc, animated: true)

の部分です。表示と非表示の記述を対にして記述することが可能になります。
ここの部分がdelegateに比べて可読性が高いと思う部分になります。
この表示・非表示に関わるコードをカスタマイズすることで、様々な種類の画面遷移に対応できると思います。

継承

 最後に画面遷移のキモの部分をsuper classに格上げします。これでCoreViewControllerを継承するだけで全てのUIViewControllerがこの機能を持てることになりました。本来はプロトコルで実装したいところですが、完了クロージャの変数を持っているために(プロトコルは変数を保持できない)継承関係にて分離しました。

Swift
import UIKit

class CoreViewController: UIViewController {

    // 完了クロージャ
    var completion: ((_ viewController: UIViewController, _ animated: Bool) -> Void)? = nil
    
    func dismissCore(animated: Bool)
    {
        completion?(self, animated)
        completion = nil
        print("\(String(describing: type(of: self)))\(#function) が呼び出されました")
    }
    
    deinit
    {
        completion?(self, true)
        completion = nil
        print("\(String(describing: type(of: self)))\(#function) が呼び出されました")
    }
}

子クラスはこちらです。

Swift
import UIKit

class ViewController: CoreViewController {

    // ストリーボードからインスタンスを生成
    static func getInstance() -> ViewController?
    {
        let storyboardInstance = UIStoryboard(name: "Main", bundle: nil)
        let vc = storyboardInstance.instantiateViewController(withIdentifier: String(describing: self))
        return vc as? ViewController
    }

    @IBAction func pushedButton(_ sender: Any) {
        dismissCore(animated: true)
    }
}

補足

下記コードを実行するには、ストリーボードの設定を少々追加する必要があります。

Swift
static func getInstance() -> ViewController?
    {
        let storyboardInstance = UIStoryboard(name: "Main", bundle: nil)
        let vc = storyboardInstance.instantiateViewController(withIdentifier: String(describing: self))
        return vc as? ViewController
    }

スクリーンショットを参考にしてください。

スクリーンショット 2017-11-12 13.10.23.jpg

ソースコード

なおソースコードをGitHubに上げております。

ソースコードはここ

以上です。

49
44
1

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
49
44

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?