NSResponder – Cocoa에서 키보드 이벤트를 처리하는 방법

사용자가 키보드를 두드리면 macOS는 각 키 타이핑에 대한 키 이벤트를 받게 된다. 이벤트 처리의 기본은 이벤트에 대해서 그 이벤트를 핸들링하는 어떤 함수가 실행되는 것이다. 시스템에 들어온 키 이벤트를 누가 어떻게 처리하게 될까?

제 1 응답자

마우스 이벤트의 경우, 이벤트를 받아서 처리해야 하는 주체가 분명하다. 마우스는 마우스 포인터를 통해서 화면 상에 표시되는 뷰와 상호작용한다. 하지만 키보드 이벤트는 어떤가? 키보드 이벤트를 처리하는 주체는 상황과 문맥에 따라 달라질 수 있다. 다만 사용자로서 우리는 어떠한 경우에 어떤 뷰가 키보드 타이핑을 받을 수 있는지 알 수 있다. 주로 파란색으로 포커스 링이 그려진 텍스트 필드나 텍스트 뷰가 그 역할을 맡게 되며, 그 때 해당 필드 내부에는 커서가 깜빡거리고 있는 것을 보게 된다.

이렇게 특정한 윈도우 안에서 키보드 타이핑에 의한 입력을 처리하는 주된 뷰를 제 1 응답자라고 한다. 제 1 응답자는 윈도우의 아웃렛으로도 존재하며, 일종의 플레이스 홀더에 해당한다. macOS에서 모든 키 입력을 모두 제 1 응답자가 처리하느냐? 꼭 그런것은 아니다. 키보드 이벤트는 대체로 우리가 타이핑하는 각각의 키에 해당하지만, Shift, Cmd, Option 키등과 함께 눌려지는 단축키들도 있다. 단축키는 특정한 뷰가 처리하는 레벨의 단축키도 있지만, 앱에서 처리되는 단축키도 있고 또 시스템 전역으로 처리되는 단축키도 있다. 즉 대부분의 키보드 이벤트는 제 1 응답자가 처리하지만, 그 보다 우선적으로 처리되기로 정해진 이벤트들은 이벤트 전달 체인에서 그 상위에 위치한 노드들에서 처리된다. 대략 다음과 같은 순서대로 처리될 것이다.

  1. 시스템 전역에서 처리되어야 하는 키는 시스템 레벨에서 처리된다.
  2. 그외에는 활성화된 앱으로 넘어온다.
  3. 앱에서 먼저 처리해야 할 키 (특정 메뉴 호출 등)는 메뉴에서 처리한다.
  4. 만약 문서 기반 앱인 경우,문서 컨트롤러가 그 다음 책임을 맡게 되고, 이내 활성화된 문서가 그 뒤를 따른다.
  5. 각 문서는 윈도 컨트롤러를 가질 수 있는데, 윈도 컨트롤러가 그 다음번 이벤트 처리자가 될 수 있다.
  6. 다음은 현재 문서/앱의 키 윈도우가 그 처리자가 될 수 있다.
  7. 윈도우 내에서는 뷰 컨트롤러, 그리고 뷰 순으로 처리 레벨이 내려오는데, 이 때 키 윈도 내의 제 1 응답자인 뷰가 키 이벤트를 처리하게 된다.

NSResponder

이벤트 체인과 처리에 관계되는 클래스인 NSWindow, NSWindowController, NSView는[^NSViewController] 모두 NSResponder의 서브 클래스이다. 이 클래스는 이벤트 처리에 관련한 기능을 담고 있는 베이스 클래스이다. 키보드 이벤트 처리와 관련된 이 클래스의 메소드들은 크게 다음과 같이 분류될 수 있다.

  1. 제1응답자 관련한 프로퍼티 및 메소드
  2. 실제 키 이벤트를 받았을 때 처리하기
  3. 해석된 키 이벤트에 따른 동작을 처리하기

[NSViewController]: NSResponder의 클래스 레퍼런스에서는 NSViewController에 대한 언급이 없는데, 최근에 NSViewController 역시 응답 체인에 추가되었다.

