WWDC Quick Look 💓 By SwiftGGTeam
SwiftUI を AppKit と UIKit で使う

SwiftUI を AppKit と UIKit で使う

元の動画を見る

ハイライト

AppKit と UIKit の NSView/UIView は、@Observable モデルのプロパティ変化に直接反応し、自動的に再描画できるようになりました。開発者が needsDisplay = true を手動で呼ぶ必要はありません。さらに NSGestureRecognizerRepresentableNSHostingMenuNSHostingSceneRepresentation の 3 つの API により、既存プロジェクトの全体アーキテクチャを変えずに、SwiftUI コンポーネント、ジェスチャ、Scene を段階的に埋め込めます。

主要内容

NSView を手動更新するつらさ

macOS の照明制御アプリを保守していると想像してください。画面には Hue、Saturation、Brightness を制御する 3 つの NSSlider があります。Hue スライダーを動かすと、Saturation と Brightness スライダーのトラック色も変える必要があります。トラック色は現在選択されている Hue 値に依存するからです。

AppKit では、これは大量の定型コードを意味します。モデルのプロパティが変わるたびに、影響を受けるビューをすべて手動で見つけ、ひとつずつ needsDisplay = true を設定します。3 つのスライダーが互いに影響し合うため、コードはすぐに絡み合います。

(03:10)

@Observable の自動追跡が AppKit に来る

Apple は Observation フレームワークの自動追跡機能を AppKit と UIKit に拡張しました。モデルクラスに @Observable マクロを付け、draw(_:)updateConstraints()layout()updateLayer() の中でモデルのプロパティを読めば、フレームワークが自動的に依存関係を登録します。プロパティが変わると、ビューは自動的に再描画されます。

描画ロジックを変える必要はありません。これまで通り描くだけで、もう needsDisplay = true を書かなくてよくなります。

(03:38)

どんなときにコンポーネントを SwiftUI へ移すべきか

発表者は照明アプリに新しい円形カラーピッカーを追加したいと考えています。外周は Hue リング、内側の左右半円は Saturation と Brightness、中央には色のプレビューがあります。描画ロジックも操作方法も全体的に書き直す必要があります。

このような「コンポーネントをゼロから作り直す」場面こそ、SwiftUI を導入する最適なタイミングです。SwiftUI を使うためだけにアプリ全体を書き直す必要はありません。新しく実装する必要がある場所から切り込めば十分です。

(05:52)

既存のジェスチャ認識器を SwiftUI に入れる

発表者には、アプリの他の場所ですでに使っているカスタム Force Click ジェスチャ認識器があります。今度はこの機能を新しい SwiftUI カラーピッカーにも追加したいと考えています。trackpad を強く押すと Saturation と Brightness を 100% にリセットする機能です。

NSGestureRecognizerRepresentable で包めば、ジェスチャロジックを書き直すことなく、.gesture() で SwiftUI ビューへ取り付けられます。このジェスチャは SwiftUI ネイティブの DragGesture と共存でき、互いに干渉しません。

(08:14)

SwiftUI でメニューを書き、AppKit のメインメニューに取り付ける

Force Click はすべての Mac で使えるわけではありません。たとえば Magic Mouse や MacBook Neo の trackpad では使えません。そこで発表者は、代替手段としてメニュー項目を追加することにしました。

SwiftUI で ColorMenu を書き、ボタン、キーボードショートカット、パレットピッカーを含めます。そして NSHostingMenuNSMenu として包み、AppKit のメインメニューバーに直接取り付けます。メニュー内のキーボードショートカットやアニメーション遷移はすべてネイティブに動作します。

(09:42)

古い AppDelegate に SwiftUI Scene を動的注入する

発表者は Menu Bar Extra を追加し、ユーザーがメインウインドウを開かなくても照明の明るさをすばやく調整できるようにしたいと考えています。SwiftUI の MenuBarExtra Scene と Settings Scene でこの機能を書き、applicationWillFinishLaunching 内で NSHostingSceneRepresentation を使って既存の NSApplicationDelegate に動的に注入します。

アプリを @mainApp プロトコルの構造へ変える必要はありません。古いアーキテクチャはそのままに、新機能では SwiftUI Scene を使えます。

(11:36)

詳細

AppKit における @Observable の自動更新

(03:38)

まずデータモデルを @Observable に変えます。

import Observation

@Observable @MainActor
final class ColorModel {
    var hue: Double = 0.6
    var saturation: Double = 1.0
    var brightness: Double = 1.0
}

次に、カスタム NSSliderCelldrawKnob 内で model.hue を直接読みます。AppKit はこのアクセスを自動的に追跡し、hue が変わったときに再描画を呼び出します。

対応する AppKit メソッドは次のとおりです。

  • NSView.draw(_:)
  • NSView.updateConstraints()
  • NSView.layout()
  • NSView.updateLayer()
  • NSViewController の対応メソッド

UIKit の対応範囲はさらに広く、UIButtonUICollectionViewCell なども含まれます。

macOS 15 と iOS 18 まで後方互換するには、Info.plist に対応する key を追加します。

  • macOS 15: NSObservationTrackingEnabled
  • iOS 18: UIObservationTrackingEnabled

