cr8rcho
개발

macOS 스크린샷 앱에서 호버 상태가 사라지는 문제 해결하기

2월 17일
5 분

문제

macOS 스크린샷 앱을 만들다 보면 한 가지 까다로운 문제를 만난다. 버튼 위에 마우스를 올려놓은 호버 상태를 캡처하고 싶은데, 캡처 UI가 뜨는 순간 호버가 사라져 버리는 것이다.

이유는 단순하다. 캡처를 위한 오버레이 창이 뜨면서 NSApp.activate()가 호출되고, 기존 앱이 비활성화되면서 호버 상태가 해제된다. macOS에서 호버 효과는 해당 앱이 활성 상태일 때만 유지되기 때문이다.

사용자: 버튼 호버  캡처 단축키  오버레이 활성화  기존  비활성화  호버 소멸

디자인 시스템 문서화, UI 리뷰, 버그 리포트 등에서 호버 상태 캡처는 꽤 자주 필요한데, 이게 안 되면 상당히 불편하다.

원인 분석

기존 코드에서 오버레이 창은 NSWindow를 상속하고 있었고, 창을 띄울 때 다음과 같은 흐름을 사용했다:

// 기존 방식: 앱을 활성화하고 기다림
private func activateAppAndWait() async {
    guard !NSApp.isActive else { return }
    NSApp.activate(ignoringOtherApps: true)
    for _ in 0..<10 {
        if NSApp.isActive { return }
        try? await Task.sleep(nanoseconds: 10_000_000)
    }
}

그리고 오버레이 창을 표시할 때:

// 기존 방식: 활성화 후 창 표시
await activateAppAndWait()
regionSelectorWindow = RegionSelectorWindow()
// 오버레이 창 표시
window.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)

NSApp.activate(ignoringOtherApps: true)가 호출되는 순간, macOS 윈도우 서버는 캡처 앱을 최전면으로 올리고 다른 앱들은 비활성 상태로 전환한다. 이때 호버, 포커스 링, 툴팁 등 활성 상태에 의존하는 모든 시각적 효과가 사라진다.

해결: NSPanel + nonactivatingPanel

핵심은 오버레이 창이 다른 앱의 활성 상태를 빼앗지 않도록 만드는 것이다. macOS에는 이를 위한 정확한 도구가 있다: NSPanel.nonactivatingPanel 스타일 마스크.

NSWindow → NSPanel 전환

// Before
class RegionOverlayWindow: NSWindow {
    init(screen: NSScreen) {
        super.init(
            contentRect: screen.frame,
            styleMask: .borderless,
            backing: .buffered,
            defer: false
        )
    }
}

// After
class RegionOverlayWindow: NSPanel {
    init(screen: NSScreen) {
        super.init(
            contentRect: screen.frame,
            styleMask: [.borderless, .nonactivatingPanel],
            backing: .buffered,
            defer: false
        )
        hidesOnDeactivate = false
    }
}

세 가지 변경이 필요하다:

  1. NSWindowNSPanel: NSPanelNSWindow의 서브클래스로, 보조 창(팔레트, 인스펙터 등)을 위해 설계되었다.
  2. .nonactivatingPanel 스타일 마스크: 이 창이 표시되어도 앱 활성화가 발생하지 않는다.
  3. hidesOnDeactivate = false: NSPanel은 기본적으로 앱이 비활성화되면 자동으로 숨는데, 이를 방지한다.

창 표시 방식 변경

// Before
window.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)

// After
window.orderFrontRegardless()
window.makeKey()
  • orderFrontRegardless(): 앱 활성화 없이 창을 최전면으로 올린다.
  • makeKey(): 키보드 입력을 받을 수 있게 하되, 앱 전체를 활성화하지는 않는다.

activateAppAndWait() 제거

오버레이가 앱 활성화를 필요로 하지 않게 되었으므로, activateAppAndWait() 메서드와 그 호출부를 모두 제거했다.

// 삭제됨
await activateAppAndWait()

결과

사용자: 버튼 호버  캡처 단축키  오버레이 표시 (비활성화 패널)  기존  활성 유지  호버 유지

이제 호버, 포커스 링, 활성 상태 하이라이트 등이 모두 보존된 상태로 캡처된다. 캡처 앱의 오버레이는 떠 있지만, macOS 윈도우 서버 관점에서 기존 앱이 여전히 활성 앱이기 때문이다.

NSPanel과 nonactivatingPanel 정리

속성NSWindowNSPanel + nonactivatingPanel
표시 시 앱 활성화OX
다른 앱 비활성화OX
키보드 입력OO (makeKey)
앱 비활성 시 자동 숨김XO (기본값, hidesOnDeactivate로 제어)

NSPanel은 Xcode의 인스펙터, Finder의 정보 창, 색상 피커 등에서 널리 사용되는 패턴이다. 스크린샷 도구처럼 "다른 앱을 방해하지 않으면서 위에 떠 있어야 하는" UI에 딱 맞는 선택이다.

마무리

호버 캡처 문제의 근본 원인은 "오버레이 창 = 앱 활성화"라는 기본 동작이었다. NSPanel.nonactivatingPanel은 이 연결 고리를 끊어주는 정확한 해결책이다.

비슷한 문제를 겪고 있다면 기억할 것은 세 가지:

  1. NSWindowNSPanel
  2. .nonactivatingPanel 스타일 마스크 추가
  3. NSApp.activate() 호출 제거

이 조합이면 대상 앱의 상태를 보존하면서 오버레이를 띄울 수 있다.