WWDC Quick Look 💓 By SwiftGGTeam
AppKit App をモダン化する

AppKit App をモダン化する

元の動画を見る

ハイライト

Apple は macOS 27 で、Gesture Recognizers による統一入力モデル、mouseDown tracking loop を置き換える Control Events、NSWindowRestoration による状態復元、NSViewCornerConfiguration による同心円角 API を AppKit に導入しました。これにより AppKit App のインタラクション、状態管理、視覚スタイルが SwiftUI や UIKit とそろいます。

主要内容

AppKit は macOS 上で最も長い歴史を持つ UI フレームワークで、多くの中核 API は NeXT 時代から続いています。年季の入った Mac App を保守しているなら、次のようなコードを見たことがあるはずです。mouseDown: をオーバーライドして独自の tracking loop を書く。マウスがボタン領域から出たかどうかを知るためだけに NSButton をサブクラス化する。ウィンドウを閉じてもう一度開いた時、ユーザーがさっき見ていた画像を自分で探し直さなければならない。

これらのコードは当時は正しかったものの、今では負担です。SwiftUI と UIKit は Gesture Recognizers でインタラクションを処理し、宣言的な状態管理で UI を構築します。一方で AppKit は、30年前のパターンをまだ多く使っています。より実際的な問題は、AppKit ウィンドウに SwiftUI View を埋め込む時に起こります。2つのイベントモデルがぶつかり、マウスイベントが AppKit の tracking loop に飲み込まれ、SwiftUI の Gesture Recognizers が永遠に受け取れないことがあります。

Apple は今年 AppKit を置き換えるのではなく、3つのことを行いました。SwiftUI と UIKit ですでに実証済みのパターンを持ち込み、古いコードを段階的に移行できるようにしつつ、AppKit の柔軟性を保っています。

1つ目は、統一入力モデルです。 iOS から来た Gesture Recognizers が、AppKit の第一級市民になりました。単純なクリックのために mouseDown: をオーバーライドしたり、ドラッグ状態を維持したり、メニュー表示を自前処理したりする必要はありません。NSClickGestureRecognizerNSPanGestureRecognizerNSView に直接付けられ、SwiftUI の onTapGesture と同じ基盤モデルで動きます。つまり、AppKit と SwiftUI を混在させた時に、イベントを奪い合いにくくなります。

2つ目は、Control Events です。 UIKit 開発者になじみのある addTarget(_:action:for:) が AppKit に来ました。以前は、ユーザーがボタン上でマウスを押した後、ボタン領域の外で離したことを知るには、NSButton をサブクラス化し、3つのマウスメソッドをオーバーライドし、mouseDownInside 状態を自分で管理する必要がありました。今は .trackingEndedOutside の1行で済み、サブクラスすら不要です。さらに API シグネチャは UIKit と完全に同じなので、iOS 開発者が macOS に移る時の学習コストはほぼありません。

3つ目は、状態復元です。 macOS ユーザーは App が自分の状態を覚えていることに慣れています。再起動後もウィンドウが元の位置にあり、サイドバーは展開されたままで、さっき見ていた画像も開かれている。この実装には以前、多くのボイラープレートが必要で、開発者が省略してしまうこともありました。macOS 27 は NSWindowRestoration の流れを3ステップに簡略化します。ウィンドウに識別子を付け、終了前に重要な状態をエンコードし、起動時にデコードして復元します。夜間のシステム自動更新で再起動しても、ユーザーの文脈は失われません。

これらの変更により、AppKit App は現代の macOS エコシステムの中で競争力を保てます。引き続き NSView でカスタム描画し、CALayer で複雑なアニメーションを組めますが、インタラクション、状態管理、視覚スタイルはようやくシステムの他の部分とそろいました。

詳細

mouseDown から現代的な入力モデルへ

多くの AppKit 開発者はいまだに mouseDown: と tracking loop でクリック、ドラッグ、選択を処理しています。このパターンは Mac の誕生時から存在しますが、根本的な問題があります。SwiftUI や UIKit の Gesture Recognizers と互換性がないのです。

Mac 上では AppKit、SwiftUI、Mac Catalyst(UIKit)が同時に動きます。3つのフレームワークには、イベント処理の共通言語が必要です。Gesture Recognizers がその橋渡しになります。

講演者は、選択追跡のためにまだ mouseDown: をオーバーライドしているなら、NSCollectionViewItemNSTableRowViewselected 属性へ切り替えるか、NSTableViewDelegateNSOutlineViewDelegate の選択変更コールバックを監視すべきだと述べています。(01:27)