2026 年の新しいシステムではデフォルトで有効になっており、設定は不要です。

ポイント:

  • @Observable マクロはクラス内のすべての var プロパティを自動的に観測システムへ参加させる
  • 対応メソッド内でプロパティを読むだけで、依存関係が自動的に作られる
  • 後方互換には Info.plist で手動スイッチを有効にする必要がある

SwiftUI の Canvas で円形カラーピッカーを実装する

(06:28)

import SwiftUI
import Observation

@Animatable
struct HSBColorPicker: View {
    var hue: Double
    var saturation: Double
    var brightness: Double
    @AnimatableIgnored var model: ColorModel

    init(model: ColorModel) {
        self.model = model
        self.hue = model.hue
        self.saturation = model.saturation
        self.brightness = model.brightness
    }

    var body: some View {
        Canvas { context, size in
            let metrics = PickerMetrics(size: size)
            drawPicker(in: &context, metrics: metrics,
                       hue: hue, saturation: saturation, brightness: brightness)
        }
        .contentShape(Circle())
        .modifier(ColorPickerDragGesture(model: model))
        .aspectRatio(1, contentMode: .fit)
    }
}

Canvas は AppKit の drawRect に似た即時モード描画 API を提供します。再描画のたびに GraphicsContext が渡され、strokefill などの命令を直接呼べます。既存の CoreGraphics コードは withCGContext を通じて再利用できます。

ポイント:

  • @Animatable は構造体を SwiftUI のアニメーションシステムに対応させる
  • @AnimatableIgnoredmodel 参照のようにアニメーション補間に参加しないプロパティをマークする
  • Canvas のクロージャシグネチャは (GraphicsContext, CGSize) -> Void
  • .contentShape(Circle()) はヒットテスト領域を円形内に制限する

SwiftUI ビューを AppKit のビュー階層に埋め込む

(07:21)

let hostingView = NSHostingView(
    rootView: HSBColorPicker(model: model)
)

NSHostingViewNSView のサブクラスなので、任意の AppKit ビュー階層へ直接配置できます。ColorModel はすでに @Observable なので、データ変化は追加の橋渡しコードなしで SwiftUI ビューに自動同期されます。

ポイント:

  • NSHostingViewNSView を継承しており、通常の AppKit ビューのように使える
  • データモデルを先に @Observable へ移しておくと、SwiftUI と AppKit の混在がほぼ摩擦なく行える

NSGestureRecognizer を SwiftUI で再利用する

(08:14)

import SwiftUI
import AppKit

struct ForceClickReset: NSGestureRecognizerRepresentable {
    var model: ColorModel

    func makeNSGestureRecognizer(context: Context) -> ForceClickGestureRecognizer {
        ForceClickGestureRecognizer()
    }

    func handleNSGestureRecognizerAction(
        _ recognizer: ForceClickGestureRecognizer,
        context: Context
    ) {
        withAnimation {
            model.saturation = 1
            model.brightness = 1
        }
    }
}

final class ForceClickGestureRecognizer: NSGestureRecognizer {
    private var didActivate = false

    override func pressureChange(with event: NSEvent) {
        if event.stage >= 2 && !didActivate {
            didActivate = true
            state = .ended
        }
    }

    override func mouseDown(with event: NSEvent) {
        didActivate = false
        state = .possible
    }

    override func mouseUp(with event: NSEvent) {
        didActivate = false
        state = .possible
    }
}

SwiftUI ビューではネイティブジェスチャのように使います。

HSBColorPicker(model: model)
    .gesture(ForceClickReset(model: model))

ポイント:

  • makeNSGestureRecognizer は既存のジェスチャ認識器サブクラスを返す
  • handleNSGestureRecognizerAction はジェスチャ発火時に呼ばれる
  • モデル変更を withAnimation で包むと、SwiftUI がアニメーション遷移を自動処理する
  • SwiftUI ネイティブの DragGesture と共存できる

SwiftUI で AppKit のメインメニューを書く

(09:42)

struct ColorMenu: View {
    var model: ColorModel

    private static let hues: [(name: String, hue: Double)] = [
        ("赤", 0), ("黄", 0.17), ("緑", 0.33),
        ("シアン", 0.5), ("青", 0.67), ("紫", 0.83),
    ]

    var body: some View {
        Button("最大輝度") {
            withAnimation {
                model.saturation = 1
                model.brightness = 1
            }
        }
        .keyboardShortcut(.upArrow, modifiers: [.command, .shift])

        Button("消灯") {
            withAnimation {
                model.brightness = 0
            }
        }
        .keyboardShortcut(.downArrow, modifiers: [.command, .shift])

        Divider()

        Picker("色", selection: Bindable(model).hue) {
            ForEach(Self.hues, id: \.hue) { entry in
                Label(entry.name, systemImage: "circle.fill")
                    .tint(Color(hue: entry.hue, saturation: 1, brightness: 1))
                    .tag(entry.hue)
            }
        }
        .pickerStyle(.palette)
    }
}

AppKit のメインメニューに取り付けます。

