【Swift】Storyboardの分割運用とコードでの画面遷移管理

## はじめに

どうも、プログラミング未経験で学習中の**はるさん**です。
今回は**Storyboardの分割運用とコードでの画面遷移管理**について説明していきたいと思います。
１つのStoryboardファイルに対して複数の画面を配置するのではなく、
１Storyboard１ViewControllerという運用方法です。
チーム開発においてとても重要になってくる運用方法だとお聞きしたので
その方法について解説していきたいと思います。

### 記事の対象者
- Swift学習を初めたばかりの方
- UIKitを使って開発をしている方
- iOSエンジニアを目指している方

### 記事を執筆時点での筆者の環境
- macOS Ventura 13.4.1
 - Xcode 14.3.1
- Swift  5.8.1 
- iOS 16.4

## 1. Storyboardを分割運用するメリット
![スクリーンショット 2023-07-25 20.55.05.e11e896e33a14c379925cf21db27eee4.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3058346/3a3317f4-42f2-ec0a-fccf-f8e562a0b52b.png)

### 1-1. コンフリクトの発生を低減できる
以前投稿した記事ではMain.storyboardを削除するやり方について説明しました。

https://qiita.com/Harx02663971/items/fb753d827acb0df8eddb

その際にStoryboardが実務において、特にチーム開発において足枷になることも
お話ししました。
これはチーム開発において大体の場合、
- A画面はaさんが担当
- B画面はbさんが担当
という形で進めると思われます。これが一つのStoryboardファイルに複数の画面が配置されている場合
高い確率でコンフリクトが発生します。
Storyboardは特性上、画面をタップしただけでも変更されたと認識されてしまうようです。
よって、Storyboardファイルを分割することでA画面はaさんしか触らないし、
B画面はbさんしか触らないのでコンフリクトを抑えることが出来るのです。
### 1-2. 画面の把握がしやすくなる
画面数が少なければ特に問題はないのですが、そのアプリの画面が２０、３０、４０と
増えていった場合にどこに目的の画面があるか把握が困難になってきます。
画面を分割して、さらにフォルダーなどで整理すれば目的の画面を探しやすく、
尚且つ管理しやすくなります。
### 1-3. 画面の使い回しがしやすくなる
Storyboardファイルを分割している場合、コードで画面遷移処理を書くことになります。
その際に同じ画面を使って別の機能を使うといった事がやりやすくなります。
## 2. Storyboardを分割運用するデメリット

![スクリーンショット 2023-07-25 21.00.06.f30aeba7941843538ec88b68f8796068.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3058346/3c4b802e-7f37-2272-b56e-88ca4b03212f.png)






### 2-1. Storyboardファイルを作る手間が発生する
当然ですが、画面ごとにStoryboardファイルを作成する手間が発生します。
### 2-2. 画面遷移を視覚的に把握しづらい
ワイヤーフレームのように一つの画面内でどのように画面遷移するのか俯瞰で
把握しづらくなります
### 2-3. 画面遷移処理のためのコードが必要
当然Segueを使った画面遷移ではないので、コードを記述する必要があります。

## 3. 実践Storyboardを分割で作成
今回は私が個人開発で作成した「お使いーじー」というアプリを元に説明します。

https://apps.apple.com/jp/app/%E3%81%8A%E4%BD%BF%E3%81%84%E3%83%BC%E3%81%98%E3%83%BC/id6449246683


画面構成はUITabBarControllerを実装して５タブ構成にしています。
それぞれのタブ画面からさらにでモーダル遷移とプッシュ遷移が発生します。
今回はあくまでレイアウト作成はStoryboardで行い、画面遷移はコードで行うようにしています。
### 3-1. Main.Storyboardの削除
まずはMain.Storyboardを削除します。
詳しい内容は冒頭でお話しした記事にて解説しています。

https://qiita.com/Harx02663971/items/fb753d827acb0df8eddb

なお、アプリの起動経路については今回多少異なるため、後述します。
### 3-2. UITabBarControllerを作成
次にUITabBarControllerを継承したviewControllerのSwiftファイルを作成します。
合わせてUITabBarControllerを実装したStoryboardファイルも作成します。
この時、自動的に作成されたviewControllerは削除しておきます。

![スクリーンショット 2023-07-25 15.37.43.ceeb022c1f6f46a88df41c232d9a299d.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3058346/0f734c71-2299-e3e0-ef1c-893ba099aa76.png)


