[Swift 입문] 21편 — MenuBarExtra와 메뉴바 앱

🤖 이 글은 Claude Code(AI)가 작성합니다. | 시리즈 목차 | 이전: [20편] macOS 앱 구조와 App 프로토콜

메뉴바 앱이란?

macOS의 오른쪽 상단 메뉴바에는 시계, 배터리, Wi-Fi 같은 시스템 아이콘들이 있습니다. 개발자도 이 영역에 자신의 앱 아이콘을 넣을 수 있습니다. 클릭하면 드롭다운 메뉴나 팝오버 창이 나타나는 형태입니다. Alfred, Bartender, 1Password 같은 앱이 이 방식을 사용합니다.

메뉴바 앱은 사용자가 메인 작업을 방해받지 않고 빠르게 접근할 수 있어서 유틸리티 앱에 적합합니다.


MenuBarExtra — SwiftUI 방식

macOS 13(Ventura)부터 MenuBarExtra Scene이 추가되어 AppKit 없이 순수 SwiftUI로 메뉴바 앱을 만들 수 있습니다.

기본 메뉴 스타일

import SwiftUI

@main
struct QuickNotesApp: App {
    var body: some Scene {
        MenuBarExtra("메모", systemImage: "note.text") {
            MenuContent()
        }
    }
}

struct MenuContent: View {
    @State private var notes: [String] = ["회의 준비", "코드 리뷰"]

    var body: some View {
        ForEach(notes, id: \.self) { note in
            Text(note)
        }

        Divider()

        Button("새 메모 추가") {
            notes.append("새 메모 \(notes.count + 1)")
        }

        Divider()

        Button("종료") {
            NSApplication.shared.terminate(nil)
        }
        .keyboardShortcut("q", modifiers: .command)
    }
}

기본 스타일은 macOS 표준 메뉴 형태로, 클릭하면 드롭다운 메뉴가 나타납니다.

윈도우 스타일 (팝오버)

@main
struct WeatherApp: App {
    var body: some Scene {
        MenuBarExtra("날씨", systemImage: "cloud.sun.fill") {
            WeatherPanel()
        }
        .menuBarExtraStyle(.window)  // 팝오버 창 스타일
    }
}

struct WeatherPanel: View {
    var body: some View {
        VStack(spacing: 16) {
            Text("서울")
                .font(.title2)
                .bold()

            Text("☀️")
                .font(.system(size: 60))

            Text("23°C")
                .font(.largeTitle)

            Text("맑음")
                .foregroundColor(.secondary)
        }
        .padding()
        .frame(width: 200)
    }
}

.menuBarExtraStyle(.window)를 추가하면 클릭 시 SwiftUI 뷰가 담긴 팝오버 창이 나타납니다.


메뉴바 아이콘 커스터마이징

@main
struct MyApp: App {
    @State private var isActive = false

    var body: some Scene {
        // SF Symbol 아이콘
        MenuBarExtra("앱", systemImage: isActive ? "circle.fill" : "circle") {
            MenuContent(isActive: $isActive)
        }

        // 텍스트 아이콘
        MenuBarExtra("⚡️") {
            MenuContent(isActive: $isActive)
        }
    }
}

아이콘은 SF Symbols 이름 또는 텍스트(이모지 포함)로 설정할 수 있습니다. 상태에 따라 아이콘을 동적으로 바꿀 수도 있습니다.


NSStatusItem — AppKit 방식 (고급)

MenuBarExtra가 지원하지 않는 세밀한 제어가 필요할 때는 AppKit의 NSStatusItem을 직접 사용합니다.

import AppKit
import SwiftUI

class StatusBarController {
    private var statusItem: NSStatusItem
    private var popover: NSPopover

    init() {
        // 상태 바 아이템 생성
        statusItem = NSStatusBar.system.statusItem(
            withLength: NSStatusItem.squareLength
        )

        // 아이콘 설정
        if let button = statusItem.button {
            button.image = NSImage(systemSymbolName: "star.fill",
                                   accessibilityDescription: "내 앱")
            button.action = #selector(togglePopover)
            button.target = self
        }

        // 팝오버 설정
        popover = NSPopover()
        popover.contentSize = NSSize(width: 300, height: 400)
        popover.behavior = .transient  // 다른 곳 클릭 시 자동으로 닫힘
        popover.contentViewController = NSHostingController(
            rootView: PopoverView()  // SwiftUI 뷰를 팝오버에 넣기
        )
    }

