【Swift】UISplitViewControllerを使ってiPhoneとiPadのUIを共通のコードで実現させる

この記事はニフティグループアドベントカレンダー22日目の記事です。
昨日は@jimmysharpさんで「[監視システムPrometheusとTICK Stackを両方使ってみた](https://qiita.com/jimmysharp/items/0cfb4fb482c9d031386b)」でした。

# 完成形
以下の動きを共通のコードで実現させることを目指します!
※設定アプリのような動きです

| iPhone | iPad |
|:------:|:----:|
| ![iPhone.gif](https://qiita-image-store.s3.amazonaws.com/0/80832/96f3be64-452a-5db1-099d-034376e23477.gif) | <img src="https://qiita-image-store.s3.amazonaws.com/0/80832/f87a3252-5ca0-118a-9ad3-65573640cbf8.gif" width="87%"> |

# 背景
私が担当しているアプリでは↑に上げた完成形のような動きをします。
ですが、iPhoneとiPadで別々のコードで書かれており苦労したことが多々あったので、共通化のためのサンプルコードを作ろうと思いました。

## どこに苦労したのか
* 画面の生成方法が違う
    * iPhone : `UISplitViewController` 使わない
    * iPad : `UISplitViewController` 使う
    * **画面の初期化処理を2つ書かなければ**ならず、変更時に片方書き忘れることがある
* 詳細への画面遷移の方法が違う
    * iPhone : 普通に `pushViewController(_:animated:)`
    * iPad : 詳細画面が生成されているかチェックし、あれば使う、なければ生成して使う
    * iPadのときは常にinitを通るわけではないので、**メソッドやプロパティへの直アクセスなどで依存するものを注入**する必要があった
        * nilかどうか気にしなきゃいけない
        * いつ初期化されるかわからない
* 上記の違いを吸収するコードを書かなければならない
    * いたるところに `if iPhone {}` `if iPad {}` ...
    * iPhoneでは動くけどiPadのこと考慮から漏れてた!!というのが1度や2度じゃない…
    * そして、吸収できないことが多々…🤮

やってられん!!😡
ということで改善していきます!!💪

## 本題に入る前に…
私が担当するアプリでは

* iPhone : portrait(縦)固定
* iPad : landscape(横)固定

という仕様になっているため、サンプルコードも同様の設定にしてます。
画面回転を対応したい場合は、 `UIViewController` の `traitCollectionDidChange(_:)` でSizeClassの変更を検知して対応してください。

[[iOS 8] マルチデバイス対応の新機能「Trait Collection」](https://dev.classmethod.jp/references/ios8-trait-collection/)

# 共通化の手順
## インプット
* `UISplitViewController`
    * 画面を分割して表示することができる
        * SizeClassの横幅を元に分割表示できるかどうか判断する
        * https://dev.classmethod.jp/references/ios8-trait-collection/
    * 左側をMaster、右側をDetailと呼ぶ
    * iOS8からはiPhoneでも使えるようになった!!👍
        * 参考：[[iOS 8] UISplitViewController が iPhone に対応しました](https://dev.classmethod.jp/references/ios8-uisplitviewcontroller-for-iphone/)
    * `showDetailViewController(_:sender:)` : 今回の主役
        * SplitViewControllerでDetailを表示していたらDetailを差し替え
        * 表示されていなかったら`navigationController.pushViewController(_:animated:)`する
        * 詳しい挙動は[こちらの記事](https://qiita.com/mini_house/items/edb31abf445b20039b19#showdetailviewcontrollersender)を参照

## 作ったもの
[サンプルコード](https://github.com/hicka04/master-detail-sample/tree/feat/for-qiita)
GitHubAPIから取得したリポジトリ情報をTableViewに表示し、セルを押下すると該当のリポジトリのWebページを表示します。

肝は以下2つです。それぞれ説明します

* `SplitViewController`
* `showDetailViewController(_:sender:)`

### [SplitViewController](https://github.com/hicka04/master-detail-sample/blob/feat/for-qiita/master-detail-sample/SplitView/SplitViewController.swift)

```swift:SplitViewController.swift
class SplitViewController: UISplitViewController, UISplitViewControllerDelegate {

    // ...
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        // 表示できるならMasterとDetail両方表示させる
        preferredDisplayMode = .allVisible

        delegate = self
        
        viewControllers = [
            UINavigationController(rootViewController: master),
            detail
        ]
    }
    
    // 横幅が小さいときDetailをどう表示するかを決めるdelegate
    // true  : Detailを隠す
    // false : Detailを表示させる
    func splitViewController(_ splitViewController: UISplitViewController,
                             collapseSecondary secondaryViewController: UIViewController,
                             onto primaryViewController: UIViewController) -> Bool {
        return splitViewController.isCollapseSecondary
    }
}

extension UISplitViewController {
    
    /// Detailを隠すかどうか
    var isCollapseSecondary: Bool {
        // 横幅が狭かったらDetailを隠す
        return traitCollection.containsTraits(in: UITraitCollection(horizontalSizeClass: .compact))
    }
}
```

### [showDetailViewController(_:sender:)](https://github.com/hicka04/master-detail-sample/blob/feat/for-qiita/master-detail-sample/SearchResult/SearchResultViewController.swift#L73)
```swift:SearchResultRouter.swift
class SearchResultViewController: UITableViewDelegate, UITableViewDataSource {
    
    // ...
    
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        let url = repositories[indexPath.row].htmlUrl
        let detailView = DetailViewController(url: url)
        // Detailに表示されるかpushViewControllerされるかはおまかせ
        showDetailViewController(detailView, sender: self)
    }
}
```

# まとめ
* iOS8からiPhoneでも使えるようになった `UISplitViewController` を活用して、iPhoneとiPadのUIを同一コードで実現できました。
    * Detailを一緒に表示させるかどうか
    * 画面遷移のロジックを統一
* このサンプルを活用して、担当アプリも改善していきたいと思います!!💪💪
* UIの共通化つながりですが、SizeClassを用いてViewの並びや表示するコンポーネントを切り替えることもできますので、合わせてご参照ください。私もあとで試してみたいと思います。
    * [Size Classについて調べた](https://qiita.com/pirosikick/items/665a212d9cf1d6b3991b)
    * [iOS Size Classesを利用してユニバーサル対応を行った話](https://engineer.recruit-lifestyle.co.jp/techblog/2017-01-23-ios-universal/)
* 最初は弊社で採用しているVIPERアーキテクチャで実装していましたが、サンプルにしてはあまりに壮大になったのでやめました。masterブランチはVIPERアーキテクチャでの実装になってますので気になる方は御覧ください。

# 最後に
明日は@taka_masaさんが「今年イチの脆弱性」についてお話いただけるそうです!お楽しみに!