@MainActor
class AppDelegate: NSObject, NSApplicationDelegate {
    let colorModel = ColorModel()

    func setupMainMenu() {
        let mainMenu = NSMenu()

        let colorMenu = NSHostingMenu(rootView: ColorMenu(model: colorModel))
        colorMenu.title = "色"

        let colorMenuItem = NSMenuItem()
        colorMenuItem.submenu = colorMenu
        mainMenu.addItem(colorMenuItem)
    }
}

ポイント:

  • NSHostingMenuNSMenu のサブクラスで、SwiftUI View をルートビューとして受け取る
  • SwiftUI の keyboardShortcut 修飾子は AppKit のメニューショートカットにマッピングされる
  • Bindable(model).hue@Observable プロパティへの双方向バインディングを作る
  • .pickerStyle(.palette) はメニュー内でピッカーをパレット表示にする

古い AppDelegate に SwiftUI Scene を動的注入する

(11:36)

@MainActor
class AppDelegate: NSObject, NSApplicationDelegate {
    let model = AppModel()
    var openSettingsAction: (() -> Void)?

    func applicationWillFinishLaunching(_ notification: Notification) {
        let scenes = NSHostingSceneRepresentation {
            LightMenuBarExtra(appModel: model)
            LightSettings(appModel: model)
        }
        NSApplication.shared.addSceneRepresentation(scenes)
        openSettingsAction = {
            scenes.environment.openSettings()
        }
    }

    @IBAction func openSettings(_ sender: Any?) {
        openSettingsAction?()
    }
}

struct LightMenuBarExtra: Scene {
    var appModel: AppModel

    var body: some Scene {
        MenuBarExtra("ライトミックス", systemImage: "lightbulb.fill",
                     isInserted: Bindable(appModel).showMenuBarExtra) {
            MenuBarContent(appModel: appModel)
        }
        .menuBarExtraStyle(.window)
    }
}

struct LightSettings: Scene {
    var appModel: AppModel

    var body: some Scene {
        Settings {
            SettingsView(appModel: appModel)
        }
    }
}

ポイント:

  • NSHostingSceneRepresentation は SwiftUI Scene を AppKit が認識できる形に包む
  • applicationWillFinishLaunching 内で addSceneRepresentation を呼び、注入を完了する
  • scenes.environment.openSettings() は設定ウインドウを開くアクションを公開し、古いコードの @IBAction から呼び出せる
  • MenuBarExtraisInserted 引数はメニューバーアイコンの表示/非表示を制御する

重要な示唆

1. 古い macOS ツールアプリに SwiftUI 設定パネルを追加する

多くの古いアプリの設定画面は XIB や手書きコードで作られており、保守コストが高くなりがちです。SwiftUI の Settings Scene として書き直し、NSHostingSceneRepresentation で既存の AppDelegate に注入します。設定項目は FormTabView で整理でき、コード量は元の XIB の半分ほどにできます。

入口 API: NSHostingSceneRepresentation { Settings { ... } }

2. 複雑なカスタム描画を SwiftUI Canvas に移す

CoreGraphics で書かれたカスタムチャートやカラーピッカーがあるなら、SwiftUI の Canvas への移行を検討できます。描画ロジックはほとんど変えずに済みます。CanvasGraphicsContextCGContext と概念が近く、withCGContext でネイティブコンテキストを直接取得して古いコードを再利用できます。

入口 API: Canvas { context, size in ... }

3. SwiftUI でメニューバーツールを作る

多くの開発者は、アプリ全体の構造を変えずにメニューバーへのショートカットを追加したいと考えています。今は既存の NSApplicationDelegateMenuBarExtra Scene を数行で直接追加できます。

入口 API: MenuBarExtra("タイトル", systemImage: "アイコン") { ... }

4. 既存の複雑なジェスチャ認識器を再利用する

圧力や複数指ジェスチャなど、時間をかけて調整したカスタム NSGestureRecognizer があるなら、SwiftUI の Gesture プロトコルで書き直す必要はありません。NSGestureRecognizerRepresentable で包めばそのまま使えます。

入口 API: NSGestureRecognizerRepresentable プロトコルを実装する

5. まずモデル層を @Observable に移す

これはコストが低く、効果が大きい最初の一歩です。中核データモデルを @Observable に変えると、既存の NSView はすぐに自動更新能力を得ます。将来新しい UI を書くときにも、モデルはすでに SwiftUI から直接利用できる状態になっています。

入口 API: @Observable class MyModel { ... }

関連 Session

  • SwiftUI の新機能 — SwiftUI の新機能全体を概観し、この Session で使う Canvas、MenuBarExtra などの API の使い方をさらに理解します
  • ドラッグ&ドロップ — SwiftUI のドラッグ操作を詳しく学び、この Session のジェスチャシステムを補完します
  • SwiftData@Observable と組み合わせて完全なデータ駆動アーキテクチャを構築できる永続データ管理
  • AppKit modernization — AppKit の新機能とモダナイズの方向性
  • Bring multiple windows to your SwiftUI app (WWDC22) — 発表者が推薦する参考動画。SwiftUI Scene の使い方をより詳しく解説します

コメント

GitHub Issues · utterances