First Responder

NSResponder는 제 1 응답자가 되는 것과 관련해서 세 개의 메소드 및 프로퍼티를 가지고 있다. 사실 Objective-C에서 이들은 모두 “메소드”로 취급해도 문제가 없었는데, Swift로 넘어오면서 구분 기준이 조금 모호한 느낌이 있다. 각각의 의미는 다음과 같다.

  • acceptFirstResponder – ( )가 없으므로 프로퍼티이다. 제 1 응답자가 되려하기 직전에 체크된다. true 값이 리턴되면 제 1 응답자가 되기를 수락한다.
  • resignFirstResponder() – 제 1 응답자 상태에서 벗어나기 직전에 호출된다.
  • becomeFirstResponder() – 제 1 응답자 상태로 진입하기 직전에 호출된다.

각각의 메소드가 Bool 값을 리턴해야 한다는 점에 주의하자. 이들 메소드는 상태 변경을 알릴 때 호출되는 것이 아니라, 상태 변경 직전에 호출된다. 예를 들어서 필수로 입력해야 하는 항목을 비워놓은채로 다른 필드를 선택하지 못하게 하는 경우를 생각해본다면, resignFirstResponder()false를 리턴하여 상태 변경을 허용하지 않을 수 있어야 한다는 말이다.

Key Events 처리

사실 키 이벤트 처리 방법은 생각보다 단순하다. 키 이벤트 핸들러인 keyDown(with:)가 호출될 때, 눌려진 키의 정보는 인자로 넘겨지는 NSEvent 객체의 character속성에 들어있다. 이를 직접 사용하는 방법이 가능한데, 애플은 이방법을 그리 권장하지는 않는다. 대신에 macOS의 입력 관리 시스템을 활용하는 방법을 권장한다. NSResponderinterpretKeyEvents(_:)라는 메소드를 제공하고 있다. keyDown(with:)을 오버라이딩하면서 해당 메소드를 호출하도록하는 것으로 키 이벤트 처리 코드를 모두 대신할 수 있다. 이 메소드에서 전달된 이벤트는 시스템에 의해서 해석된다. 그리고 그 해석 결과에 따라 수행해야 할 액션 메소드가 다시 시스템에 의해서 호출된다.

해석된 이벤트에 따라 처리해야할 액션 메시지는 NSResponder의 레퍼런스 중에서 Responding to Action Messagess 부분을 보면 된다. 주로 사용하는 메소드에는 다음과 같은 것들이 있다.

  • insertText(_:Any) – 입력된 키가 글자 타이핑으로 처리되는 경우. 인자는 문자열이며 NSString 혹은 NSAttributedString 타입이다.
  • insertTab(_:Any?) – 탭 키가 입력됐을 때. 인자는 sender 이다.
  • insertBacktab(_:Any?) – 백탭(Shift + tab)이 입력된 경우
  • deleteBackward(_:Any?) – 백스페이스 키를 통해 한 글자를 지운 경우
  • deleteForward(_:Any?) – fn + delete를 통해 앞으로 한 글자를 지우는 경우

샘플 앱

한개의 문자를 표시할 수 있는 커스텀 뷰를 장착한 간단한 앱을 만들어보겠다. (이 예제는 아론 힐리가스의 OSX Programming With Cocoa의 19장, 20장에서의 예제이다. 단, Swift4.0 기준으로 작성했으며, macOS 10.12 기준이다. 국내에 번역된 3판의 경우 Objective-C로 작성된 XCode 3.0 기준의 내용이라, 좀 다른 부분들이 있다.)

이 앱에 포함될 몇 가지 기능이다.

  1. 커스텀 뷰는 제 1 응답자가 되어 키 입력을 받을 수 있다. 입력 받은 키의 문자는 뷰에 그려진다.
  2. 커스텀 뷰가 활성화되었을 때와 그렇지 않을 때를 배경색을 이용해서 시각적으로 구분한다.
  3. 포커스링도 그려준다.
  4. 탭 키를 이용해서 키 뷰 변경을 가능하게 한다.
  5. PDF를 저장한다.

