SwiftでUISplitViewControllerをiPhoneとiPadの両方に対応させる

UISplitViewController は iPad 向けのコンポーネントという印象が強いですが、iOS 8 以降は iPhone でも動作するアダプティブな仕組みが組み込まれています。ただし、何も考えずに実装すると iPhone で起動した瞬間にディテール画面だけが表示されたり、戻るボタンが出なかったりと、想定外の挙動に悩まされることになります。

iPhone と iPad で何が変わるのか

UISplitViewController の振る舞いは、画面の水平サイズクラスによって大きく異なります。

iPad(レギュラー幅)

プライマリとセカンダリが横に並んで表示される。ユーザーは一覧と詳細を同時に見ながら操作できる。UISplitViewController 本来の 2 カラムレイアウトがそのまま活きる環境。

iPhone(コンパクト幅)

2 カラムを並べる余地がないため、スプリットビューが自動的に折りたたまれる。内部的にはナビゲーションスタックに変換され、プッシュ・ポップで画面を行き来する形になる。

この折りたたみ(collapse)と展開(expand)の挙動を正しくハンドリングすることが、ユニバーサル対応の核心です。

起動時にマスターを表示する

iPhone でスプリットビューを使ったとき、最初にディテール側が全画面で表示されてしまう問題は非常によくあるケースです。これは UISplitViewController がデフォルトでセカンダリ(ディテール)を優先表示するためで、iPad では自然な挙動ですが iPhone では違和感があります。

class SceneDelegate: UIResponder, UIWindowSceneDelegate,
                     UISplitViewControllerDelegate {
    var window: UIWindow?

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

        let master = MasterViewController(style: .plain)
        let detail = DetailViewController()

        let masterNav = UINavigationController(rootViewController: master)
        let detailNav = UINavigationController(rootViewController: detail)

        let split = UISplitViewController()
        split.viewControllers = [masterNav, detailNav]
        split.delegate = self
        split.preferredDisplayMode = .oneBesideSecondary

        window = UIWindow(windowScene: windowScene)
        window?.rootViewController = split
        window?.makeKeyAndVisible()
    }
}

デリゲートを設定したら、折りたたみ時の挙動をカスタマイズするメソッドを実装します。

collapseSecondary の制御

iPhone のようなコンパクト幅で起動したとき、スプリットビューはセカンダリをプライマリの上に「折りたたむ」処理を行います。このときに呼ばれるのが splitViewController(_:collapseSecondary:onto:) です。

func splitViewController(
    _ splitViewController: UISplitViewController,
    collapseSecondary secondaryViewController: UIViewController,
    onto primaryViewController: UIViewController
) -> Bool {
    if let detailNav = secondaryViewController as? UINavigationController,
       let detail = detailNav.topViewController as? DetailViewController,
       detail.hasContent == false {
        return true
    }
    return false
}

このメソッドで true を返すと、セカンダリの折りたたみをシステムに任せず自分で処理したことになり、結果的にセカンダリは破棄されます。

まだ何も選択されていない空のディテール画面をスタックに積まないための仕組み。

DetailViewController 側には、コンテンツが設定済みかどうかを示すプロパティを用意しておきます。

class DetailViewController: UIViewController {
    var hasContent: Bool = false

    private let titleLabel = UILabel()
    private let bodyLabel = UILabel()

    func configure(with title: String, body: String) {
        hasContent = true
        titleLabel.text = title
        bodyLabel.text = body
    }
}

こうすることで、まだ何も選択していない状態で iPhone を起動するとマスター画面が最初に表示され、アイテムを選択済みならディテールが表示されるという自然な分岐が実現します。

separateSecondary の制御

折りたたみとは逆に、iPhone を横向きにして iPad のようなレギュラー幅になったとき、あるいは iPad のスライドオーバーから通常表示に戻ったときに呼ばれるのが separateSecondary です。

func splitViewController(
    _ splitViewController: UISplitViewController,
    separateSecondaryFrom primaryViewController: UIViewController
) -> UIViewController? {
    if let masterNav = primaryViewController as? UINavigationController {
        // ナビゲーションスタックからディテールを探す
        for vc in masterNav.viewControllers {
            if let detail = vc as? DetailViewController {
                masterNav.popToRootViewController(animated: false)
                return UINavigationController(rootViewController: detail)
            }
        }
    }
    // ディテールが見つからなければ空のディテールを返す
    let emptyDetail = DetailViewController()
    return UINavigationController(rootViewController: emptyDetail)
}