右クリックメニューにも既存の方法があります。NSView.defaultMenu はすべてのインスタンスに共有メニューを与え、NSResponder.menu は各 responder に個別メニューを設定し、NSView.menuForEvent はイベントに応じてメニューを動的に作ります。3つの場面をすべてカバーしており、座標を自分で計算する必要はありません。

ドラッグも同様です。tableView(_:pasteboardWriterForRow:) から NSPasteboardItem を返せば、後続処理はシステムが行います。NSCollectionViewNSOutlineViewNSBrowser も対応する現代的なドラッグ delegate メソッドを持っています。(03:35)

func tableView(_ tableView: NSTableView,
        pasteboardWriterForRow row: Int) -> (any NSPasteboardWriting)? {
    let pasteboardItem = NSPasteboardItem()
    pasteboardItem.setString(..., forType: .string)
    return pasteboardItem
}

Control Events が AppKit に来る

UIKit 開発者にとって Control Events はなじみ深いものです。macOS 27 はそれを AppKit に持ち込みます。

以前は、ユーザーがボタンを押した後にマウスをボタン領域外へ移動して離したことを検出するには、NSButton をサブクラス化し、mouseDown:mouseDragged:mouseUp: をオーバーライドし、状態を自分で管理する必要がありました。今はボタンに .trackingEndedOutside Control Event を直接追加できます。サブクラスは不要です。(04:55)

let button = NSButton()
button.addTarget(
    self,
    action: #selector(trackingEndedOutsideHandler),
    for: .trackingEndedOutside
)

重要ポイント:

  • addTarget(_:action:for:) は UIKit の API シグネチャと一致しており、フレームワーク間移行の学習コストが低いです
  • .trackingEndedOutside は、ユーザーがボタン上でマウスを押したが、ボタン領域外で離したことを表します
  • NSButton をサブクラス化する必要はなく、インスタンスに target と action を直接登録できます
  • 多くの Control Events は macOS 10.11 以降すでに利用可能です

Gesture Recognizers とイベント透過

Gesture Recognizers は view 階層上で動作します。兄弟 view がボタンを覆っていると、その view がマウスイベントを静かに消費してしまうことがあります。

覆っているレイヤーが視覚装飾にすぎず、イベントを受け取る必要がない場合は、hitTest(_:) をオーバーライドして nil を返します。これにより、イベントは下にある content view へ透過します。(05:44)

override func hitTest(_ point: NSPoint) -> NSView? {
    return nil
}

重要ポイント:

  • hitTest(_:) はどの view がマウスイベントを受け取るかを決めます
  • nil を返すことは、「ここにはこのイベントを処理する view がない」という意味です
  • イベントは覆われた view へ続けて渡されます
  • その overlay 自身は、どのマウスイベントも受け取れなくなります

キーボードナビゲーションとステータスバー拡張インターフェイス

Mac ユーザーにはキーボードを多用する人が多くいます。Tab キーでコントロール間を循環するフォーカス順序を key view loop と呼びます。

ウィンドウで autorecalculatesKeyViewLoop = true を設定すると、view 階層が変化するたびにシステムがフォーカス順序を自動的に再計算します。設定しない場合、ループ全体を手動で管理する必要があります。(06:24)

window.autorecalculatesKeyViewLoop = true

ステータスバーアイコン(status item)もキーボードナビゲーションに参加できます。ステータスバーアイコンをクリックしてカスタムウィンドウを表示する場合は、その UI のライフサイクルを AppKit に伝える必要があります。そうしないとキーボードフォーカスが予期せず飛ぶことがあります。

expandedInterfaceDelegate を設定し、3つのメソッドを実装します。セッション開始時にウィンドウを表示し、終了時に非表示にし、ユーザーがアクションを選んだ後にセッションをキャンセルします。(07:37)

@main class LightAppDelegate: NSObject, NSApplicationDelegate {
    lazy var lightStatusItem: NSStatusItem = { ... }()

    func applicationDidFinishLaunching(_ notification: Notification) {
        lightStatusItem.expandedInterfaceDelegate = self
    }
}

extension LightAppDelegate: NSStatusItemExpandedInterfaceDelegate {
    func statusItem(_ statusItem: NSStatusItem, didBegin session:
                    NSStatusItemExpandedInterfaceSession) {
        // ウィンドウを表示する
    }

