mcpField
M
macos-patterns

macos-patterns

github.com/fayazara開発685682 回インストール22 週間前に更新
リポジトリ

macos-patterns について

Web開発からネイティブ macOS アプリ開発に移る開発者向けのパターン集です。AIによる実用的な macOS コード生成を支援するため、ウィンドウ、メニュー、入力、OS連携など固有のAPIを扱います。

主な機能

  1. 01メニューバーアプリとポップオーバー
  2. 02Dockアイコンとアクティベーションポリシー
  3. 03ウィンドウレベル、フォーカス、画面動作
  4. 04ショートカット、ファイル選択、ペーストボード
  5. 05ドラッグ&ドロップ、ナビゲーション、OS連携

活用シーン

  1. 01メニューバーアプリと設定ウィンドウの実装
  2. 02フォーカスを奪わないフローティングパネルの作成
  3. 03複数ディスプレイでのウィンドウ配置
  4. 04ネイティブアプリへのグローバルショートカット追加

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/fayazara/macos-app-skills --skill macos-patterns

インストール後、Claude Code で/macos-patternsを入力して読み込まれたか確認します。

詳細

Web 開発者向け macOS ネイティブパターン

これは、Web には相当するものがない macOS 固有のパターンに関するリファレンスガイドです。macOS ネイティブアプリを構築する際、Web 開発から移ってきた人がつまずきやすいポイントをまとめています。macOS コードを生成する前に AI がこれを参照することで、実際には動作しないパターンを自信満々に出力するのを防げます。

メニューバーアプリ

方法は 2 つあります。シンプルなメニューには MenuBarExtra、詳細な制御には NSStatusItem を使用します。

SwiftUI MenuBarExtra(シンプル)
@main
struct MyApp: App {
    var body: some Scene {
        MenuBarExtra("MyApp", image: "MenuBarIcon") {
            MenuBarView()
        }
    }
}

これにより、メニューバーのみのアプリが作成されます。メニューの内容は標準的な SwiftUI ビューです(通常のメニューよりも豊かな UI を持つポップオーバー形式のメニューバーアプリには、.window スタイルを使用します)。

MenuBarExtra("MyApp", image: "MenuBarIcon") {
    PopoverContentView()
}
.menuBarExtraStyle(.window)
AppKit NSStatusItem(詳細な制御)

カスタムメニュー、動的なアイコン、複雑なインタラクションには、次の方法を使用します。

let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
if let button = statusItem.button {
    let icon = NSImage(named: "MenuBarIcon")!
    icon.size = NSSize(width: 18, height: 18)
    icon.isTemplate = true  // CRITICAL: adapts to dark/light mode automatically
    button.image = icon
}