### 3-3. Storyboard＆ViewControllerを用意
次に必要なタブメニューの分だけStoryboard＆ViewControllerを用意しておきます。
この時に忘れがちですが、以下２つの設定も忘れずに行いましょう。
- Is Initial View Controllerのチェックを入れる
- CustomViewControllerの設定

![スクリーンショット 2023-07-25 15.44.09.e02270d6de414179906e2e30016cc764.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3058346/475cdf45-3206-fd73-265b-4c2688e6b580.png)






![スクリーンショット 2023-07-25 15.43.57.51657fc7ef2643c89ded6c20de509207.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3058346/3a0be45a-4e9f-8760-0393-164ec68d6910.png)




### 3-4. UITabBarControllerとそれぞれのViewControllerをリファレンスで繋ぐ
あとは必要なタブメニュー分のStoryboard Referenceを設置します。


![スクリーンショット 2023-07-25 15.41.13.6fca7eb00cf34b43b31d888e85677dbb.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3058346/b8e0300a-8bfb-1400-7a31-d97ba24f6298.png)

Control + ドラッグで接続します。
レリーションのviewControllerで接続します。
![スクリーンショット 2023-07-25 15.51.02.848a3a90d5804efea6713c9337c2d3d7.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3058346/b8b03bdc-ac47-1a1f-1521-bd6d8db9b0a2.png)


最後にStoryboard Referenceの「Storyboard」にファイル名を入力します。
![スクリーンショット 2023-07-25 15.52.49.8988b8be5dca404ea95db8a3e8eaec2b.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3058346/c78f84fb-22d7-1812-c960-6ae3f0816285.png)




### 3-5.  起動経路
最後にSceneDelegateに以下のコードを記述すれば起動できます。
今回は単純なviewControllerではなく、UITabBarControllerです。
UITabBarControllerを継承しているStoryboardにルートを指定しています。
デフォルトでは一番左のメニューが最初の起動画面に指定されています。
ですので特にコードで指定しなければ基本はタブメニューでいうところの
一番左のメニュー画面が起動経路となります。
今回は説明を割愛しますが、左から２つ目や３つ目を起動画面にすることもできます。


```Swift
import UIKit

class SceneDelegate: UIResponder, UIWindowSceneDelegate {

    var window: UIWindow?


    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        guard let windowScene = (scene as? UIWindowScene) else { return }
        //Windowをインスタンス化
        let window = UIWindow(windowScene: windowScene)
        //rootViewControllerに指定したいControllerを指定
        //StoryboardからViewControllerを生成
        window.rootViewController = UIStoryboard(name: "UITabBarControllerView", bundle: nil).instantiateInitialViewController()
        window.makeKeyAndVisible()
        self.window = window
    }
```
## 4. 画面遷移を管理するクラスを作る
次にコードを使って画面遷移を行う専用のクラスを作成します。
一般的にはこのクラスをRouterという名称で、尚且つシングルトンクラスで作成します。
それぞれのviewControllerに遷移のコードを記述しても良いです。
しかし、画面遷移に関する処理はRouterクラス定義しておけば、
「画面遷移に関する処理を編集したい」となったときに迷わずに探し出すことができます。
### 4-1. シングルトンの定義
 基本的にはシングルトンでの定義は以下のようになります。
- クラス名にはfinalキーワードで継承禁止
- インスタンスのアクセスにstaticプロパティを使用
- イニシャライザをプライベートにして使用禁止にする
このシングルトンパターンを使うことで、インスタンスが1つしか生成されないことが保証されます。
```swift
import UIKit

/// 画面遷移に関する処理を担うクラス
final class Router {
    static let shared = Router()
    private init() {}
}
```

### 4-2. 起動経路の記述
起動経路もある意味画面遷移であるのでこちらに移動します。
以下のようにUIWindowを返り値にして、
呼び出し元で代入するようにしました。
```swift
import UIKit

/// 画面遷移に関する処理を担うクラス
final class Router {
    static let shared = Router()
    private init() {}

    /// 起動経路メソッド
    internal func showRoot(windowScene: UIWindowScene) -> UIWindow {
        //Windowをインスタンス化
        let window = UIWindow(windowScene: windowScene)
        //rootViewControllerに指定したいControllerを指定
        //StoryboardからViewControllerを生成
        window.rootViewController = UIStoryboard(name: "UITabBarControllerView", bundle: nil)
            .instantiateInitialViewController()
        window.makeKeyAndVisible()
        return window
    }

```
使用例


