macOS 26
Build a menu bar app in 25 minutes
🎯 What You'll Build
A menu bar utility that:
- ✅ Lives in menu bar
- ✅ Shows quick info
- ✅ Global keyboard shortcuts
- ✅ Native macOS feel
🚀 Step 1: Menu Bar App
import SwiftUI
@main
struct MenuBarApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
var body: some Scene {
Settings {
SettingsView()
}
}
}
class AppDelegate: NSObject, NSApplicationDelegate {
var statusItem: NSStatusItem?
var popover: NSPopover?
func applicationDidFinishLaunching(_ notification: Notification) {
// Create menu bar item
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
if let button = statusItem?.button {
button.image = NSImage(systemSymbolName: "cloud.fill", accessibilityDescription: "Weather")
button.action = #selector(togglePopover)
button.target = self
}
// Create popover
popover = NSPopover()
popover?.contentSize = NSSize(width: 300, height: 400)
popover?.behavior = .transient
popover?.contentViewController = NSHostingController(rootView: PopoverView())
}
@objc func togglePopover() {
guard let button = statusItem?.button else { return }
if let popover = popover {
if popover.isShown {
popover.performClose(nil)
} else {
popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
}
}
}
}
struct PopoverView: View {
var body: some View {
VStack(spacing: 20) {
Text("72°")
.font(.system(size: 60, weight: .bold))
Text("Sunny")
.font(.title2)
Divider()
Button("Quit") {
NSApplication.shared.terminate(nil)
}
}
.padding()
}
}
🎨 Native macOS UI
Toolbar
struct ContentView: View {
var body: some View {
NavigationSplitView {
SidebarView()
} detail: {
DetailView()
}
.toolbar {
ToolbarItem(placement: .navigation) {
Button {
NSApp.keyWindow?.firstResponder?.tryToPerform(#selector(NSSplitViewController.toggleSidebar(_:)), with: nil)
} label: {
Image(systemName: "sidebar.left")
}
}
ToolbarItem {
Button("Add") {
// Add action
}
}
}
}
}
Window Management
struct ContentView: View {
var body: some View {
Text("Main Content")
.frame(minWidth: 600, minHeight: 400)
.onAppear {
// Set window properties
if let window = NSApplication.shared.windows.first {
window.title = "My App"
window.styleMask.insert(.fullSizeContentView)
window.titlebarAppearsTransparent = true
}
}
}
}
Context Menus
struct ItemView: View {
let item: Item
var body: some View {
Text(item.name)
.contextMenu {
Button("Edit") {
// Edit action
}
Button("Duplicate") {
// Duplicate action
}
Divider()
Button("Delete", role: .destructive) {
// Delete action
}
}
}
}
⌨️ Keyboard Shortcuts
struct ContentView: View {
var body: some View {
Text("Content")
.onAppear {
setupKeyboardShortcuts()
}
}
private func setupKeyboardShortcuts() {
// Command+N for new item
NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in
if event.modifierFlags.contains(.command) && event.charactersIgnoringModifiers == "n" {
createNewItem()
return nil
}
return event
}
}
private func createNewItem() {
// Create new item
}
}
// Or use SwiftUI commands
struct ContentView: View {
var body: some View {
Text("Content")
}
}
extension ContentView {
@CommandsBuilder
var commands: some Commands {
CommandMenu("Items") {
Button("New Item") {
createNewItem()
}
.keyboardShortcut("n", modifiers: .command)
Button("Delete Item") {
deleteItem()
}
.keyboardShortcut(.delete, modifiers: .command)
}
}
}
🎯 File Operations
Open File
struct FileOpenerView: View {
@State private var fileContent = ""
var body: some View {
VStack {
Text(fileContent)
Button("Open File") {
openFile()
}
}
}
private func openFile() {
let panel = NSOpenPanel()
panel.allowsMultipleSelection = false
panel.canChooseDirectories = false
panel.allowedContentTypes = [.text]
if panel.runModal() == .OK, let url = panel.url {
fileContent = (try? String(contentsOf: url)) ?? "Error reading file"
}
}
}
Save File
private func saveFile(content: String) {
let panel = NSSavePanel()
panel.allowedContentTypes = [.text]
panel.nameFieldStringValue = "document.txt"
if panel.runModal() == .OK, let url = panel.url {
try? content.write(to: url, atomically: true, encoding: .utf8)
}
}
🎨 Drag and Drop
struct DropZoneView: View {
@State private var droppedFiles: [URL] = []
var body: some View {
VStack {
Text("Drop files here")
.frame(width: 300, height: 200)
.background(.gray.opacity(0.2))
.cornerRadius(10)
.onDrop(of: [.fileURL], isTargeted: nil) { providers in
handleDrop(providers: providers)
return true
}
List(droppedFiles, id: \.self) { url in
Text(url.lastPathComponent)
}
}
}
private func handleDrop(providers: [NSItemProvider]) {
for provider in providers {
provider.loadItem(forTypeIdentifier: "public.file-url", options: nil) { item, error in
if let data = item as? Data,
let url = URL(dataRepresentation: data, relativeTo: nil) {
DispatchQueue.main.async {
droppedFiles.append(url)
}
}
}
}
}
}
🎯 System Integration
Notifications
import UserNotifications
func sendNotification() {
let content = UNMutableNotificationContent()
content.title = "Task Complete"
content.body = "Your export is ready"
content.sound = .default
let request = UNNotificationRequest(
identifier: UUID().uuidString,
content: content,
trigger: nil
)
UNUserNotificationCenter.current().add(request)
}
Dock Badge
// Set badge
NSApp.dockTile.badgeLabel = "5"
// Clear badge
NSApp.dockTile.badgeLabel = nil
Launch at Login
import ServiceManagement
func enableLaunchAtLogin() {
try? SMAppService.mainApp.register()
}
func disableLaunchAtLogin() {
try? SMAppService.mainApp.unregister()
}
var isLaunchAtLoginEnabled: Bool {
SMAppService.mainApp.status == .enabled
}
🎨 Multi-Window Support
@main
struct MultiWindowApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
.commands {
CommandGroup(replacing: .newItem) {
Button("New Window") {
openNewWindow()
}
.keyboardShortcut("n", modifiers: .command)
}
}
}
private func openNewWindow() {
let newWindow = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 600, height: 400),
styleMask: [.titled, .closable, .miniaturizable, .resizable],
backing: .buffered,
defer: false
)
newWindow.center()
newWindow.contentView = NSHostingView(rootView: ContentView())
newWindow.makeKeyAndOrderFront(nil)
}
}
🎯 Touch Bar (Legacy)
extension NSTouchBar.CustomizationIdentifier {
static let myApp = NSTouchBar.CustomizationIdentifier("com.myapp.touchbar")
}
extension NSTouchBarItem.Identifier {
static let playButton = NSTouchBarItem.Identifier("com.myapp.play")
}
class TouchBarController: NSObject, NSTouchBarDelegate {
func makeTouchBar() -> NSTouchBar {
let touchBar = NSTouchBar()
touchBar.customizationIdentifier = .myApp
touchBar.defaultItemIdentifiers = [.playButton]
touchBar.delegate = self
return touchBar
}
func touchBar(_ touchBar: NSTouchBar, makeItemForIdentifier identifier: NSTouchBarItem.Identifier) -> NSTouchBarItem? {
switch identifier {
case .playButton:
let button = NSButtonTouchBarItem(identifier: identifier, title: "Play", target: self, action: #selector(play))
return button
default:
return nil
}
}
@objc func play() {
// Play action
}
}
🎨 Mac Catalyst
Convert iOS app to macOS:
// In target settings:
// General → Deployment Info → Mac (Designed for iPad)
// Platform-specific code
#if targetEnvironment(macCatalyst)
// Mac-specific code
#else
// iOS-specific code
#endif
💡 Best Practices
1. Native macOS Patterns
// ✅ Use NavigationSplitView (not TabView)
NavigationSplitView {
SidebarView()
} detail: {
DetailView()
}
// ✅ Use toolbar (not bottom bar)
.toolbar {
ToolbarItem {
Button("Action") { }
}
}
2. Keyboard First
// Add keyboard shortcuts for everything
.keyboardShortcut("n", modifiers: .command)
.keyboardShortcut("w", modifiers: .command)
.keyboardShortcut("q", modifiers: .command)
3. Window Restoration
struct ContentView: View {
@SceneStorage("selectedTab") private var selectedTab = 0
var body: some View {
TabView(selection: $selectedTab) {
// Tabs
}
}
}
📚 Resources
🔗 Next Steps
Pro tip: macOS users expect keyboard shortcuts. Add them everywhere!