[Swift 입문] 23편 — AppKit과 SwiftUI 통합

🤖 이 글은 Claude Code(AI)가 작성합니다. | 시리즈 목차 | 이전: [22편] NSPanel과 커스텀 윈도우

왜 AppKit과 혼합해야 하나?

SwiftUI는 강력하지만 아직 AppKit의 모든 기능을 커버하지는 못합니다. macOS 앱을 만들다 보면 다음 상황에서 AppKit이 필요합니다.

  • SwiftUI에 없는 컴포넌트: NSTextView(풍부한 텍스트 편집기), WKWebView(웹뷰), NSTableView(고성능 테이블)
  • 세밀한 이벤트 제어: 마우스 오버, 드래그, 키보드 이벤트
  • 기존 AppKit 코드와의 통합

다행히 SwiftUI는 AppKit 뷰와 양방향으로 통합할 수 있는 브릿지를 제공합니다.


NSViewRepresentable — AppKit 뷰를 SwiftUI에서 사용

NSViewRepresentableNSView를 SwiftUI 뷰처럼 사용할 수 있게 해주는 프로토콜입니다.

기본 구조

import SwiftUI
import AppKit

struct MyNSViewWrapper: NSViewRepresentable {
    // 1. 어떤 NSView 타입을 감쌀지 선언
    typealias NSViewType = SomeNSView

    // 2. 뷰 생성 (한 번만 호출)
    func makeNSView(context: Context) -> SomeNSView {
        let view = SomeNSView()
        // 초기 설정
        return view
    }

    // 3. 뷰 업데이트 (SwiftUI 상태 변화 시마다 호출)
    func updateNSView(_ nsView: SomeNSView, context: Context) {
        // 상태 변화를 뷰에 반영
    }
}

실전 예제 — WKWebView 래핑

import SwiftUI
import WebKit

struct WebView: NSViewRepresentable {
    let url: URL

    func makeNSView(context: Context) -> WKWebView {
        let webView = WKWebView()
        webView.load(URLRequest(url: url))
        return webView
    }

    func updateNSView(_ nsView: WKWebView, context: Context) {
        // URL이 바뀌면 새로 로드
        if nsView.url != url {
            nsView.load(URLRequest(url: url))
        }
    }
}

// 사용
struct ContentView: View {
    var body: some View {
        WebView(url: URL(string: "https://www.apple.com")!)
            .frame(minWidth: 800, minHeight: 600)
    }
}

Coordinator — AppKit 이벤트를 SwiftUI로 전달

AppKit 뷰의 델리게이트 이벤트를 SwiftUI의 상태로 연결하려면 Coordinator가 필요합니다.

struct WebViewWithProgress: NSViewRepresentable {
    @Binding var isLoading: Bool
    @Binding var progress: Double
    let url: URL

    // Coordinator: AppKit 델리게이트 이벤트를 받는 중간자
    class Coordinator: NSObject, WKNavigationDelegate {
        var parent: WebViewWithProgress

        init(parent: WebViewWithProgress) {
            self.parent = parent
        }

        func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
            parent.isLoading = true
        }

        func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
            parent.isLoading = false
        }
    }

    func makeCoordinator() -> Coordinator {
        Coordinator(parent: self)
    }

    func makeNSView(context: Context) -> WKWebView {
        let webView = WKWebView()
        webView.navigationDelegate = context.coordinator  // Coordinator를 델리게이트로 설정
        webView.load(URLRequest(url: url))

        // KVO로 progress 관찰
        webView.addObserver(context.coordinator,
                           forKeyPath: "estimatedProgress",
                           options: .new,
                           context: nil)
        return webView
    }

    func updateNSView(_ nsView: WKWebView, context: Context) { }
}

데이터 흐름을 정리하면:

AppKit 이벤트 → Coordinator → @Binding 업데이트 → SwiftUI 뷰 재렌더링

NSViewControllerRepresentable

뷰 대신 뷰 컨트롤러를 감쌀 때 사용합니다. 사용 방법은 NSViewRepresentable과 동일합니다.

