macos-patterns
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseNative macOS Patterns for Web Developers
面向Web开发者的macOS原生开发模式
This is a reference guide for the macOS-specific patterns that have no web equivalent. When building a native macOS app, these are the things that trip up everyone coming from web development. The AI should consult this before generating macOS code to avoid confidently producing patterns that don't work.
这是一份针对macOS专属模式的参考指南,这些模式在Web开发中没有对应概念。在构建macOS原生应用时,这些正是所有Web开发者容易踩坑的点。AI在生成macOS代码前应参考此文档,避免生成看似合理但实际无法运行的代码模式。
Menu Bar Apps
菜单栏应用
There are two approaches. Use for simple menus, for full control.
MenuBarExtraNSStatusItem有两种实现方式:简单菜单使用,需要完全控制时使用。
MenuBarExtraNSStatusItemSwiftUI MenuBarExtra (simple)
SwiftUI MenuBarExtra(简单版)
swift
@main
struct MyApp: App {
var body: some Scene {
MenuBarExtra("MyApp", image: "MenuBarIcon") {
MenuBarView()
}
}
}This creates a menu-bar-only app. The menu content is a standard SwiftUI view. For a popover-style menu bar app (richer UI than a plain menu), use the style:
.windowswift
MenuBarExtra("MyApp", image: "MenuBarIcon") {
PopoverContentView()
}
.menuBarExtraStyle(.window)swift
@main
struct MyApp: App {
var body: some Scene {
MenuBarExtra("MyApp", image: "MenuBarIcon") {
MenuBarView()
}
}
}这会创建一个仅显示在菜单栏的应用。菜单内容是标准SwiftUI视图。如果需要弹出式菜单栏应用(比普通菜单更丰富的UI),使用样式:
.windowswift
MenuBarExtra("MyApp", image: "MenuBarIcon") {
PopoverContentView()
}
.menuBarExtraStyle(.window)AppKit NSStatusItem (full control)
AppKit NSStatusItem(完全控制版)
For custom menus, dynamic icons, or complex interactions:
swift
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 = menuisTemplate = true适用于自定义菜单、动态图标或复杂交互场景:
swift
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 // 关键:自动适配深色/浅色模式
button.image = icon
}
let menu = NSMenu()
menu.addItem(NSMenuItem(title: "设置...", action: #selector(openSettings), keyEquivalent: ","))
statusItem.menu = menuisTemplate = trueNSPopover for Rich Menu Bar Content
NSPopover实现丰富菜单栏内容
For a popover attached to the menu bar icon (like Bartender, iStatMenus):
swift
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)
}用于实现附在菜单栏图标上的弹出面板(类似Bartender、iStatMenus):
swift
let popover = NSPopover()
popover.contentSize = NSSize(width: 300, height: 400)
popover.behavior = .transient // 点击外部自动关闭
popover.contentViewController = NSHostingController(rootView: MyPopoverView())
// 从状态栏按钮显示:
if let button = statusItem.button {
popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
}Activation Policy -- Dock Icon Toggling
激活策略——Dock图标切换
A macOS app can dynamically show/hide its Dock icon and Cmd-Tab presence at runtime. Menu-bar-only apps start as (invisible in Dock) and temporarily become when they open windows like Settings.
.accessory.regularswift
// 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)Use reference counting if multiple windows can be open simultaneously:
swift
@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) }
}
}macOS应用可在运行时动态显示/隐藏Dock图标和Cmd-Tab列表项。仅菜单栏应用启动时为(Dock中不可见),当打开设置等窗口时临时切换为。
.accessory.regularswift
// 启动时——隐藏Dock图标:
NSApp.setActivationPolicy(.accessory)
// 打开窗口时——显示Dock图标:
NSApp.setActivationPolicy(.regular)
NSApp.activate(ignoringOtherApps: true)
// 关闭最后一个窗口时——再次隐藏:
NSApp.setActivationPolicy(.accessory)如果有多个窗口可同时打开,使用引用计数:
swift
@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 vs NSWindow
NSPanel vs NSWindow
NSPanelNSWindow- Floating overlays (preview cards, recording indicators)
- Palettes and tool windows
- Inspectors
- Anything that should stay visible while the user works in another app
Key configuration:
swift
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
]To host SwiftUI content in the panel:
swift
let hostingView = NSHostingView(rootView: MySwiftUIView())
panel.contentView = hostingViewOverride focus behavior when needed:
swift
class MyPanel: NSPanel {
override var canBecomeKey: Bool { true } // Can receive keyboard input
override var canBecomeMain: Bool { false } // Never becomes the "main" window
}NSPanelNSWindow- 浮动覆盖层(预览卡片、录制指示器)
- 调色板和工具窗口
- 检查器
- 任何用户在其他应用工作时仍需保持可见的内容
关键配置:
swift
let panel = NSPanel(
contentRect: .zero,
styleMask: [.borderless, .nonactivatingPanel], // 不会抢夺焦点
backing: .buffered,
defer: false
)
panel.isOpaque = false
panel.backgroundColor = .clear
panel.hasShadow = false
panel.hidesOnDeactivate = false // 应用失去焦点时仍保持可见
panel.isFloatingPanel = true // 悬浮在普通窗口上方
panel.ignoresMouseEvents = true // 点击事件穿透到下方窗口
panel.collectionBehavior = [
.canJoinAllSpaces, // 在所有虚拟桌面可见
.fullScreenAuxiliary, // 在全屏应用上方可见
]在面板中托管SwiftUI内容:
swift
let hostingView = NSHostingView(rootView: MySwiftUIView())
panel.contentView = hostingView必要时覆盖焦点行为:
swift
class MyPanel: NSPanel {
override var canBecomeKey: Bool { true } // 可接收键盘输入
override var canBecomeMain: Bool { false } // 永远不会成为「主」窗口
}Screen Capture Exclusion
屏幕捕获排除
If your app shows floating UI that shouldn't appear in screenshots/recordings:
swift
panel.sharingType = .none // Invisible to screen capture APIs如果你的应用显示的浮动UI不应出现在截图/录屏中:
swift
panel.sharingType = .none // 对屏幕捕获API不可见Window Levels
窗口层级
macOS has a multi-tier window level system that controls where windows appear relative to the entire OS (not just your app):
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)Set via:
swift
window.level = .floating
// or for extreme cases:
window.level = NSWindow.Level(rawValue: Int(CGShieldingWindowLevel()))macOS有多层级窗口系统,控制窗口相对于整个系统(而非仅你的应用)的显示位置:
normal (0) -- 标准应用窗口
floating (3) -- 工具调色板、浮动面板
modalPanel (8) -- 模态对话框
mainMenu (24) -- 菜单栏
screenSaver (1000) -- 位于所有内容上方,除了...
CGShieldingWindowLevel() -- 绝对顶层(信息亭模式)设置方式:
swift
window.level = .floating
// 极端场景下:
window.level = NSWindow.Level(rawValue: Int(CGShieldingWindowLevel()))Collection Behaviors
集合行为
Control how windows interact with Spaces, fullscreen, and Cmd-Tab:
swift
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
]控制窗口与Spaces、全屏、Cmd-Tab的交互:
swift
window.collectionBehavior = [
.canJoinAllSpaces, // 在所有虚拟桌面可见
.fullScreenAuxiliary, // 在全屏应用上方可见
.stationary, // 切换Space时不移动
.ignoresCycle, // 不在Cmd-`窗口切换列表中显示
.transient, // 应用隐藏时自动移除
]Screen Geometry
屏幕几何
macOS uses bottom-left origin coordinates. The Y axis is flipped compared to the web.
swift
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 MacsUse when positioning over the menu bar (notch overlays). Use for normal window placement.
framevisibleFramemacOS使用左下角原点坐标系统,Y轴与Web开发相反。
swift
let screen = NSScreen.main!
// 全屏矩形(包含菜单栏和Dock区域):
screen.frame // 示例:(0, 0, 1728, 1117)
// 可用区域(排除菜单栏和Dock):
screen.visibleFrame // 示例:(0, 0, 1728, 1055)
// 刘海检测(带刘海的MacBook有顶部安全区域):
screen.safeAreaInsets.top > 0 // 带刘海的Mac上返回true在菜单栏(刘海覆盖区域)上方定位时使用,普通窗口定位时使用。
framevisibleFrameQuartz vs AppKit Y-Axis
Quartz vs AppKit Y轴
Core Graphics / Quartz uses top-left origin. AppKit uses bottom-left origin. When converting between the two:
swift
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 → QuartzCore Graphics / Quartz使用左上角原点,AppKit使用左下角原点。两者转换方式:
swift
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 → QuartzMulti-Monitor
多显示器
Never assume a single screen. Always handle the case where is not the only display:
NSScreen.mainswift
// 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永远不要假设只有一个屏幕。务必处理不是唯一显示器的情况:
NSScreen.mainswift
// 查找包含鼠标指针的屏幕:
let mouseLocation = NSEvent.mouseLocation
let screen = NSScreen.screens.first { $0.frame.contains(mouseLocation) }
// 查找包含指定窗口的屏幕:
let screen = window.screenKeyboard Shortcuts
键盘快捷键
There are 3 tiers, each for different use cases.
分为3个层级,适用于不同场景。
Tier 1: SwiftUI keyboard shortcuts (in-app, when focused)
层级1:SwiftUI键盘快捷键(应用内,聚焦时生效)
swift
Button("Settings") { openSettings() }
.keyboardShortcut(",", modifiers: [.command]) // Cmd+,swift
Button("设置") { openSettings() }
.keyboardShortcut(",", modifiers: [.command]) // Cmd+,Tier 2: NSEvent monitors (app-wide or global)
层级2:NSEvent监视器(应用内或全局)
swift
// 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)Returning from a local monitor consumes the event (stops propagation). Global monitors cannot consume events.
nilswift
// 本地:仅你的应用激活时触发
let monitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in
if event.keyCode == 53 { /* 退出键 */ return nil /* 拦截事件 */ }
return event // 传递事件
}
// 全局:其他应用激活时也触发
let monitor = NSEvent.addGlobalMonitorForEvents(matching: .keyDown) { event in
// 无法拦截事件——仅可读
}
// 关键:使用完毕后务必移除监视器
NSEvent.removeMonitor(monitor)从本地监视器返回会拦截事件(停止传播)。全局监视器无法拦截事件。
nilTier 3: Carbon hotkeys (system-wide, works when app is not focused)
层级3:Carbon热键(系统级,应用未聚焦时也生效)
The only way to register true global keyboard shortcuts. Uses the Carbon API (1990s-era, still not deprecated):
swift
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
)The callback runs on an arbitrary thread -- always dispatch to main:
swift
DispatchQueue.main.async { self.handleHotKey() }这是注册真正全局键盘快捷键的唯一方式,使用Carbon API(诞生于1990年代,至今未被弃用):
swift
import Carbon.HIToolbox
var hotKeyRef: EventHotKeyRef?
let hotKeyID = EventHotKeyID(signature: OSType(0x4D594150), id: 1) // "MYAP"
RegisterEventHotKey(
UInt32(kVK_ANSI_1), // 键码
UInt32(optionKey), // 修饰键
hotKeyID,
GetApplicationEventTarget(),
0,
&hotKeyRef
)回调会在任意线程执行——务必调度到主线程:
swift
DispatchQueue.main.async { self.handleHotKey() }File Pickers
文件选择器
Open (select files/directories)
打开(选择文件/目录)
swift
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
}swift
let panel = NSOpenPanel()
panel.allowedContentTypes = [.image, .movie] // UTType过滤
panel.allowsMultipleSelection = true
panel.canChooseDirectories = false
panel.canChooseFiles = true
panel.canCreateDirectories = true
panel.title = "选择图片"
// 模态(阻塞线程):
if panel.runModal() == .OK {
let urls = panel.urls
}
// 或表单模态(附加到窗口,异步):
panel.beginSheetModal(for: window) { response in
guard response == .OK else { return }
let urls = panel.urls
}Save
保存
swift
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
}swift
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 }
// 将文件写入url
}Directory picker
目录选择器
swift
let panel = NSOpenPanel()
panel.canChooseFiles = false
panel.canChooseDirectories = true
panel.directoryURL = URL(fileURLWithPath: NSHomeDirectory())swift
let panel = NSOpenPanel()
panel.canChooseFiles = false
panel.canChooseDirectories = true
panel.directoryURL = URL(fileURLWithPath: NSHomeDirectory())Clipboard / Pasteboard
剪贴板 / Pasteboard
macOS's pasteboard is fundamentally different from the web's . It is a multi-item, multi-type container.
navigator.clipboardswift
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) { ... }A single pasteboard item can advertise multiple types. When reading, check types in priority order.
macOS的剪贴板与Web的有本质区别,它是一个多项目、多类型的容器。
navigator.clipboardswift
let pasteboard = NSPasteboard.general
// 关键:写入前必须清空。忘记这一步是常见错误。
pasteboard.clearContents()
// 写入文本:
pasteboard.setString("hello", forType: .string)
// 写入图片数据:
let pngData = try Data(contentsOf: imageURL)
pasteboard.setData(pngData, forType: .png)
// 写入文件URL(用于Finder粘贴):
pasteboard.writeObjects([url as NSURL])
// 读取(检查可用类型):
if let string = pasteboard.string(forType: .string) { ... }
if let data = pasteboard.data(forType: .png) { ... }单个剪贴板项目可声明多种类型。读取时按优先级顺序检查类型。
Drag and Drop
拖放
SwiftUI (simple)
SwiftUI(简单版)
swift
// 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
}swift
// 使元素可拖拽:
Image(nsImage: image)
.draggable(fileURL) {
// 自定义拖拽预览
Image(nsImage: image).frame(width: 100, height: 75)
}
// 接受拖拽:
.dropDestination(for: URL.self) { urls, location in
handleDroppedFiles(urls)
return true
}AppKit (full control)
AppKit(完全控制版)
Register as a drop target:
swift
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
}
}注册为拖放目标:
swift
class MyView: NSView {
override init(frame: NSRect) {
super.init(frame: frame)
registerForDraggedTypes([.fileURL, .png, .tiff])
}
override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation {
.copy // 显示绿色+光标
}
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 Layout
NavigationSplitView + 检查器布局
The native macOS pattern for a sidebar + detail + inspector layout:
swift
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")
}
}
}
}
}The modifier creates a native right-side panel that slides in/out, automatically manages layout, and integrates with the window's toolbar.
.inspector()macOS原生的侧边栏+详情+检查器布局模式:
swift
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()Launch at Login
开机自启
Use (macOS 13+):
SMAppServiceswift
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()The state is unique to macOS -- the app has asked to launch at login, but the user must manually approve it in System Settings > General > Login Items.
requiresApprovalAlways disable in debug builds to avoid polluting the login item list during development.
使用(macOS 13+):
SMAppServiceswift
import ServiceManagement
// 检查状态:
switch SMAppService.mainApp.status {
case .enabled: /* 开机自启已启用 */
case .requiresApproval: /* 已注册但用户需在系统设置中批准 */
case .notRegistered, .notFound: /* 未注册 */
}
// 注册:
try SMAppService.mainApp.register()
// 取消注册:
try SMAppService.mainApp.unregister()requiresApproval开发调试时务必禁用此功能,避免污染登录项列表。
Quick Look Preview
Quick Look预览
Show a system Quick Look panel for any file (images, PDFs, videos, documents):
swift
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
}
}为任意文件(图片、PDF、视频、文档)显示系统Quick Look面板:
swift
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() // 必须先激活应用,否则面板会在其他窗口后方打开
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 Integration
NSWorkspace——系统集成
swift
// 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.accessibilityDisplayShouldReduceTransparencyswift
// 在默认浏览器中打开URL:
NSWorkspace.shared.open(URL(string: "https://example.com")!)
// 在Finder中显示文件(选中该文件):
NSWorkspace.shared.activateFileViewerSelecting([fileURL])
// 获取前台应用:
let app = NSWorkspace.shared.frontmostApplication
let name = app?.localizedName // 示例:"Safari"
// 检查辅助功能偏好设置:
NSWorkspace.shared.accessibilityDisplayShouldReduceMotion // 遵循「减少动态效果」
NSWorkspace.shared.accessibilityDisplayShouldReduceTransparencyUserDefaults + @AppStorage
UserDefaults + @AppStorage
Programmatic access
程序化访问
swift
// 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")swift
// 写入:
UserDefaults.standard.set(true, forKey: "autoSave")
UserDefaults.standard.set(0.8, forKey: "quality")
// 读取(键不存在时返回false/0/nil):
let autoSave = UserDefaults.standard.bool(forKey: "autoSave")
let quality = UserDefaults.standard.double(forKey: "quality")Reactive SwiftUI binding
响应式SwiftUI绑定
swift
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
}
}@AppStorageUserDefaultsswift
struct SettingsView: View {
@AppStorage("autoSave") private var autoSave = false
@AppStorage("quality") private var quality = 0.8
var body: some View {
Toggle("自动保存", isOn: $autoSave) // 自动持久化
Slider(value: $quality, in: 0...1) // 自动持久化
}
}@AppStorageUserDefaultsScreenCaptureKit
ScreenCaptureKit
Capture screen content at native Retina resolution:
swift
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)The points-to-pixels conversion is critical. ScreenCaptureKit works in points, but output dimensions must be in pixels for Retina resolution.
以原生Retina分辨率捕获屏幕内容:
swift
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) // 点 → 像素
config.height = Int(CGFloat(display.height) * scale)
config.showsCursor = false
let image = try await SCScreenshotManager.captureImage(contentFilter: filter, configuration: config)点到像素的转换至关重要。ScreenCaptureKit以点为单位工作,但输出尺寸必须以像素为单位才能获得Retina分辨率。
Common Mistakes Web Devs Make
Web开发者常犯的错误
| What they try | Why it fails | What to do instead |
|---|---|---|
SwiftUI | Steals focus, shows in Dock, no transparency | Use |
| macOS uses discrete window levels, not a flat stack | Set |
| Doesn't account for menu bar, Dock, or notch | Use |
| macOS requires | Always call |
| Only works when app is focused | Use Carbon |
| macOS has modal, sheet-modal, and async file pickers | Use |
| Single-monitor assumptions | macOS users commonly have 2-3 displays | Always use |
| CSS animation for everything | macOS has spring physics, reduced motion, per-window animation | Use SwiftUI |
| 尝试的操作 | 失败原因 | 正确做法 |
|---|---|---|
使用SwiftUI | 抢夺焦点、显示在Dock中、不支持透明 | 使用带 |
用 | macOS使用离散窗口层级,而非扁平堆叠 | 将 |
用 | 未考虑菜单栏、Dock或刘海 | 根据场景使用 |
用 | macOS要求先调用 | 写入前务必调用 |
用 | 仅应用聚焦时生效 | 使用Carbon的 |
套用 | macOS有模态、表单模态和异步文件选择器 | 使用对应展示模式的 |
| 假设单显示器 | macOS用户通常有2-3台显示器 | 始终使用 |
| 所有动画都用CSS思路 | macOS有弹簧物理、减少动态效果、窗口级动画 | 使用SwiftUI |