```Swift
import UIKit

class SceneDelegate: UIResponder, UIWindowSceneDelegate {

    var window: UIWindow?
    
    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        guard let windowScene = (scene as? UIWindowScene) else { return }

        self.window = Router.shared.showRoot(windowScene: windowScene)
    }
```

### 4-3. 画面遷移の記述例
まずは基本の画面遷移メソッドを用意します。
このコードは単純にnavigationControllerの有無に応じて
プッシュ遷移かモーダル遷移を切り替えています
```swift
private extension Router {

    func show(from: UIViewController, to: UIViewController, completion:(() -> Void)? = nil){
        if let nav = from.navigationController {
            nav.pushViewController(to, animated: true)
            completion?()
        } else {
            from.present(to, animated: true, completion: completion)
        }
    }
}
```

あとはそれぞれの遷移を定義しています。
それぞれの遷移メソッドは以下の原則を設けて作成してみました。
- 遷移先は指定している
- 遷移元は引数from:で呼び出し元で代入する
- 遷移処理の基本はshowメソッド
- 絶対的に遷移方法を指定したい場合はその都度遷移処理をかく
- 遷移時にデータの受け渡しがある場合はここに書く
- デリゲートパターンの時は呼び出し元をそのクラスに指定する


いくつか抜粋してみます。

### ①標準の画面遷移
最初に遷移先をインスタンス化します。
次にshow(from: to: )メソッドを呼び出して遷移する標準パターンです。
```swift
import UIKit
final class Router {
    /// 共有設定画面に画面遷移
    internal func showShareSettingsView(from: UIViewController) {
        guard let toVC = UIStoryboard(name: "ShareSettingsView", bundle: nil)
            .instantiateInitialViewController() as? ShareSettingsViewController else { return }
        show(from: from, to: toVC)
    }
} 
```
使用例
ここの呼び出し元ではUINavigationControllerを実装しているので、
指定はしていませんがプッシュ遷移になります。
```swift
/// アカウント画面
final class AccountViewController: UIViewController {
    /// 共有設定画面にプッシュ遷移
    @objc private func goShareSettingsView() {
        guard NetworkMonitor.shared.isConnected else {
            AlertController.showAlert(tittle: "エラー", errorMessage: AuthError.networkError.title)
            return
        }
        Router.shared.showShareSettingsView(from: self)
    }
}
```