let menu = NSMenu()
menu.addItem(NSMenuItem(title: "Settings...", action: #selector(openSettings), keyEquivalent: ","))
statusItem.menu = menu

isTemplate = true は必須です。これを指定しないと、ライトモードでアイコンが見えなくなったり、ダークモードで正しく表示されなかったりします。

豊富なメニューバーコンテンツ向けの NSPopover

メニューバーアイコンに紐づくポップオーバーを表示する場合(Bartender、iStatMenus のようなもの):

let popover = NSPopover()
popover.contentSize = NSSize(width: 300, height: 400)
popover.behavior = .transient  // auto-closes when clicking outside
popover.contentViewController = NSHostingController(rootView: MyPopoverView())

// Show from the status item button:
if let button = statusItem.button {
    popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
}

アクティベーションポリシー -- Dock アイコンの切り替え

macOS アプリは、実行中に Dock アイコンと Cmd-Tab への表示を動的に切り替えられます。メニューバーのみのアプリは .accessory(Dock に表示されない)として起動し、設定などのウィンドウを開くときに一時的に .regular になります。

// At launch -- hide from Dock:
NSApp.setActivationPolicy(.accessory)

// When opening a window -- show in Dock:
NSApp.setActivationPolicy(.regular)
NSApp.activate(ignoringOtherApps: true)

// When closing the last window -- hide again:
NSApp.setActivationPolicy(.accessory)

複数のウィンドウを同時に開ける場合は、参照カウントを使用します。

@MainActor
enum AppActivationPolicy {
    private static var count = 0

    static func enter() {
        count += 1
        NSApp.setActivationPolicy(.regular)
        NSApp.activate(ignoringOtherApps: true)
    }

    static func leave() {
        count = max(0, count - 1)
        guard count == 0 else { return }
        Task { @MainActor in NSApp.setActivationPolicy(.accessory) }
    }
}

NSPanel と NSWindow

NSPanel は NSWindow のサブクラスで、フォーカスを奪うべきではない補助コンテンツ向けです。次の用途で使用します。

  • フローティングオーバーレイ(プレビューカード、録画インジケーター)
  • パレットとツールウィンドウ
  • インスペクター
  • ユーザーが別のアプリで作業している間も表示しておくべきもの

主な設定:

let panel = NSPanel(
    contentRect: .zero,
    styleMask: [.borderless, .nonactivatingPanel],  // Does NOT steal focus
    backing: .buffered,
    defer: false
)
panel.isOpaque = false
panel.backgroundColor = .clear
panel.hasShadow = false
panel.hidesOnDeactivate = false     // Stay visible when app loses focus
panel.isFloatingPanel = true        // Float above normal windows
panel.ignoresMouseEvents = true     // Clicks pass through to windows below
panel.collectionBehavior = [
    .canJoinAllSpaces,              // Visible on all virtual desktops
    .fullScreenAuxiliary,           // Visible over fullscreen apps
]

パネルに SwiftUI コンテンツを表示するには:

let hostingView = NSHostingView(rootView: MySwiftUIView())
panel.contentView = hostingView

必要に応じてフォーカス動作をオーバーライドします。

class MyPanel: NSPanel {
    override var canBecomeKey: Bool { true }     // Can receive keyboard input
    override var canBecomeMain: Bool { false }   // Never becomes the "main" window
}
画面キャプチャからの除外

スクリーンショットや録画に表示させたくないフローティング UI をアプリで表示する場合:

panel.sharingType = .none  // Invisible to screen capture APIs

ウィンドウレベル

macOS には、ウィンドウを OS 全体に対してどこに表示するかを制御する、複数階層のウィンドウレベルシステムがあります(アプリ内だけを対象とするものではありません)。

normal (0)           -- standard app windows
floating (3)         -- tool palettes, floating panels
modalPanel (8)       -- modal dialogs
mainMenu (24)        -- menu bar
screenSaver (1000)   -- above everything except...
CGShieldingWindowLevel() -- above absolutely everything (kiosk mode)

次のように設定します。

window.level = .floating
// or for extreme cases:
window.level = NSWindow.Level(rawValue: Int(CGShieldingWindowLevel()))
コレクション動作

ウィンドウと Spaces、フルスクリーン、Cmd-Tab との連動を制御します。

window.collectionBehavior = [
    .canJoinAllSpaces,       // Visible on all virtual desktops
    .fullScreenAuxiliary,    // Visible over fullscreen apps
    .stationary,             // Doesn't move with Space transitions
    .ignoresCycle,           // Hidden from Cmd-` window cycling
    .transient,              // Removed when app is hidden
]

画面の座標

macOS では左下原点の座標を使用します。Y 軸は Web とは逆向きです。

let screen = NSScreen.main!

// Full screen rectangle (includes menu bar and Dock area):
screen.frame  // e.g., (0, 0, 1728, 1117)

// Usable area (excludes menu bar and Dock):
screen.visibleFrame  // e.g., (0, 0, 1728, 1055)

// Notch detection (MacBook with notch has top safe area):
screen.safeAreaInsets.top > 0  // true on notch Macs

メニューバー上に配置する場合(ノッチのオーバーレイなど)は frame を使用します。通常のウィンドウ配置には visibleFrame を使用します。

Quartz と AppKit の Y 軸

Core Graphics / Quartz は左上原点、AppKit は左下原点を使用します。この 2 つの間で変換する場合:

let desktopFrame = NSScreen.screens.reduce(CGRect.null) { $0.union($1.frame) }
let appKitY = desktopFrame.maxY - quartzY - height  // Quartz → AppKit
let quartzY = desktopFrame.maxY - appKitY - height  // AppKit → Quartz
マルチモニター

画面が 1 台だけだと決めつけないでください。NSScreen.main が唯一のディスプレイではない場合も、必ず処理してください。

// Find the screen containing the mouse pointer:
let mouseLocation = NSEvent.mouseLocation
let screen = NSScreen.screens.first { $0.frame.contains(mouseLocation) }

// Find the screen containing a specific window:
let screen = window.screen

キーボードショートカット

用途に応じて 3 つの段階があります。

第 1 段階:SwiftUI のキーボードショートカット(アプリ内、フォーカス時)
Button("Settings") { openSettings() }
    .keyboardShortcut(",", modifiers: [.command])  // Cmd+,
第 2 段階:NSEvent モニター(アプリ全体またはグローバル)
// Local: fires only when YOUR app is active
let monitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in
    if event.keyCode == 53 { /* Escape */ return nil /* consume */ }
    return event  // pass through
}

// Global: fires even when ANOTHER app is active
let monitor = NSEvent.addGlobalMonitorForEvents(matching: .keyDown) { event in
    // Cannot consume the event -- read-only
}

// CRITICAL: Always remove monitors when done
NSEvent.removeMonitor(monitor)

ローカルモニターから nil を返すと、イベントが消費され(伝播が停止し)ます。グローバルモニターではイベントを消費できません。

第 3 段階:Carbon ホットキー(システム全体、アプリが非フォーカス時も動作)

真のグローバルキーボードショートカットを登録する唯一の方法です。Carbon API を使用します(1990 年代の API ですが、今も非推奨ではありません)。

import Carbon.HIToolbox

var hotKeyRef: EventHotKeyRef?
let hotKeyID = EventHotKeyID(signature: OSType(0x4D594150), id: 1) // "MYAP"

RegisterEventHotKey(
    UInt32(kVK_ANSI_1),      // Key code
    UInt32(optionKey),         // Modifiers
    hotKeyID,
    GetApplicationEventTarget(),
    0,
    &hotKeyRef
)

コールバックは任意のスレッドで実行されるため、必ずメインスレッドにディスパッチしてください。

DispatchQueue.main.async { self.handleHotKey() }

ファイルピッカー

開く(ファイル/ディレクトリを選択)
let panel = NSOpenPanel()
panel.allowedContentTypes = [.image, .movie]  // UTType filter
panel.allowsMultipleSelection = true
panel.canChooseDirectories = false
panel.canChooseFiles = true
panel.canCreateDirectories = true
panel.title = "Choose Images"

// Modal (blocks the thread):
if panel.runModal() == .OK {
    let urls = panel.urls
}

// Or sheet-modal (attached to a window, async):
panel.beginSheetModal(for: window) { response in
    guard response == .OK else { return }
    let urls = panel.urls
}
保存
let panel = NSSavePanel()
panel.allowedContentTypes = [.png]
panel.nameFieldStringValue = "screenshot.png"
panel.canCreateDirectories = true

panel.begin { response in
    guard response == .OK, let url = panel.url else { return }
    // write file to url
}
ディレクトリピッカー
let panel = NSOpenPanel()
panel.canChooseFiles = false
panel.canChooseDirectories = true
panel.directoryURL = URL(fileURLWithPath: NSHomeDirectory())

クリップボード/ペーストボード

macOS のペーストボードは、Web の navigator.clipboard とは根本的に異なります。複数のアイテムと複数の形式を格納できるコンテナーです。

let pasteboard = NSPasteboard.general

// CRITICAL: You MUST clear before writing. Forgetting this is a common bug.
pasteboard.clearContents()

// Write text:
pasteboard.setString("hello", forType: .string)

// Write image data:
let pngData = try Data(contentsOf: imageURL)
pasteboard.setData(pngData, forType: .png)

// Write a file URL (for Finder paste):
pasteboard.writeObjects([url as NSURL])

// Read (check what types are available):
if let string = pasteboard.string(forType: .string) { ... }
if let data = pasteboard.data(forType: .png) { ... }

1 つのペーストボードアイテムで複数の形式を提示できます。読み取る際は、優先順位に従って形式を確認してください。

ドラッグ&ドロップ

SwiftUI(シンプル)
// Make something draggable:
Image(nsImage: image)
    .draggable(fileURL) {
        // Custom drag preview
        Image(nsImage: image).frame(width: 100, height: 75)
    }

// Accept drops:
.dropDestination(for: URL.self) { urls, location in
    handleDroppedFiles(urls)
    return true
}
AppKit(詳細な制御)

ドロップ先として登録します。

class MyView: NSView {
    override init(frame: NSRect) {
        super.init(frame: frame)
        registerForDraggedTypes([.fileURL, .png, .tiff])
    }

    override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation {
        .copy  // Show the green + cursor
    }

    override func performDragOperation(_ sender: NSDraggingInfo) -> Bool {
        guard let urls = sender.draggingPasteboard.readObjects(forClasses: [NSURL.self]) as? [URL] else {
            return false
        }
        handleFiles(urls)
        return true
    }
}

NavigationSplitView + Inspector レイアウト

サイドバー、詳細、インスペクターを備えたレイアウトの、macOS 標準のパターンです。

struct ContentView: View {
    @State private var selection: Item?
    @State private var showInspector = true

    var body: some View {
        NavigationSplitView {
            SidebarView(selection: $selection)
                .navigationSplitViewColumnWidth(min: 200, ideal: 220, max: 300)
        } detail: {
            DetailView(item: selection)
        }
        .inspector(isPresented: $showInspector) {
            InspectorView(item: selection)
                .inspectorColumnWidth(min: 250, ideal: 280, max: 400)
        }
        .toolbar {
            ToolbarItem {
                Button { showInspector.toggle() } label: {
                    Image(systemName: "sidebar.right")
                }
            }
        }
    }
}

.inspector() 修飾子は、スライドして表示/非表示になるmacOS標準の右側パネルを作成します。レイアウトを自動的に管理し、ウインドウのツールバーと連携します。

ログイン時に起動

SMAppService を使用します(macOS 13 以降)。

import ServiceManagement

// Check status:
switch SMAppService.mainApp.status {
case .enabled: /* running at login */
case .requiresApproval: /* registered but user must approve in System Settings */
case .notRegistered, .notFound: /* not registered */
}

// Register:
try SMAppService.mainApp.register()

// Unregister:
try SMAppService.mainApp.unregister()

requiresApproval の状態は macOS 固有です。アプリはログイン時に起動するよう要求していますが、ユーザーが「システム設定」>「一般」>「ログイン項目」で手動で承認する必要があります。

開発中にログイン項目リストが不要に増えるのを避けるため、デバッグビルドでは必ず無効にしてください。

Quick Look プレビュー

あらゆるファイル(画像、PDF、動画、ドキュメント)をシステムの Quick Look パネルで表示します。

import QuickLookUI

class PreviewPresenter: NSObject, QLPreviewPanelDataSource {
    var url: URL?

    func show(url: URL) {
        self.url = url
        guard let panel = QLPreviewPanel.shared() else { return }
        NSApp.activate()  // MUST activate first or panel opens behind other windows
        panel.dataSource = self
        panel.reloadData()
        panel.makeKeyAndOrderFront(nil)
    }

    func numberOfPreviewItems(in panel: QLPreviewPanel!) -> Int { url != nil ? 1 : 0 }

    func previewPanel(_ panel: QLPreviewPanel!, previewItemAt index: Int) -> (any QLPreviewItem)! {
        url as? NSURL
    }
}

NSWorkspace — OS との連携

// Open URL in default browser:
NSWorkspace.shared.open(URL(string: "https://example.com")!)

// Reveal file in Finder (selects it):
NSWorkspace.shared.activateFileViewerSelecting([fileURL])

// Get the frontmost application:
let app = NSWorkspace.shared.frontmostApplication
let name = app?.localizedName  // e.g., "Safari"

// Check accessibility preferences:
NSWorkspace.shared.accessibilityDisplayShouldReduceMotion  // Respect "Reduce Motion"
NSWorkspace.shared.accessibilityDisplayShouldReduceTransparency

UserDefaults + @AppStorage

プログラムからのアクセス
// Write:
UserDefaults.standard.set(true, forKey: "autoSave")
UserDefaults.standard.set(0.8, forKey: "quality")

// Read (returns false/0/nil if key doesn't exist):
let autoSave = UserDefaults.standard.bool(forKey: "autoSave")
let quality = UserDefaults.standard.double(forKey: "quality")
リアクティブな SwiftUI バインディング
struct SettingsView: View {
    @AppStorage("autoSave") private var autoSave = false
    @AppStorage("quality") private var quality = 0.8

    var body: some View {
        Toggle("Auto Save", isOn: $autoSave)      // Auto-persisted
        Slider(value: $quality, in: 0...1)         // Auto-persisted
    }
}

@AppStorage は UserDefaults から自動的に値を読み書きし、値が変わると SwiftUI ビューの更新をトリガーします。

ScreenCaptureKit

画面の内容を Retina のネイティブ解像度でキャプチャします。

import ScreenCaptureKit

let content = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: false)
guard let display = content.displays.first else { return }