    @objc func togglePopover() {
        if popover.isShown {
            popover.performClose(nil)
        } else {
            if let button = statusItem.button {
                popover.show(relativeTo: button.bounds,
                            of: button,
                            preferredEdge: .minY)
            }
        }
    }
}

AppDelegate에서 초기화

class AppDelegate: NSObject, NSApplicationDelegate {
    var statusBarController: StatusBarController?

    func applicationDidFinishLaunching(_ notification: Notification) {
        statusBarController = StatusBarController()
    }
}

@main
struct MyApp: App {
    @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate

    var body: some Scene {
        // 메인 윈도우 없이 메뉴바만 사용할 경우 빈 WindowGroup
        // (Info.plist에 LSUIElement = true 필요)
        Settings {
            SettingsView()
        }
    }
}

NSHostingController — AppKit에 SwiftUI 뷰 삽입

AppKit 기반 코드에서 SwiftUI 뷰를 사용하려면 NSHostingController를 사용합니다.

// SwiftUI 뷰를 NSViewController로 감싸기
let hostingController = NSHostingController(rootView: MySwiftUIView())

// NSWindow에 넣기
let window = NSWindow(
    contentRect: NSRect(x: 0, y: 0, width: 400, height: 300),
    styleMask: [.titled, .closable, .resizable],
    backing: .buffered,
    defer: false
)
window.contentViewController = hostingController
window.center()
window.makeKeyAndOrderFront(nil)

반대로 SwiftUI 안에서 AppKit 뷰를 사용하려면 NSViewRepresentable을 사용합니다(다음 편에서 다룹니다).


메뉴바 앱 완성 예제

// 클립보드 히스토리 앱
@Observable
class ClipboardStore {
    var history: [String] = []
    private var timer: Timer?

    func startMonitoring() {
        timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
            self.checkClipboard()
        }
    }

    private func checkClipboard() {
        guard let text = NSPasteboard.general.string(forType: .string),
              text != history.first else { return }
        history.insert(text, at: 0)
        if history.count > 20 { history.removeLast() }
    }
}

@main
struct ClipboardApp: App {
    @State private var store = ClipboardStore()

    var body: some Scene {
        MenuBarExtra("📋", isInserted: .constant(true)) {
            ClipboardMenu(store: store)
        }
        .menuBarExtraStyle(.window)
    }
}

struct ClipboardMenu: View {
    let store: ClipboardStore

    var body: some View {
        VStack(alignment: .leading, spacing: 0) {
            Text("클립보드 히스토리")
                .font(.headline)
                .padding()

            Divider()

            if store.history.isEmpty {
                Text("기록 없음")
                    .foregroundColor(.secondary)
                    .padding()
            } else {
                ScrollView {
                    VStack(alignment: .leading, spacing: 0) {
                        ForEach(store.history.prefix(10), id: \.self) { item in
                            Button {
                                NSPasteboard.general.clearContents()
                                NSPasteboard.general.setString(item, forType: .string)
                            } label: {
                                Text(item)
                                    .lineLimit(1)
                                    .frame(maxWidth: .infinity, alignment: .leading)
                            }
                            .buttonStyle(.plain)
                            .padding(.horizontal)
                            .padding(.vertical, 6)

                            Divider()
                        }
                    }
                }
            }
        }
        .frame(width: 280, height: 320)
        .onAppear { store.startMonitoring() }
    }
}

핵심 요약

  • MenuBarExtra: macOS 13+에서 SwiftUI만으로 메뉴바 앱 구현 가능
  • 기본 스타일: 드롭다운 메뉴 / .window 스타일: 팝오버 창
  • NSStatusItem: AppKit 방식으로 더 세밀한 제어 가능
  • NSHostingController: AppKit 코드 안에 SwiftUI 뷰를 삽입하는 다리
  • LSUIElement = true: Dock 아이콘 없이 메뉴바만 사용
  • NSPasteboard: 클립보드 읽기/쓰기 API

다음 편에서는 NSPanel과 커스텀 윈도우를 만드는 방법, 그리고 노치 영역을 활용한 오버레이 UI를 다룹니다.

🤖 Generated with Claude Code

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 항목은 *(으)로 표시합니다