SwiftUI DocumentGroup で「新規タブ」をなめらかに実装する

macOS の Finder や Safari、などを使っていると、「新規タブ ⌘T」がスッと当たり前のように動いてくれます。アプリを作る側からするとあの挙動は当たり前にあるべきものに見えますが、SwiftUI の DocumentGroup で同じことをやろうとするとびっくりするくらい何も用意されていません。

今開発中のmacOS 用テキストエディタで

⌘T で新規ドキュメントを 現在のウィンドウのタブとして 開く

を実装したら、AppKit の癖と SwiftUI の癖が両方ぶつかってきて、最終的に「同期検出 + アニメーション抑制 + NSWindowTabGroup」を組み合わせる必要がありました。完成すると Finder 並みになめらかに動きます。試行錯誤を残しておきます。

対象は macOS 14 以降、DocumentGroup を使う SwiftUI アプリです。

ただしこれは僕の知識不足ってだけの話かもしれませんので、いいやり方があればご教示いただけると嬉しいです。

やりたいこと

  • ⌘T で新規ドキュメントを開く
  • 開いたドキュメントは現在のウィンドウのタブとして表示される(独立ウィンドウにならない)
  • 新規タブを開いたら、そのタブがアクティブになる(Safari や Finder の挙動に揃える)
  • ⌘N は従来どおり独立した新規ウィンドウとして開く
  • タブとして合流するときにちらつかない(独立ウィンドウとして一瞬見えてからタブに吸い込まれる、をやめる)
  • macOS のシステム設定「タブで開く」を尊重する設計とは独立して、Kami の中ではいつでも ⌘T = タブ にする

「⌘N は単独、⌘T はタブ」というのが OS 全体の伝統的な作法なので、これに合わせたいところです。

SwiftUI が用意してくれていないもの

DocumentGroup は「新規ドキュメント」自体は NSDocumentController.shared.newDocument(nil) で作れます。ですが、

  • それを現在のウィンドウのタブとして開かせる API
  • newWindowForTab: のような AppKit 標準アクションを SwiftUI のウィンドウに送る経路

