ハイライト
AppKit と UIKit の
NSView/UIViewは、@Observableモデルのプロパティ変化に直接反応し、自動的に再描画できるようになりました。開発者がneedsDisplay = trueを手動で呼ぶ必要はありません。さらにNSGestureRecognizerRepresentable、NSHostingMenu、NSHostingSceneRepresentationの 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 を書き、ボタン、キーボードショートカット、パレットピッカーを含めます。そして NSHostingMenu で NSMenu として包み、AppKit のメインメニューバーに直接取り付けます。メニュー内のキーボードショートカットやアニメーション遷移はすべてネイティブに動作します。
(09:42)
古い AppDelegate に SwiftUI Scene を動的注入する
発表者は Menu Bar Extra を追加し、ユーザーがメインウインドウを開かなくても照明の明るさをすばやく調整できるようにしたいと考えています。SwiftUI の MenuBarExtra Scene と Settings Scene でこの機能を書き、applicationWillFinishLaunching 内で NSHostingSceneRepresentation を使って既存の NSApplicationDelegate に動的に注入します。
アプリを @main と App プロトコルの構造へ変える必要はありません。古いアーキテクチャはそのままに、新機能では 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
}
次に、カスタム NSSliderCell の drawKnob 内で model.hue を直接読みます。AppKit はこのアクセスを自動的に追跡し、hue が変わったときに再描画を呼び出します。
対応する AppKit メソッドは次のとおりです。
NSView.draw(_:)NSView.updateConstraints()NSView.layout()NSView.updateLayer()NSViewControllerの対応メソッド
UIKit の対応範囲はさらに広く、UIButton、UICollectionViewCell なども含まれます。
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 が渡され、stroke や fill などの命令を直接呼べます。既存の CoreGraphics コードは withCGContext を通じて再利用できます。
ポイント:
@Animatableは構造体を SwiftUI のアニメーションシステムに対応させる@AnimatableIgnoredはmodel参照のようにアニメーション補間に参加しないプロパティをマークするCanvasのクロージャシグネチャは(GraphicsContext, CGSize) -> Void.contentShape(Circle())はヒットテスト領域を円形内に制限する
SwiftUI ビューを AppKit のビュー階層に埋め込む
(07:21)
let hostingView = NSHostingView(
rootView: HSBColorPicker(model: model)
)
NSHostingView は NSView のサブクラスなので、任意の AppKit ビュー階層へ直接配置できます。ColorModel はすでに @Observable なので、データ変化は追加の橋渡しコードなしで SwiftUI ビューに自動同期されます。
ポイント:
NSHostingViewはNSViewを継承しており、通常の 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)
}
}
ポイント:
NSHostingMenuはNSMenuのサブクラスで、SwiftUIViewをルートビューとして受け取る- 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は SwiftUISceneを AppKit が認識できる形に包むapplicationWillFinishLaunching内でaddSceneRepresentationを呼び、注入を完了するscenes.environment.openSettings()は設定ウインドウを開くアクションを公開し、古いコードの@IBActionから呼び出せるMenuBarExtraのisInserted引数はメニューバーアイコンの表示/非表示を制御する
重要な示唆
1. 古い macOS ツールアプリに SwiftUI 設定パネルを追加する
多くの古いアプリの設定画面は XIB や手書きコードで作られており、保守コストが高くなりがちです。SwiftUI の Settings Scene として書き直し、NSHostingSceneRepresentation で既存の AppDelegate に注入します。設定項目は Form と TabView で整理でき、コード量は元の XIB の半分ほどにできます。
入口 API: NSHostingSceneRepresentation { Settings { ... } }
2. 複雑なカスタム描画を SwiftUI Canvas に移す
CoreGraphics で書かれたカスタムチャートやカラーピッカーがあるなら、SwiftUI の Canvas への移行を検討できます。描画ロジックはほとんど変えずに済みます。Canvas の GraphicsContext は CGContext と概念が近く、withCGContext でネイティブコンテキストを直接取得して古いコードを再利用できます。
入口 API: Canvas { context, size in ... }
3. SwiftUI でメニューバーツールを作る
多くの開発者は、アプリ全体の構造を変えずにメニューバーへのショートカットを追加したいと考えています。今は既存の NSApplicationDelegate に MenuBarExtra 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