    func statusItemDidEndExpandedInterfaceSession(
        _ statusItem: NSStatusItem, animated: Bool) {
        // ウィンドウを非表示にする
    }

    func selectedAction() {
        // 選択されたアクションを実行する
        lightStatusItem.expandedInterfaceSession?.cancel()
    }
}

重要ポイント:

  • expandedInterfaceDelegate は status item 作成時に設定します
  • didBegin でカスタムウィンドウを表示し、didEnd で取り除きます
  • cancel() は拡張インターフェイスセッションを閉じるようシステムへ要求します
  • フォーカスが自然に別の場所へ移った場合、システムが自動的にセッションをキャンセルします

優雅な終了と状態復元

ユーザーはいつでも App を終了できるべきです。システムが夜間更新で再起動する時、App はシャットダウンを妨げるべきではありません。

App が sheet を表示していると、ウィンドウが閉じられず、App も終了できないことがあります。preventsApplicationTerminationWhenModal の既定値は true で、これはデータ損失を防ぐためです。ただし、ユーザーの介入が不要な sheet では false に設定すべきです。(09:45)

window.preventsApplicationTerminationWhenModal = false

状態復元は3ステップです。復元を有効にし、状態をエンコードし、状態をデコードします。

第1ステップとして、ウィンドウに identifier と autosave name を設定し、isRestorable を有効にして、restorationClass を指定します。(10:18)

@MainActor class MainWindowController: NSWindowController, NSWindowDelegate {
    convenience init() {
        let window = NSWindow( ... )
        window.identifier = NSUserInterfaceItemIdentifier(WindowIdentifiers.mainWindow)
        window.setFrameAutosaveName(WindowIdentifiers.mainWindow)
        window.isRestorable = true
        window.restorationClass = WindowRestorationHandler.self
    }
}

重要ポイント:

  • identifier はウィンドウの一意識別子で、復元時の照合に使われます
  • setFrameAutosaveName により、ウィンドウが画面上の位置とサイズを覚えます
  • ドキュメントウィンドウには autosave name を設定する必要はありません
  • restorationClass はウィンドウ復元を担当するクラスを指定します

第2ステップとして、encodeRestorableState で UI を再構築するために必要な最小情報を保存します。ドキュメントデータやデータベース内容は保存せず、UI 状態だけを保存します。(11:04)

override func encodeRestorableState(with coder: NSCoder) {
    super.encodeRestorableState(with: coder)
    coder.encode(selectedProduct?.identifier.uuid,
                forKey: RestorationKeys.productIdentifier)
}

view 階層が変化したら invalidateRestorableState() を呼び、状態が古くなったため次回終了前に再エンコードが必要だとシステムに伝えます。(11:50)

splitViewController.onProductSelected = { [weak self] product in
    self?.invalidateRestorableState()
}

第3ステップとして、WindowRestorationHandler でウィンドウを復元します。(12:26)

class WindowRestorationHandler: NSObject, NSWindowRestoration {
    static func restoreWindow(
        withIdentifier identifier: NSUserInterfaceItemIdentifier,
        state: NSCoder,
        completionHandler: @escaping (NSWindow?, Error?) -> Void
    ) {
        if identifier == .mainWindow, let window = appDelegate.mainWindowController?.window {
            completionHandler(window, nil)
        } else if identifier == .imageWindow {
            let controller = ImageWindowController()
            appDelegate.imageWindowControllers.append(controller)
            completionHandler(controller.window, nil)
        } else {
            completionHandler(nil, error)
        }
    }
}

重要ポイント:

  • 復元が必要な各ウィンドウごとに、このメソッドが1回呼ばれます
  • 必ず completionHandler を呼ぶ必要があります。AppKit はすべてのウィンドウ復元が終わるのを待ちます
  • メインウィンドウは通常すでに存在するため、その window を直接渡します
  • 他のウィンドウは controller を新規作成し、その window を渡します
  • 復元に失敗した場合も completionHandler を呼び、エラーを渡します

最後に、window controller の restoreState で状態をデコードし、UI を復元します。(13:29)

override func restoreState(with coder: NSCoder) {
    super.restoreState(with: coder)
    if let productId = coder.decodeObject(
        of: [NSString.self],
        forKey: RestorationKeys.productIdentifier) as? String {
        splitViewController?.selectedProductId = productId
    }
}