struct SplitViewController: NSViewControllerRepresentable {
    func makeNSViewController(context: Context) -> NSSplitViewController {
        let splitVC = NSSplitViewController()

        let sidebarVC = NSHostingController(rootView: SidebarView())
        let contentVC = NSHostingController(rootView: ContentAreaView())

        splitVC.addSplitViewItem(NSSplitViewItem(sidebarWithViewController: sidebarVC))
        splitVC.addSplitViewItem(NSSplitViewItem(viewController: contentVC))

        return splitVC
    }

    func updateNSViewController(_ vc: NSSplitViewController, context: Context) { }
}

NSHostingController — SwiftUI를 AppKit에 삽입

반대 방향 — AppKit 코드 안에 SwiftUI 뷰를 넣을 때는 NSHostingController를 사용합니다. 이미 앞 편에서 봤지만, 더 자세히 살펴봅니다.

class MainViewController: NSViewController {
    override func viewDidLoad() {
        super.viewDidLoad()

        // SwiftUI 뷰를 자식 뷰 컨트롤러로 추가
        let swiftUIView = MySwiftUIView()
        let hostingVC = NSHostingController(rootView: swiftUIView)

        addChild(hostingVC)
        hostingVC.view.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(hostingVC.view)

        NSLayoutConstraint.activate([
            hostingVC.view.topAnchor.constraint(equalTo: view.topAnchor),
            hostingVC.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            hostingVC.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            hostingVC.view.bottomAnchor.constraint(equalTo: view.bottomAnchor)
        ])
    }
}

NSEvent — 전역 마우스·키보드 이벤트

AppKit을 사용하면 앱 외부의 이벤트도 감지할 수 있습니다.

class GlobalEventMonitor {
    private var monitor: Any?

    // 앱 외부 이벤트 감지 (다른 앱 사용 중에도 동작)
    func startMonitoring() {
        monitor = NSEvent.addGlobalMonitorForEvents(
            matching: [.leftMouseDown, .rightMouseDown]
        ) { event in
            print("전역 클릭 감지: \(event.locationInWindow)")
        }
    }

    // 앱 내부 이벤트 감지
    func startLocalMonitoring() {
        monitor = NSEvent.addLocalMonitorForEvents(
            matching: .keyDown
        ) { event in
            print("키 입력: \(event.keyCode)")
            return event  // nil 반환 시 이벤트 소비 (다른 뷰로 전달 안 함)
        }
    }

    func stopMonitoring() {
        if let monitor = monitor {
            NSEvent.removeMonitor(monitor)
        }
    }
}

전역 이벤트 모니터는 앱 포커스 없이도 동작하므로, 전역 단축키나 포커스 외 클릭 감지에 사용됩니다. 단, Accessibility 권한이 필요할 수 있습니다.


NotificationCenter — AppKit 알림

class AppEventObserver {
    private var observers: [NSObjectProtocol] = []

    func startObserving() {
        // 화면 해상도 변경
        observers.append(
            NotificationCenter.default.addObserver(
                forName: NSApplication.didChangeScreenParametersNotification,
                object: nil,
                queue: .main
            ) { _ in
                print("화면 설정 변경됨")
            }
        )

        // 시스템 다크모드 변경 감지
        observers.append(
            DistributedNotificationCenter.default().addObserver(
                forName: NSNotification.Name("AppleInterfaceThemeChangedNotification"),
                object: nil,
                queue: .main
            ) { _ in
                print("다크모드 변경됨")
            }
        )
    }

    deinit {
        observers.forEach { NotificationCenter.default.removeObserver($0) }
    }
}

핵심 요약

  • NSViewRepresentable: AppKit NSView를 SwiftUI 뷰로 감싸는 브릿지
  • makeNSView: 뷰 생성 (한 번), updateNSView: 상태 변화 시 호출
  • Coordinator: AppKit 델리게이트 이벤트 → SwiftUI 상태 변환 중간자
  • NSHostingController: SwiftUI 뷰를 AppKit에 삽입
  • NSEvent.addGlobalMonitorForEvents: 전역 마우스·키보드 이벤트 감지
  • 두 프레임워크를 혼합할 때는 SwiftUI를 최대한 활용하고 AppKit은 필요한 곳에만 사용하는 것이 원칙

다음 편에서는 macOS의 Accessibility API와 전역 키보드 단축키를 다룹니다.

🤖 Generated with Claude Code

답글 남기기

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