BigLetterView

책에서와 비슷하게 BigLetterView.swift 라는 새로운 파일을 하나 만든다. 이는 우리가 만들 커스텀 뷰에 관한 클래스이다. 기본적으로 제 1 응답자가 되어 키 입력을 받고, 입력받은 문자를 내부에 저장한 후 이를 다시 뷰를 통해 그리는 일을 수행한다.

프로퍼티

기본적으로 다음의 프로퍼티가 필요하다.

  1. 그려낼 문자열을 저정하는 String 타입 프로퍼티. 값이 변경될 때, 뷰를 새로 그리도록 한다.
  2. 배경색. 현재 제 1 응답자인지 여부에 따라 다른색을 쓸 것이므로 computed property가 되어야 한다.
  3. 현재 제 1 응답자인지 여부. 역시 computed.

그외에 문자를 그리는 부분에서 추가적인 프로퍼티가 필요할텐데, 이는 그 때 가서 추가로 정의하기로 한다.  우선 아래는 기본적인 프로퍼티와 그리기 함수의 기본 내용이다.

class BigLetterView: NSView {
  var string: String = " " {
    didSet {
      needsDisplay = true
      // OBJC에서 [setNeedsDisplay: YES];가 이렇게 바뀐다.
    }
  }
  var isFirstResponder: Bool {
    if let fr = window?.firstResponder {
      return fr === self
    }
    return false
  }
  var bgColor: NSColor {
    return isFirstResponder ? NSColor.white : NSColor.lightGray
  }

  override func draw(_ dirtyRect: NSRect) {
    // 배경색을 그린다.
    bgColor.set()
    NSBezierPath.fill(bounds)
    // 글자를 그린다.
    // ...
  }
}

제 1 응답자 관련

제 1 응답자가 되거나, 되었다가 그만둘 때 배경색이 달라져야 하므로 다음과 같이 오버라이딩한다.

/// in BigLetterView
override var acceptFirstResponder: Bool { return true }

override func becomeFirstResonder() -> Bool {
  needsDisplay = true
  return true
}

override func resignFirstResponder() -> Bool {
  needsDisplay = true
  return true
}

키 이벤트 처리

제 1 응답자가 되었을 때 키 이벤트를 처리한다. 처리해야 하는 이벤트의 종류는 앞에서 설명한 주요 이벤트와 동일하다.

/// in BigLetterView

override func keyDown(with event: NSEvent) {
  interpretKeyEvents([event])
}

override func insertString(_ insertString) {
  if let s = insertString as? String {
    string = s
  }
}

override func insertTab(_ sender: Any?) {
  // 다음 키 뷰로 옮기는 액션은 NSWindow가 한다.
  window?.selectKeyView(following: self)
}

override func insertBacktab(_ sender: Any?) {
  window?.selectKeyView(preceding: self)
}

override func deleteBackward(_ sender: Any?){
  s = " "
}

override func deleteForward(_ sender: Any?) {
  s = " "
}

참고로 다음 키 뷰(nextKeyView) 속성은 인터페이스 빌더에서 연결해주면 된다. 이 때 뷰 끼리의 연결은 우버튼 드래그나 ctrl 드래그로는 할 수 없으므로 (오토 레이아웃 연결 모드가 된다.) connection 인스펙터를 열어서 처리하도록 한다.

포커스 링 그리기

포커스링은 라이언이후의 macOS에서는 앱킷에서 자동으로 그려준다. 따라서 그에 필요한 API를 오버라이드한다.

// in BigLetterView
override var focusRingMaskBounds: NSRect { return bounds }
override func drawFocusRingMask() { 
  NSBezierPath.fill(bounds)
}

속성있는 문자열 그리기