### ② データを受け渡す場合の遷移
画面遷移時に何かしらの情報を遷移先の画面に渡すことは多いと思います。
今回は画面に表示するスクリーンショットをアセットに保存しています。
そのアセット名を引数として渡して遷移する処理です。
```swift
import UIKit
final class Router {
    /// チュートリアルの詳細画面へ画面遷移
    /// - 第１引数from：遷移元のUIViewController
    /// - 第２引数imageName : imageViewに表示するスクショのアセット名
    internal func showDetailTutorial(from: UIViewController, imageName: String) {
        guard let detailTutorialVC = UIStoryboard(name: "DetailTutorialView" , bundle: nil)
            .instantiateInitialViewController() as? DetailTutorialViewController else { return }
        detailTutorialVC.configurer(imageName: imageName)
        detailTutorialVC.modalPresentationStyle = .fullScreen
        show(from: from, to: detailTutorialVC)
    }
}
```
使用例
```swift
import UIKit
/// チュートリアルの一覧を表示する
final class HomeTutorialViewController: UITableViewController {
    /// チュートリアルの一覧を表示するTableView
    @IBOutlet private weak var tutorialTableView: UITableView!

    /// チュートリアルの表示する画像名を管理
    enum ImageName: String {
        case shoppingList = "TutorialShppingListVer2.0.0"
        case salesFloorMap = "TutorialSalesFloorMapVer2.0.0"
        case editShoppingList = "TutorialEditShoppingListVer2.0.0"
        case editSalesFloorMap = "TutorialEditSalesFloorMapVer2.0.0"
        case accountDescription = "TutorialAccountDescriptionVer2.0.0"
        case accountCreate = "TutorialAccountCreateVer2.0.0"
        case shareSettings = "TutorialShareSettingsVer2.0.0"
        case accountDelete = "TutorialAccountDeleteVer2.0.0"
        case offLine = "TutorialOffLineVer2.0.0"
    }

    override func viewDidLoad() {
        super.viewDidLoad()
    }


    /// タップしてモーダル遷移
    override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        // セクションと行番号でDetailTutorialViewに表示するチュートリアルの画像を切り替える
        switch (indexPath.section, indexPath.row) {
            case (0, 0):
                return Router.shared.showDetailTutorial(from: self, imageName: ImageName.shoppingList.rawValue)
            case (0, 1):
                return Router.shared.showDetailTutorial(from: self, imageName: ImageName.salesFloorMap.rawValue)
            case (0, 2):
                return Router.shared.showDetailTutorial(from: self, imageName: ImageName.editShoppingList.rawValue)
            case (0, 3):
                return Router.shared.showDetailTutorial(from: self, imageName: ImageName.editSalesFloorMap.rawValue)

            case (1, 0):
                return Router.shared.showDetailTutorial(from: self, imageName: ImageName.accountDescription.rawValue)
            case (1, 1):
                return Router.shared.showDetailTutorial(from: self, imageName: ImageName.accountCreate.rawValue)
            case (1, 2):
                return Router.shared.showDetailTutorial(from: self, imageName: ImageName.shareSettings.rawValue)
            case (1, 3):
                return Router.shared.showDetailTutorial(from: self, imageName: ImageName.accountDelete.rawValue)

            case (2, 0):
                return Router.shared.showDetailTutorial(from: self, imageName: ImageName.offLine.rawValue)

            case (3, 0):
                // アプリの設定画面に画面遷移し、ライセンス画面を表示
                guard let settingsUrl = URL(string: UIApplication.openSettingsURLString) else { return }
                return UIApplication.shared.open(settingsUrl)

            default:
                dismiss(animated: true)
        }
    }
}
```
### ③ デリゲートを登録するとき
デリゲートを登録する場合は遷移元である引数の型はUIViewControllerではなく、使用を想定している
ViewController自体にします。
```swift
    /// 売り場選択画面に遷移
    /// 選択後にボタンの見た目を変更するためにデリデートをセット
    internal func showSelectTypeOfSalesFloorView(from: EditItemViewController) {
        guard let toVC = UIStoryboard(name: "SelectTypeOfSalesFloorView", bundle: nil)
            .instantiateInitialViewController() as? SelectTypeOfSalesFloorViewController else { return }
        toVC.delegate = from
        show(from: from, to: toVC)
    }
```
使用例
```swift
import UIKit
import RealmSwift
/// G-品目新規作成
final class EditItemViewController: UIViewController {
    /// 売り場選択
    @IBOutlet private weak var selectTypeOfSalesFloorButton: UIButton! {
        didSet {
            selectTypeOfSalesFloorButton.addTarget(self, action: #selector(goSelectTypeOfSalesFloorView), for: .touchUpInside)
            selectTypeOfSalesFloorButton.setAppearanceWithShadow(fontColor: .black)
        }
    }

    /// 売り場選択画面に遷移するメソッド
    ///  - 遷移後に自身のボタンの見た目を変更するためにデリゲートをセット
    @objc private func goSelectTypeOfSalesFloorView() {
        Router.shared.showSelectTypeOfSalesFloorView(from: self)
    }
}
```


## 終わりに
いかがでしたでしょうか。
最初はなかなかな手順が慣れないと思いますが、慣れると画面の整理がフォルダーやファイルで行えて把握しやすくなると思います。
また画面遷移をコードで記述することで複雑な案件にも柔軟に対応することができます。
さらに、MVCで開発する際にViewControllerに処理が集中してしまう
いわゆる**ファットビューコントローラー**を防ぐ意味でも、
画面遷移専用のクラスを用意することで処理を分散化することができるでしょう。

逆に、勿論１つのStoryboardファイルだけで運用するメリットもあると思っています。
画面遷移の全体像を把握しやすい点や、わざわざコードで遷移を書かずに済む点です。
なのでアプリの要件によって使い分けることが大事だと思っています。
私としては以下の内容であればわざわざStoryboardを分割しなくても良いかなとは思います。
- ある機能だけを作るテストプロジェクト
- 個人開発で画面の構成も１〜９画面程度の小規模なもの


今回の例でお見せしたアプリ「お使いーじー」のソースコードはgithubで公開しています。
**Tagのv2.0.1**をダウンロード、ないしはブラウザで閲覧可能ですので
詳しく知りたい方はご覧になってみてください。
この記事が初学者の方々の学習において、少しでもお役にたてれば幸いです。

https://github.com/HaruhikoMotokawa/RemeApp