SwiftでUISplitViewControllerのマスター・ディテール間でデータを受け渡す

UISplitViewController を使ったアプリでは、マスター側でアイテムを選択し、その内容をディテール側に表示するのが基本的な流れです。この受け渡しにはいくつかの方法がありますが、もっとも標準的なのはデリゲートパターンを使ったアプローチになります。

全体の流れ

マスター・ディテール間のデータ受け渡しは、次のような手順で行われます。

マスター側でアイテムをタップ

デリゲートメソッドを通じてディテール側へデータを渡す

ディテール側が受け取ったデータで画面を更新

ここで重要なのは、マスター側がディテール側を直接参照しないことです。デリゲートパターンを使えば、マスターは「誰がデータを受け取るか」を知らなくても済むため、コンポーネント間の結合度を低く保てます。

データモデルの定義

まず、受け渡すデータのモデルを用意します。ここでは簡単な記事データを例にしましょう。

struct Article {
    let title: String
    let body: String
}

実際のアプリではもっと多くのプロパティを持つことが多いですが、受け渡しの仕組み自体はデータ構造の複雑さに依存しません。

デリゲートプロトコルの定義

マスター側からディテール側へ通知するためのプロトコルを定義します。

protocol ArticleSelectionDelegate: AnyObject {
    func didSelectArticle(_ article: Article)
}

AnyObject を付けているのは、このデリゲートを weak 参照で保持するためです。クラス専用プロトコルにすることで、循環参照を防止できます。

マスター側の実装

マスター側の ViewController では、テーブルビューにアイテムを表示し、タップ時にデリゲートを呼び出します。

class MasterViewController: UITableViewController {
    weak var delegate: ArticleSelectionDelegate?

    private let articles: [Article] = [
        Article(title: "UISplitViewController入門", body: "Split View の基本を解説します。"),
        Article(title: "Auto Layout実践", body: "制約ベースのレイアウトを学びます。"),
        Article(title: "Core Data概要", body: "永続化フレームワークの全体像です。")
    ]

    override func tableView(_ tableView: UITableView,
                            numberOfRowsInSection section: Int) -> Int {
        return articles.count
    }

    override func tableView(_ tableView: UITableView,
                            cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
        cell.textLabel?.text = articles[indexPath.row].title
        return cell
    }

    override func tableView(_ tableView: UITableView,
                            didSelectRowAt indexPath: IndexPath) {
        let selected = articles[indexPath.row]
        delegate?.didSelectArticle(selected)
        showDetailViewController(DetailViewController(), sender: nil)
    }
}

didSelectRowAt の中で delegate?.didSelectArticle を呼ぶだけで、マスター側の責務は完了です。マスターはディテールの具体的な型を知る必要がありません。

ディテール側の実装

ディテール側では、プロトコルに準拠してデータを受け取り、UI を更新します。

class DetailViewController: UIViewController, ArticleSelectionDelegate {
    private let titleLabel = UILabel()
    private let bodyLabel = UILabel()

    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .systemBackground
        setupLabels()
    }

    func didSelectArticle(_ article: Article) {
        titleLabel.text = article.title
        bodyLabel.text = article.body
    }

    private func setupLabels() {
        titleLabel.font = .boldSystemFont(ofSize: 24)
        titleLabel.translatesAutoresizingMaskIntoConstraints = false
        bodyLabel.numberOfLines = 0
        bodyLabel.translatesAutoresizingMaskIntoConstraints = false

        view.addSubview(titleLabel)
        view.addSubview(bodyLabel)

        NSLayoutConstraint.activate([
            titleLabel.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor,
                                            constant: 20),
            titleLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
            titleLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),
            bodyLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 12),
            bodyLabel.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
            bodyLabel.trailingAnchor.constraint(equalTo: titleLabel.trailingAnchor)
        ])
    }
}

didSelectArticle が呼ばれると、ラベルのテキストが即座に切り替わります。ビューのライフサイクルとは独立してデータを受け取れる点がデリゲートパターンの利点です。

SceneDelegate での接続

最後に、SceneDelegate でマスターとディテールを組み立て、デリゲートを接続します。

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 }

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

        master.delegate = detail

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

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

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

ここが全体の接続ポイントです。master.delegate = detail の 1 行で、マスターからディテールへのデータの流れが確立されます。

デリゲートとクロージャの比較

デリゲート以外にも、クロージャを使った受け渡し方法があります。

デリゲートパターン

プロトコルで契約を明示するため、複数メソッドが必要な場合や長期的な関係に向いている。テストでモックを差し込みやすいのも利点。

クロージャパターン

単発の通知であれば簡潔に書ける。ただし循環参照に注意が必要で、複数のイベントを扱うとプロパティが増えて煩雑になりがち。

UISplitViewController のマスター・ディテール間のように、アプリの存続期間を通じてやり取りが継続する関係では、デリゲートパターンのほうが見通しのよいコードになります。

注意点

データの受け渡しで見落としがちなのは、ディテール側のビューがまだロードされていないタイミングでデリゲートメソッドが呼ばれるケースです。viewDidLoad より前にデータが届くと、ラベルなどの UI 要素が nil のまま設定しようとして反映されません。

この問題を防ぐには、受け取ったデータをプロパティに保持しておき、viewDidLoad 内で改めて UI に反映する遅延適用パターンが有効です。

didSet でビューの存在を確認し、ロード済みなら即反映・未ロードならフラグだけ立てる手法。

具体的には、didSelectArticle でプロパティに値を格納し、viewDidLoad や viewWillAppear で改めてラベルへセットするようにします。こうすればビューのライフサイクルに左右されず、確実にデータが画面に表示されます。