속성있는 문자열을 만들어서 이를 뷰에 그리는 작업을 해보겠다. 속성 있는 문자열(NSAttributedString)을 만들기 위해서는 표시할 문자열과 그 시각적 속성정보가 필요하다. 속성있는 문자열은 뷰에 그리는 시점에 별도로 생성하는 계산 프로퍼티가 될 것이고, 시각 속성정보는 한 번 생성해두면 계속 재사용할 정보이다. 따라서 다음과 같이 정의한다. 참고로 공백 문자인 경우에는 그릴 필요가 없으니 옵셔널로 정의해버리자.

// in BigLetterView

lazy var attributes: [NSAttributedStringKey: Any] = {
  return [
    .font: NSFont.systemFont(ofSize: 75.0),
    .foregroundColor: NSColor.red
  ]
}()

var attributedText: NSAttributedString? {
  guard string != " " else { return nil }
  return NSAttributedString(string: string, attributes: attributes)
}

속성 있는 문자열을 그리기 위해서는 뷰의 draw(_:) 등과 같이 드로잉 명령 콜이 가능한 컨텍스트에서 해당 문자열 객체의 draw(at:) / draw(in:) 메소드를 사용하면 된다. 따라서 뷰의 어디에, 얼마만큼 그릴 것이냐 하는 부분이 필요하다. 그려질 위치를 정하기 위해서는 그려지는 문자열의 화면상의 크기를 알고 있어야 한다. (그래야 한 가운데에 그리지) 이 크기 역시 size() 메소드를 이용해서 얻을 수 있다. 다음 프로퍼티를 추가한다.

// in BigLetterView

/// 그려질 문자의 프레임
var characterFrame: NSRect {
  guard let size = attributedText?.size() { return NSRect.zero }
  let (width, height) = (size.width, size.height)
  let x = bounds.midX - width / 2
  let y = boudns.midY - height / 2
  return NSRect(x:x, y:y, width:width, height:height)
}

자 그러면 최종적으로 문자를 그리는 코드는 매우 간단하게 처리할 수 있다.

override func draw(_ dirtyRect: NSRect) {
  bgColor.set()
  NSBezierPath.fill(bounds)
  attributedText?.draw(in: characterFrame)
}

인터페이스 구성

인터페이스 빌더에서 아래와 같이 뷰들을 구성한다.

두 개의 텍스트 필드는 탭 키를 눌러서 포커스를 옮길 수 있도록 하는 부분을 확인하는데 사용하려 한다. 커스텀뷰 > 텍스트필드1 > 텍스트필드2 > 커스텀뷰 와 같이 nextKeyView 속성이 꼬리에 꼬리를 물도록 설정한다. 대부분의 코드는 뷰 클래스 내에 패키징 되어 있으므로 추가적인 설정은 필요하지 않을 것이다. 프로젝트 소스 코드는 다음 링크에서 내려받을 수 있다.

BigLetterView 사용 앱 소스 : https://app.box.com/s/gfmf6sst4gq9brqt2hvjfg10iw71rs4d

보너스

문자를 그리는 커스텀 뷰를 만들면서 draw(_:) 메소드를 직접 구현했다. 이렇게 직접 그리기 명령을 통해서 자신을 그릴 수 있는 뷰들은 손쉽게 PDF로 만들 수 있다. NSView의 dataWithPDF(inside:)draw(_:)를 PDF 문서를 위한 컨텍스트 상에서 호출하여 뷰의 내용을 그대로 PDF 데이터로 복제할 수 있다.   PDF 파일을 저장하기 위해서는 다음의 준비 단계를 거치면 된다.

  1.  기본적으로 Xcode9의 코코아 앱은 샌드박스 앱이다. 따라서 User selected Files에 대한 쓰기 권한을 주도록 설정해야 한다.
  2. NSSavePanel을 이용해서 저장할 파일 위치를 결정한다.
  3. 이후 실제 저장한다.

첫번째로 앱에 쓰기 권한을 주기 위해서는 프로젝트 설정에서 Capability 탭에서 File Access 항목을 본다. 사용자 선택 파일에는 기본적으로 Read 권한만이 주어지는데,  이에 대해서 Read/Write로 설정을 변경해서 파일을 쓸 수 있는 앱으로 빌드할 수 있어야 한다. (그렇지 않은 경우, NSSavePanel을 만드는 순간, 에러가 난다.)