の両方が用意されていません。NSApp.sendAction(#selector(NSWindow.newWindowForTab(_:)), to: nil, from: nil) を投げると、SwiftUI 内部の AppKitWindow というプライベートクラスが newWindowForTab: に応答していないので unrecognized selector で落ちます。

つまり、newDocument(nil) で作られた新規ウィンドウを後から手動でタブにねじ込むしかありません。

効かなかったNGアプローチ

Window.tabbingMode = .preferred をデフォルトにする

「新規ウィンドウは可能ならタブに合流する」という設定です。これにすると ⌘T はタブで開いてくれますが、⌘N までタブで開いてしまいます。⌘N と ⌘T を区別したいので不適です。

⌘T を押した瞬間だけ tabbingMode を切替える

「⌘T を押す → tabbingMode を .preferred に切替 → newDocument → 元に戻す」。理屈は通りますが、newDocument内部的にどのタイミングでウィンドウを生成するかは SwiftUI の都合なので、戻すタイミングが微妙にずれてレース状態になります。本質的に競合解決を諦めた書き方になり、「たまに独立ウィンドウになる」が再発します。

addTabbedWindow(_:ordered:) だけでまとめる

正攻法です。新ウィンドウの ID を検出して parent.addTabbedWindow(newWin, ordered: .above) で合流。動きはしますが、新ウィンドウが画面上に独立ウィンドウとして一瞬現れて、それからタブに吸い込まれるアニメーションが見えてしまいます。「まだちらつくね」と私自身がコミット直後にこぼしたほどです。

効いたアプローチ:4 つの仕掛けの合わせ技

最終的にちらつきが消えた構成は次のとおりです。

#仕掛け役割
1既存ウィンドウ ID を Set<ObjectIdentifier> でスナップショットnewDocument の前後差分で「今回作られたウィンドウ」を一意に特定
2同期で新ウィンドウ検出を試み、見えれば即マージSwiftUI が同期的にウィンドウを作るパスでは、ここでマージするとちらつきゼロになる
3同期で見えなければ DispatchQueue.main.async で次ランループに再検出SwiftUI が非同期でウィンドウを作るパスへのフォールバック
4マージ前に animationBehavior = .noneorderOut(nil)parent.tabGroup?.addWindow(_:)「独立ウィンドウとして一瞬見える」を物理的に消す。さらに既存タブグループがあれば tabGroup.addWindow のほうが addTabbedWindow より滑らか
5マージ後に tabGroup.selectedWindow = winmakeKeyAndOrderFront(nil)新規タブをアクティブにする。合流しても元のタブのままだと Safari / Finder と挙動が違う

parent.tabGroup を使うあたりは Qiita の @Shota-Abe さんの記事 を参考にしました。addTabbedWindow は「初回のタブ化」、tabGroup.addWindow は「既存タブグループへの追加」と使い分けるのが正解です。

コード全体

import SwiftUI
import AppKit

struct EditorCommands: Commands {
    var body: some Commands {
        // ファイルメニュー: 「新規」の直下に「新規タブ ⌘T」を追加
        CommandGroup(after: .newItem) {
            Button("新規タブ") {
                Self.openNewTab()
            }
            .keyboardShortcut("t")
        }
    }

    /// SwiftUI DocumentGroup には newWindowForTab(_:) が直接届く経路がない。
    /// なので newDocument で生成された新ウィンドウを NSWindowTabGroup に
    /// 明示的に合流させる。
    ///
    /// 手順:
    ///   1. 既存ウィンドウ ID を記録
    ///   2. NSDocumentController.newDocument を呼ぶ
    ///   3. まず同期で新ウィンドウが見えれば即 merge(ちらつき最小)
    ///   4. 同期で見えなければ次ランループで再検出して merge
    ///   5. merge 前に animationBehavior = .none + orderOut(画面から消す)
    ///      で「独立ウィンドウとして一瞬表示 → タブに合流」のちらつきを抑える
    ///   6. parent.tabGroup が既にあれば tabGroup.addWindow(_:) を使う
    ///      なければ addTabbedWindow で初回タブグループを作る
    private static func openNewTab() {
        guard let parent = NSApp.keyWindow else {
            // 親ウィンドウがなければ普通に新規ドキュメントを開くだけ
            NSDocumentController.shared.newDocument(nil)
            return
        }
        let beforeIDs = Set(NSApp.windows.map { ObjectIdentifier($0) })
        NSDocumentController.shared.newDocument(nil)

        // 同期でも検出可能ならその場で merge(ちらつきが最小になる)
        let immediate = Self.findNewWindows(beforeIDs: beforeIDs, parent: parent)
        if !immediate.isEmpty {
            for win in immediate { Self.mergeAsTab(win, into: parent) }
            return
        }

        // SwiftUI が非同期にウィンドウを作る場合は次ランループで再検出
        DispatchQueue.main.async {
            let newWindows = Self.findNewWindows(beforeIDs: beforeIDs, parent: parent)
            for win in newWindows { Self.mergeAsTab(win, into: parent) }
        }
    }

    private static func findNewWindows(beforeIDs: Set<ObjectIdentifier>,
                                       parent: NSWindow) -> [NSWindow] {
        NSApp.windows.filter { win in
            !beforeIDs.contains(ObjectIdentifier(win))
                && win !== parent
                && !(win is NSPanel)
        }
    }

    private static func mergeAsTab(_ win: NSWindow, into parent: NSWindow) {
        // ちらつき抑制: アニメーションなしで一旦画面から外す
        win.animationBehavior = .none
        win.orderOut(nil)
        // 既存タブグループがあればそちらに合流するほうが動きが安定する
        if let tabGroup = parent.tabGroup {
            tabGroup.addWindow(win)
            tabGroup.selectedWindow = win
        } else {
            parent.addTabbedWindow(win, ordered: .above)
            parent.tabGroup?.selectedWindow = win
        }
        win.makeKeyAndOrderFront(nil)
    }
}

これで Finder 並みのなめらかさになりました。

なぜ「同期検出 + 非同期検出」の二段構えなのか

NSDocumentController.newDocument(nil) を呼んだ瞬間、新ウィンドウが NSApp.windows に登録されるタイミングは SwiftUI のバージョンや macOS のバージョンで微妙に違います

  • 同期で登録されているケース: その場で findNewWindows が見つかります。これがちらつき最小ルートです。
  • 非同期で登録されるケース: 同期で取りに行っても空で、次のメインランループに登録されます。

両方にフォールバックしておくのが事故りません。同期だけだと将来 SwiftUI が非同期化したら効かなくなりますし、非同期だけにすると現状ある「同期で済むケース」でもわざわざ 1 ランループ分ちらつきの隙ができてしまいます。

なぜ tabGroup.addWindow のほうがなめらかなのか

NSWindow には「タブ化された複数のウィンドウをまとめる」内部表現として NSWindowTabGroup があります。

  • parent.addTabbedWindow(child, ordered: .above) は「親に対して新しい兄弟タブを生やす」初回向き の API です。
  • parent.tabGroup?.addWindow(child) は「既に存在しているタブグループに、もう一枚追加する」 API です。

タブが 1 枚しかない(=まだ tabGroup が立ち上がっていない)状態では addTabbedWindow を使い、2 枚目以降は tabGroup.addWindow のほうが UI 的に揃って見えます。実際に二つを切替えてみると後者のほうが動きが安定します。Kami のコードでは if let tabGroup = parent.tabGroup でちょうどよく分岐できます。

ハマりどころと小ネタ

NSPanel を弾く

findNewWindows!(win is NSPanel) を入れているのは、SwiftUI が裏で NSPanel 系のサブビュー(autosize 用の隠しパネルなど)を持つことがあって、それを誤ってタブにしようとすると addWindow 側で例外が出るからです。isVisibleframe.size でも代用できますが、型で弾くのが一番素直です。

NSApp.keyWindownil のケース

⌘T はメニューから叩く以外に、ウィンドウがフォーカスを失った状態で叩かれるケースもあります。その場合 keyWindownil になります。フォールバックとして「タブにせず普通に新規ドキュメントを開く」を入れておくと、最低限の機能性が落ちません。

guard let parent = NSApp.keyWindow else {
    NSDocumentController.shared.newDocument(nil)
    return
}

static func で書いている理由

@FocusedValue などインスタンス状態に依存しない処理なので、Commands 構造体のメソッドとして書いてもいいのですが、static func のほうが「グローバル副作用」を視覚化できて読みやすいです。Commands プロトコルの View 体は描画のたびに構築されるので、ここでインスタンス状態を持たせない原則に揃えています。

animationBehavior = .none を「全期間」付けない

mergeAsTab の中で一時的に .none にしていますが、これを起動時にデフォルトで .none に固定すると ウィンドウを閉じるときのアニメーションも消えるので体験が悪化します。あくまで「タブ合流の瞬間だけ無効化」が正解です。今回のコードは合流直前にしか触らないので副作用がありません。

まとめ

  • SwiftUI DocumentGroup には ⌘T 相当の標準 API がありません。newDocument(nil) で作ったウィンドウをこちら側から能動的にタブグループへ合流させるしかありません。
  • ちらつきを消す鍵は 同期検出ルート + animationBehavior = .none + orderOut + tabGroup.addWindow の合わせ技です。どれか一つでも欠けるとどこかで「独立ウィンドウとしての姿が一瞬見える」現象が出ます。
  • 合流後に tabGroup.selectedWindow = win + makeKeyAndOrderFront新タブをアクティブにします。Safari や Finder の自然な挙動に揃えるために忘れずに入れておきましょう。
  • AppKit 時代の NSWindowTabGroup の知識は、SwiftUI ベースのアプリでもまだ確実に役立ちます。

SwiftUI と AppKit のグレーゾーンに踏み込むと、SwiftUI のラッパーを通すよりも NSApp.windows を直接覗いて自分で世話したほうが結果が安定する場面がたまにあります。⌘T はその最たる例でした。

参考