let filter = SCContentFilter(display: display, excludingWindows: [])
let config = SCStreamConfiguration()
let scale = CGFloat(filter.pointPixelScale)
config.width = Int(CGFloat(display.width) * scale)   // Points → pixels
config.height = Int(CGFloat(display.height) * scale)
config.showsCursor = false

let image = try await SCScreenshotManager.captureImage(contentFilter: filter, configuration: config)

ポイントからピクセルへの変換が重要です。ScreenCaptureKit はポイント単位で動作しますが、Retina 解像度で出力するには寸法をピクセル単位にする必要があります。

Web 開発者がよくする間違い

試してしまうことうまくいかない理由代わりにすること
フローティング UI に SwiftUI の Window シーンを使うフォーカスを奪い、Dock に表示され、透明にならない.nonactivatingPanel を指定した NSPanel を使います
ウインドウの順序を z-index のように考えるmacOS では、単一のスタックではなく、個別のウインドウレベルを使いますwindow.level を .floating、.screenSaver などに設定します
位置の指定に window.innerHeight を使うメニューバー、Dock、ノッチが考慮されません状況に応じて NSScreen.visibleFrame または .frame を使います
navigator.clipboard.writeText() を使うmacOS では、先に clearContents() を呼び出す必要があります書き込む前に必ず pasteboard.clearContents() を呼び出します
グローバルショートカットに addEventListener('keydown') を使うアプリにフォーカスがあるときだけ機能しますシステム全体で使うには Carbon の RegisterEventHotKey を使います
<input type="file"> のメンタルモデルをそのまま当てはめるmacOS にはモーダル、シートモーダル、非同期のファイルピッカーがあります適切な表示モードで NSOpenPanel を使います
モニターが1台であることを前提にするmacOS ユーザーは2〜3台のディスプレイを使うことがよくあります必ず NSScreen.screens を使い、適切なディスプレイを探します
あらゆる場面で CSS アニメーションを使うmacOS にはスプリング物理、視差効果の軽減、ウインドウ単位のアニメーションがありますSwiftUI の .animation(.spring(...)) を使い、accessibilityDisplayShouldReduceMotion を確認します

コメント