2, 3의 과정은 하나의 함수 내에서 처리 가능하다. 다만, NSSavePanel의 완료 핸들러는 원칙적으로 escaping 이기 때문에 명시적으로 self 에 대해서는 약한 참조를 하도록 해야 한다는 점마나 주의하자.

/// save PDF in BigLetterView

@IBAction func savePDF(_ sender: Any?) {
  let panel = NSSavePanel()
  panel.allowedFileTypes = ["pdf"]
  guard let window = window else { return }
  // 클로저이므로 [unowned self] 혹은 [weak self]를 명시하고
  // self의 속성에 대해서는 self를 명시해야 한다.
  // 이 클로저의 실행 시점은 self의 라이프사이클과 일치하지 않을 수 있음에 주의.
  panel.beginSheetModal(for: window) { [unowned self] res in
    switch res {
    case .OK:
      let data = self.dataWithPDF(inside:self.bounds)
      do {
        data.write(to:panel.url!)
      } catch {
         // 에러가 발생한 경우 표시한다. 
         let na = NSAlert(error: error)
         na.beginSheetModal(for: self.window!)
      }
    default:
      return
  }
}

이 함수를 메뉴 상에서 새로운 Menu Item을 추가한 후,  그와 연결해준다. 그리고 제대로 저장되는지 확인해보자. (예시)

참고자료

NSView의 내용을 PDF로 만들기 (Swift)

뷰로부터 pdf 데이터 얻기

AppKit의 모든 그리기 명령은 PDF로 변환가능하다. 그리고 이렇게 변환된 PDF 데이터는 프린터로 보내지거나 파일에 기록될 수 있다. PDF는 해상도에 의존하지 않기 때문에 어느 기기에서 봐도 제법 괜찮은 품질을 보여준다는 점을 잊지 말자.

draw:를 사용해서 뷰를 그리는 것을 할 수 있다면, PDF는 공짜로 얻을 수 있다. 이 방법은 매우 쉬운데 NSView는 다음과 같은 메소드를 가지고 있다.

  • func dataWithPDF(inside: NSRect) -> Data

이 메소드는 draw(:)를 호출해서 이를 통해 그래픽 데이터를 얻는다. 뷰 그리기 명령에서 얻어지는 모든 내역은 화면이 아닌 데이터 객체로 들어간다. 이렇게 얻은 내용은 간단히 파일에 저장할 수 있다.

@IBAction func savePDF(_ sender: Any) {
    let panel = NSSavePanel()
    panel.allowedFileTypes = ["pdf"]
    panel.allowsOtherFileTypes = false
    panel.beginSheetModal(for: window!) { clicked in
        if clicked == NSFileHandleingPanelOKButton, let url = panel.url {
            let data = self.dataWithPDF(inside: self.bounds)
            do {
                try data.write(to:url)
            } catch {
                let a = NSAlert(error: error as! NSError)
                a.runModal()
            }
        }
    }
}

(macOS | Swift ) NSSavePanel / NSOpenPanel

이게 무려 5년이나 된 글이고, 그 사이에 저장/열기 패널은 액션시트로 변경되면서 많은 부분이 바뀌었기에 다시 한 번 정리합니다. (2016-11-23)

macOS앱에서 일반적인 파일 포맷을 다루는 경우1, 파일을 저장하거나 열려고 할 때 파일의 위치를 지정해주기 위해서 저장 / 열기 패널을 열게 된다. 이 패널은 모달하게 동작하면서 저장위치를 선택할 수 있게 하는 macOS의 공통 UI를 사용한다. NSOpenPanel, NSSavePanel은 이 때 사용하는 클래스이며, 다음과 같이 동작한다.

  1. 패널을 모달하게 열어 사용자로 하여금 저장/로딩할 파일을 설정하게 한다.
  2. 델리게이트를 통해 사용자의 선택이 완료되었을 때, 처리할 동작을 위임할 수 있다.

파일 열기

NSOpenPanel 은 파일을 열 때 사용하는 패널로 다음과 같은 식으로 동작하게 할 수 있다.

  1. 델리게이트를 정의한다.
  2. 패널 인스턴스를 생성한다.
  3. 패널을 설정한다. (초기 위치 및 UI 상의 여러 메시지 들)
  4. 패널을 보여준다.

사용자가 ok 혹은 cancel 버튼을 클릭하면 패널은 델리게이트에 대해서 각 액션별로 정해진 동작을 수행하게 하고 UI를 닫는다.2

델리게이트

사실 begin(completionHandler:)를 호출하게 되면 단순히 패널을 띄우는 것이 아니라 이 때부터 패널 객체는 델리게이트와 끊임없이 서로 대화하면서 상황을 공유하게 된다. 이 때의 자세한 상황을 들여다보면 다음과 같은 케이스에서 호출되는 델리게이트 메소드들이 정의되어 있다.

  1. 특정 파일/디렉토리의 URL이 패널 내에서 활성화될 것인지 여부를 확인한다. (Open시)
  2. 사용자가 선택한 주소가 유효한지를 확인한다.
  3. 사용자가 디렉토리를 변경하면, 이를 델리게이트에게 알려준다.
  4. 사용자가 패널을 확장하려하면[^3] 이를 델리게이트에게 알려준다.
  5. 사용자가 선택했던 파일을 변경하면 이를 델리게이트에게 알려준다.
  6. 파일을 선택하고 버튼을 클릭해 확인하면 이를 델리게이트에게 알려준다

사실 이 경우에 델리게이트는 파일 선택과 관련된 여러 액션의 디테일을 조정하는 역할을 수행하며, 동시에 파일 선택을 완료했을 때의 정보도 획득해갈 수 있지만, 보통은 다음과 같이 델리게이트 없이도 충분히 사용이 가능하다. runModal()의 경우에 패널은 모달하게 동작한다. 패널이 모달하게 표시되는 동안에 원래 윈도는 사용자와 상호작용할 수 없으므로 runModal()은 블럭킹 함수로 실행된다. 이 함수의 리턴 값은 어떤 버튼이 눌려졌는지를 구분해주는 정수값이다.

func openDocument() {
    let panel = NSOpenPanel()
    panel.title = "Open"
    panel.nameFieldLabel = "File to open"
    panel.message = "select files to opne"
    if NSFileHandlingPannelOKButton == panel.runModal() {
        for url in panel.urls {
           // URL별 처리
        }
    }
}

실제 패널 실행은 크게 세 가지 모드가 사용될 수 있다.

  1. 앱 전체에 대해 모달하게 표시한다: runModal() 사용
  2. 특정 창에 대해서만 모달하게 표시한다. : beginSheetModal(for:,completionHandler:)
  3. 모드리스 창으로 실행한다. : begin(completionHandler:)

암튼 대략의 내용은 이러하고 보다 자세한 내용은 레퍼런스 페이지를 참고하도록 한다.


레퍼런스

  1. NSOpenSavePanelDelegate : https://developer.apple.com/reference/appkit/nsopensavepaneldelegate
  2. NSOpenPanel : https://developer.apple.com/reference/appkit/nsopenpanel
  3. NSSavePanel : https://developer.apple.com/reference/appkit/nssavepanel

  1. 만약 앱 전용으로만 사용하는 별도의 포맷을 사용한다면, 굳이 파일을 여기저기에 저장하는 타입보다는 앱 자체를 슈박스타입(사진 앱처럼 데이터 저장소를 앱 내에서 제어하는 앱)으로 제작하는 편이 좋을 수 있다. 
  2. 실제로 델리게이트는 패널이 열리기 전에 열릴지여부를 확인하는 것부터 디렉토리가 변경되는 것, ok를 클릭했을 때 해당 주소가 유효한지 등도 체크할 수 있다.