重要ポイント:

  • coder には、以前 encodeRestorableState で書き込んだすべてのキー値が含まれています
  • identifier をデコードした後、対応する view controller へ渡します
  • データ本体はデータベースやファイルシステムから読みます。復元状態にエンコードしてはいけません

視覚更新:Liquid Glass と同心円角

macOS 26 で導入された Liquid Glass マテリアルは、macOS 27 でも進化します。多くの更新は自動です。サイドバーはウィンドウ端まで伸び、選択項目は semibold になり、コンテンツは引き続きマテリアルの背後を流れます。

macOS 27 は新しいインタラクション効果を追加します。クリック時にガラスがわずかに跳ね返り、ユーザーへフィードバックを与えます。この効果はコントロールとボタン向けであり、使いすぎるべきではありません。(15:06)

同心円角(concentricity)は macOS 27 の新 API です。子 view が親コンテナの角に近い時、その corner radius が親コンテナの曲線に自動的に合います。(16:11)

class LocalWeatherView: NSView {
    override var cornerConfiguration: NSViewCornerConfiguration? {
        let radius: NSViewCornerRadius = .containerConcentric(minimumCornerRadius)
        return .uniformCorners(radius: radius)
    }
}

重要ポイント:

  • cornerConfigurationNSView のオーバーライド可能な属性です
  • .containerConcentric はコンテナ view から半径を自動計算します
  • minimumCornerRadius は、角から遠い場合でも最小角丸を確保します
  • .uniformCorners は4つの角すべてに同じ半径を使います
  • システムは view とコンテナ角の距離を自動計算し、近いほど曲線が一致するようにします

重要な示唆

1. 古い App の mouseDown を一掃する

mouseDown:mouseDragged:mouseUp: を全体検索します。クリック、ドラッグ、選択を処理するだけのコードなら、Gesture Recognizers または Control Events に置き換えます。コード量を減らせるだけでなく、SwiftUI を埋め込んだ時にイベントが衝突しにくくなります。

入り口:NSClickGestureRecognizerNSPanGestureRecognizerNSControl.addTarget(_:action:for:)

2. すべてのウィンドウに状態復元を追加する

ユーザーが Mac を再起動した後、App は終了前の見た目に戻るべきです。メインウィンドウ、設定ウィンドウ、画像ビューアウィンドウそれぞれに NSWindowRestoration 実装を追加します。保存するのは UI 状態(どの項目が選択されているか、どこまでスクロールしたか)だけで、ドキュメントデータは保存しません。

入り口:window.isRestorable = trueencodeRestorableState(with:)restoreWindow(withIdentifier:state:completionHandler:)

3. 同心円角で視覚的な違和感を直す

角丸を持つすべてのカスタム view を確認します。それらが別の角丸コンテナの角付近にある場合、手動計算した cornerRadiusNSViewCornerConfiguration へ置き換えます。Maps のローカル天気 view が典型例です。

入口:cornerConfiguration 属性をオーバーライドし、.uniformCorners(radius: .containerConcentric(minimum)) を返します

4. ステータスバーアイコンをキーボードナビゲーション対応にする

App にメニューバーアイコンがある場合、「キーボードナビゲーション」が有効な時に Tab キーでフォーカスできるか確認します。クリック後にカスタムウィンドウを表示する場合は NSStatusItemExpandedInterfaceDelegate を実装し、キーボードフォーカスが正しく出入りするようにします。

入り口:statusItem.expandedInterfaceDelegate = self

5. 非テキスト view にネイティブテキスト選択を追加する

macOS 27 の新しい NSTextSelectionManager は、macOS らしい双方向選択、ドラッグ、切り替えの挙動を任意のカスタム view に持ち込めます。カスタムテキストレンダリング view を持つ App では、選択ロジック全体を自作する必要がなくなります。

入口:NSTextSelectionManager を view に付け、text selection data source を設定します

関連 Session

  • SwiftUI + AppKit 相互運用 — AppKit ウィンドウに SwiftUI View を埋め込み、イベントモデルがどのように統一されるかを理解します
  • UIKit のモダン化 — UIKit 側のモダン化。多くの概念は AppKit にも直接対応します
  • Xcode 27 — AppKit プロジェクトの開発体験を改善する新しい Xcode の機能
  • SwiftUI — 宣言的フレームワークの設計思想。AppKit が Gesture Recognizers へ寄っていく理由を理解する助けになります
  • Swift — Swift 言語の新機能と、Swift 6 モードで多くの API を使う方法

コメント

GitHub Issues · utterances