折りたたみ中にプッシュされたディテール画面を、展開時に正しくセカンダリ側へ戻す処理です。これを実装しないと、展開後にディテール側が空になったり、マスター側のスタックにディテールが残ったままになったりします。

showDetailViewController の活用

マスターからディテールへ遷移する際は、直接 navigationController?.pushViewController を呼ぶのではなく、showDetailViewController を使います。

override func tableView(_ tableView: UITableView,
                        didSelectRowAt indexPath: IndexPath) {
    let article = articles[indexPath.row]
    let detail = DetailViewController()
    detail.configure(with: article.title, body: article.body)

    let detailNav = UINavigationController(rootViewController: detail)
    showDetailViewController(detailNav, sender: nil)
}
pushViewController

ナビゲーションスタックに直接プッシュする。コンパクト幅では動作するが、レギュラー幅ではセカンダリ側に表示されず、マスター側のスタックに積まれてしまう。

showDetailViewController

環境に応じて適切な表示方法を自動選択する。レギュラー幅ではセカンダリを置き換え、コンパクト幅ではプッシュ遷移になる。UISplitViewController 配下では常にこちらを使うべき。

この 1 つのメソッドだけで、iPad では右側カラムの切り替え、iPhone ではプッシュ遷移という適切な動作が自動的に行われます。

traitCollection を使った条件分岐

特定のサイズクラスのときだけ UI を調整したい場合は、traitCollection を参照します。

override func traitCollectionDidChange(
    _ previousTraitCollection: UITraitCollection?
) {
    super.traitCollectionDidChange(previousTraitCollection)

    if traitCollection.horizontalSizeClass == .compact {
        navigationItem.leftBarButtonItem = nil
    } else {
        navigationItem.leftBarButtonItem =
            splitViewController?.displayModeButtonItem
    }
}

displayModeButtonItem はレギュラー幅でのみ意味を持つため、コンパクト幅では非表示にしています。traitCollectionDidChange はデバイスの回転やマルチタスク切り替え時に呼ばれるため、ここでレイアウトの微調整を入れると滑らかなアダプティブ対応になります。

動作確認のチェックリスト

iPhone と iPad の両対応では、検証すべきパターンが多くなります。

iPhone 縦向き起動

マスター画面が初期表示されるか。アイテム選択後にディテールへプッシュ遷移するか。バックボタンでマスターに戻れるか。

iPad 横向き起動

2 カラムが正しく並ぶか。マスターでの選択がセカンダリに反映されるか。displayModeButtonItem が機能するか。

回転とマルチタスク

iPhone の縦横切り替えでレイアウトが崩れないか。iPad のスライドオーバーやスプリットビューでコンパクト幅に移行した際、折りたたみが正しく行われるか。展開時にディテールが復元されるか。

特に見落としやすいのが iPad のマルチタスクです。iPad であってもスライドオーバーで表示されるとコンパクト幅になるため、iPhone と同じ折りたたみ処理が走ります。「iPad だからレギュラー幅」という前提でコードを書くと、このケースで破綻するので注意が必要です。

iOS 14 以降の簡略化

iOS 14 で導入された UISplitViewController(style:) イニシャライザを使うと、collapse / separate のデリゲートメソッドを実装しなくても、システムがより賢く折りたたみ・展開を処理してくれます。

let split = UISplitViewController(style: .doubleColumn)
split.setViewController(masterNav, for: .primary)
split.setViewController(detailNav, for: .secondary)
split.preferredDisplayMode = .oneBesideSecondary
split.preferredSplitBehavior = .tile

カラムベースの API では、setViewController(_:for:) でプライマリとセカンダリを明示的に登録します。従来の viewControllers 配列への代入と比べて意図が明確になり、折りたたみ時の挙動も改善されています。ただし iOS 13 以前をサポートする必要がある場合は、従来のデリゲート方式を併用することになります。